mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
Second pass over the suite, driven by the inventory in the gaps document. Nine agents wrote suites in parallel against private databases, then a tenth read all of it adversarially and five of its findings were fixed. unit + component 2076 -> 2079 (+888 over the round) api 647 -> 1015 e2e 18 -> 29 What was closed: - lib/route-access.ts, the page-level authorization layer, went from zero tests to 48. Every API route was guarded and none of the pages were. - The five media proxy routes now have a real 2xx beside every 403. The blocker was the positive control, solved by stubbing r2Client.send() and leaving lib/r2-media-proxy.ts itself real. - Every remaining server-side lib module: invitations, email verification, the upload tokens, the logger, request origin, the whole R2 and Bunny lifecycle, notifications and admin stats. - Six video-page hooks, and the chunking arithmetic extracted out of lib/client/r2-video-upload.ts as a pure module. - Five end-to-end flows: workspace members, bulk operations, the admin area, player interaction and failure recovery. Three things about the harness itself turned out to be wrong: - Two @/lib/r2 stubs in tests/setup/api.ts had the wrong return shape, so every route reaching finalizeR2VideoUpload silently took the "not a valid video" branch and no test noticed. - The auth matrix asserted only "not 2xx", which two entries satisfied without their guard existing. It now requires 401 or 403, which makes both load-bearing, and all 60 routes pass the stricter form. - Both admin API routes had no positive control anywhere: replacing their guard with an unconditional refusal left the entire suite green. Found by the adversarial review, now covered. Process: - bun run test:mutation runs StrykerJS over the authorization and validation modules. Diagnostic, not a gate, weekly in CI rather than on a push. - playwright.config.ts gains an opt-in webkit project for the player spec. - AGENTS.md now requires a batch of new tests to be reviewed by somebody who did not write them. Only two production files change, both deliberate: lib/auth.ts loses a verbatim copy of its own permission formulas, and lib/client/r2-video-upload.ts calls the extracted arithmetic. No behaviour change in either.
761 lines
26 KiB
TypeScript
761 lines
26 KiB
TypeScript
// lib/r2.ts is stubbed wholesale in tests/setup/api.ts so the API suite never
|
|
// speaks S3. This file stubs the boundary instead: the real S3Client is
|
|
// constructed and the real presigner runs, only `send()` is replaced. That way
|
|
// the assertions are about the command objects the module builds (bucket, key,
|
|
// part number, range, abort path), which is where the bugs would be.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
AbortMultipartUploadCommand,
|
|
CompleteMultipartUploadCommand,
|
|
CreateBucketCommand,
|
|
CreateMultipartUploadCommand,
|
|
DeleteObjectCommand,
|
|
GetBucketCorsCommand,
|
|
GetObjectCommand,
|
|
HeadBucketCommand,
|
|
HeadObjectCommand,
|
|
PutBucketCorsCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from '@aws-sdk/client-s3';
|
|
|
|
// lib/r2.ts snapshots every R2_* variable into a module-level const when it is
|
|
// evaluated, so these have to be in place before the import below runs.
|
|
// vi.hoisted() is the only hook that fires early enough. All dummy values.
|
|
vi.hoisted(() => {
|
|
process.env.R2_ENDPOINT = 'http://minio.test:9000';
|
|
process.env.R2_ACCESS_KEY_ID = 'unit-test-access-key';
|
|
process.env.R2_SECRET_ACCESS_KEY = 'unit-test-secret-key';
|
|
process.env.R2_BUCKET_NAME = 'openframe-unit';
|
|
delete process.env.R2_ACCOUNT_ID;
|
|
delete process.env.R2_PRESIGN_ENDPOINT;
|
|
delete process.env.R2_PUBLIC_BASE_URL;
|
|
});
|
|
|
|
import {
|
|
R2_BUCKET_NAME,
|
|
abortMultipartVideoUpload,
|
|
completeMultipartVideoUpload,
|
|
createMultipartVideoUpload,
|
|
createPresignedImagePutUrl,
|
|
createPresignedUploadPartUrl,
|
|
createPresignedVideoPutUrl,
|
|
deleteR2Object,
|
|
deleteVideoObject,
|
|
ensureR2BucketExists,
|
|
ensureR2UploadCors,
|
|
getR2PublicObjectUrl,
|
|
getR2UploadCorsOrigins,
|
|
headVideoObject,
|
|
readVideoObjectBytes,
|
|
uploadAudio,
|
|
} from '@/lib/r2';
|
|
|
|
const BUCKET = 'openframe-unit';
|
|
const VIDEO_KEY = 'videos/11111111-2222-4333-8444-555555555555.mp4';
|
|
|
|
let send: ReturnType<typeof vi.spyOn>;
|
|
|
|
/** The command object handed to the Nth send() call. */
|
|
function commandAt(index: number): { input: Record<string, unknown> } {
|
|
return send.mock.calls[index][0] as { input: Record<string, unknown> };
|
|
}
|
|
|
|
function inputAt(index: number): Record<string, unknown> {
|
|
return commandAt(index).input;
|
|
}
|
|
|
|
/** An AWS SDK error carries its HTTP status under $metadata, not on the Error. */
|
|
function s3Error(httpStatusCode: number | undefined): Error {
|
|
const error = new Error('s3 rejected the request');
|
|
if (httpStatusCode !== undefined) {
|
|
Object.assign(error, { $metadata: { httpStatusCode } });
|
|
}
|
|
return error;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
send = vi.spyOn(S3Client.prototype, 'send').mockResolvedValue({} as never);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('R2_BUCKET_NAME', () => {
|
|
it('re-exports the configured bucket so callers do not read the env twice', () => {
|
|
expect(R2_BUCKET_NAME).toBe(BUCKET);
|
|
});
|
|
});
|
|
|
|
describe('getR2PublicObjectUrl', () => {
|
|
it('serves objects from the endpoint and bucket when no public base url is set', () => {
|
|
expect(getR2PublicObjectUrl('voice/note.webm')).toBe(
|
|
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
|
);
|
|
});
|
|
|
|
it('strips leading slashes so the key is never doubled up', () => {
|
|
expect(getR2PublicObjectUrl('///voice/note.webm')).toBe(
|
|
'http://minio.test:9000/openframe-unit/voice/note.webm'
|
|
);
|
|
});
|
|
|
|
// The remaining branches read env captured at module load, so they need a
|
|
// fresh module registry rather than a stubEnv on the already-loaded copy.
|
|
async function loadWith(env: Record<string, string | undefined>) {
|
|
vi.resetModules();
|
|
for (const [name, value] of Object.entries(env)) {
|
|
vi.stubEnv(name, value);
|
|
}
|
|
return import('@/lib/r2');
|
|
}
|
|
|
|
it('prefers R2_PUBLIC_BASE_URL over the endpoint and trims its trailing slashes', async () => {
|
|
const r2 = await loadWith({ R2_PUBLIC_BASE_URL: 'https://cdn.example.com//' });
|
|
|
|
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe('https://cdn.example.com/images/a.png');
|
|
});
|
|
|
|
it('builds the Cloudflare virtual-host url when only an account id is configured', async () => {
|
|
const r2 = await loadWith({
|
|
R2_ENDPOINT: undefined,
|
|
R2_PUBLIC_BASE_URL: undefined,
|
|
R2_ACCOUNT_ID: 'acct-123',
|
|
});
|
|
|
|
expect(r2.getR2PublicObjectUrl('images/a.png')).toBe(
|
|
'https://openframe-unit.acct-123.r2.cloudflarestorage.com/images/a.png'
|
|
);
|
|
});
|
|
|
|
it('throws rather than emitting a half-formed url when nothing is configured', async () => {
|
|
const r2 = await loadWith({
|
|
R2_ENDPOINT: undefined,
|
|
R2_PUBLIC_BASE_URL: undefined,
|
|
R2_ACCOUNT_ID: undefined,
|
|
});
|
|
|
|
expect(() => r2.getR2PublicObjectUrl('images/a.png')).toThrow(
|
|
'Missing R2_PUBLIC_BASE_URL or R2_ACCOUNT_ID'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('ensureR2BucketExists', () => {
|
|
it('stops after the head when the bucket already exists', async () => {
|
|
await ensureR2BucketExists();
|
|
|
|
expect(send).toHaveBeenCalledTimes(1);
|
|
expect(commandAt(0)).toBeInstanceOf(HeadBucketCommand);
|
|
expect(inputAt(0)).toEqual({ Bucket: BUCKET });
|
|
});
|
|
|
|
it.each([404, 301, 403])('creates the bucket when the head answers %i', async (status) => {
|
|
send.mockRejectedValueOnce(s3Error(status)).mockResolvedValueOnce({} as never);
|
|
|
|
await ensureR2BucketExists();
|
|
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
|
expect(inputAt(1)).toEqual({ Bucket: BUCKET });
|
|
});
|
|
|
|
it('rethrows an unexpected head failure instead of trying to create the bucket', async () => {
|
|
send.mockRejectedValueOnce(s3Error(500));
|
|
|
|
await expect(ensureR2BucketExists()).rejects.toThrow('s3 rejected the request');
|
|
expect(send).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('treats a failure with no http status as a missing bucket', async () => {
|
|
// A DNS or socket failure has no $metadata, so the guard falls through.
|
|
send.mockRejectedValueOnce(s3Error(undefined)).mockResolvedValueOnce({} as never);
|
|
|
|
await ensureR2BucketExists();
|
|
|
|
expect(commandAt(1)).toBeInstanceOf(CreateBucketCommand);
|
|
});
|
|
});
|
|
|
|
describe('uploadAudio', () => {
|
|
it('writes under the voice prefix and returns the public url', async () => {
|
|
const url = await uploadAudio(Buffer.from('audio'), 'note.webm');
|
|
|
|
expect(commandAt(0)).toBeInstanceOf(PutObjectCommand);
|
|
expect(inputAt(0)).toMatchObject({
|
|
Bucket: BUCKET,
|
|
Key: 'voice/note.webm',
|
|
ContentType: 'audio/webm',
|
|
});
|
|
expect(url).toBe('http://minio.test:9000/openframe-unit/voice/note.webm');
|
|
});
|
|
|
|
it('keeps only the basename so a traversal cannot escape the voice prefix', async () => {
|
|
await uploadAudio(Buffer.from('audio'), '../../etc/passwd');
|
|
|
|
expect(inputAt(0).Key).toBe('voice/passwd');
|
|
});
|
|
|
|
it('strips a windows-style path separator too', async () => {
|
|
await uploadAudio(Buffer.from('audio'), 'C:\\Users\\x\\note.webm');
|
|
|
|
expect(inputAt(0).Key).toBe('voice/note.webm');
|
|
});
|
|
|
|
it('strips a dot run left behind after the basename is taken', async () => {
|
|
await uploadAudio(Buffer.from('audio'), 'a..b.webm');
|
|
|
|
expect(inputAt(0).Key).toBe('voice/ab.webm');
|
|
});
|
|
|
|
it('rejects a filename that sanitises down to nothing', async () => {
|
|
await expect(uploadAudio(Buffer.from('audio'), 'dir/')).rejects.toThrow('Invalid filename');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('honours an explicit content type', async () => {
|
|
await uploadAudio(Buffer.from('audio'), 'note.mp3', 'audio/mpeg');
|
|
|
|
expect(inputAt(0).ContentType).toBe('audio/mpeg');
|
|
});
|
|
});
|
|
|
|
describe('createPresignedVideoPutUrl', () => {
|
|
it('refuses a key outside the videos prefix', async () => {
|
|
await expect(
|
|
createPresignedVideoPutUrl('images/a.png', 'video/mp4', BigInt(1))
|
|
).rejects.toThrow('Invalid video object key');
|
|
});
|
|
|
|
it('refuses a key that only mentions the prefix further along', async () => {
|
|
await expect(
|
|
createPresignedVideoPutUrl('evil/videos/a.mp4', 'video/mp4', BigInt(1))
|
|
).rejects.toThrow('Invalid video object key');
|
|
});
|
|
|
|
it.each([BigInt(0), BigInt(-1)])('refuses a content length of %s', async (length) => {
|
|
await expect(createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', length)).rejects.toThrow(
|
|
'Invalid video content length'
|
|
);
|
|
});
|
|
|
|
it('refuses a content length past the safe integer range', async () => {
|
|
await expect(
|
|
createPresignedVideoPutUrl(
|
|
VIDEO_KEY,
|
|
'video/mp4',
|
|
BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)
|
|
)
|
|
).rejects.toThrow('Invalid video content length');
|
|
});
|
|
|
|
it('accepts the largest representable content length', async () => {
|
|
await expect(
|
|
createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(Number.MAX_SAFE_INTEGER))
|
|
).resolves.toContain('X-Amz-Signature=');
|
|
});
|
|
|
|
it('signs a one hour PUT against the bucket and key by default', async () => {
|
|
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
|
|
|
// Everything here is deterministic; the signature itself deliberately is not.
|
|
expect(url.origin).toBe('http://minio.test:9000');
|
|
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
|
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
|
expect(url.searchParams.get('x-id')).toBe('PutObject');
|
|
expect(url.searchParams.get('X-Amz-Signature')).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('honours a caller-supplied expiry window', async () => {
|
|
const url = new URL(
|
|
await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024), 900)
|
|
);
|
|
|
|
expect(url.searchParams.get('X-Amz-Expires')).toBe('900');
|
|
});
|
|
|
|
it('binds the content length into the signature so the size cannot be swapped', async () => {
|
|
const url = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
|
|
|
expect(url.searchParams.get('X-Amz-SignedHeaders')?.split(';')).toContain('content-length');
|
|
});
|
|
|
|
it('produces a different signature for a different key', async () => {
|
|
const a = new URL(await createPresignedVideoPutUrl(VIDEO_KEY, 'video/mp4', BigInt(1024)));
|
|
const b = new URL(
|
|
await createPresignedVideoPutUrl('videos/other.mp4', 'video/mp4', BigInt(1024))
|
|
);
|
|
|
|
expect(a.searchParams.get('X-Amz-Signature')).not.toBe(b.searchParams.get('X-Amz-Signature'));
|
|
});
|
|
});
|
|
|
|
describe('createPresignedImagePutUrl', () => {
|
|
it('refuses a key outside the images prefix', async () => {
|
|
await expect(createPresignedImagePutUrl(VIDEO_KEY, 'image/png')).rejects.toThrow(
|
|
'Invalid image object key'
|
|
);
|
|
});
|
|
|
|
it('signs a PUT against the bucket and key', async () => {
|
|
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png', 120));
|
|
|
|
expect(url.origin).toBe('http://minio.test:9000');
|
|
expect(url.pathname).toBe(`/${BUCKET}/images/avatar.png`);
|
|
expect(url.searchParams.get('X-Amz-Expires')).toBe('120');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('defaults to a one hour window', async () => {
|
|
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
|
|
|
expect(url.searchParams.get('X-Amz-Expires')).toBe('3600');
|
|
});
|
|
|
|
// Documents current behaviour rather than endorsing it: ContentType is passed
|
|
// to the command but the presigner does not sign it, so the grant does not
|
|
// pin the uploaded media type. See the note in the review notes.
|
|
it('does not bind the content type into the signature', async () => {
|
|
const url = new URL(await createPresignedImagePutUrl('images/avatar.png', 'image/png'));
|
|
|
|
expect(url.searchParams.get('X-Amz-SignedHeaders')).toBe('host');
|
|
});
|
|
});
|
|
|
|
describe('createMultipartVideoUpload', () => {
|
|
it('refuses a key outside the videos prefix', async () => {
|
|
await expect(createMultipartVideoUpload('images/a.png', 'video/mp4')).rejects.toThrow(
|
|
'Invalid video object key'
|
|
);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns the upload id the service assigned', async () => {
|
|
send.mockResolvedValueOnce({ UploadId: 'upload-abc' } as never);
|
|
|
|
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).resolves.toBe('upload-abc');
|
|
expect(commandAt(0)).toBeInstanceOf(CreateMultipartUploadCommand);
|
|
expect(inputAt(0)).toEqual({
|
|
Bucket: BUCKET,
|
|
Key: VIDEO_KEY,
|
|
ContentType: 'video/mp4',
|
|
});
|
|
});
|
|
|
|
it('throws when the service answers without an upload id', async () => {
|
|
send.mockResolvedValueOnce({} as never);
|
|
|
|
await expect(createMultipartVideoUpload(VIDEO_KEY, 'video/mp4')).rejects.toThrow(
|
|
'Failed to create multipart upload'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createPresignedUploadPartUrl', () => {
|
|
it('refuses a key outside the videos prefix', async () => {
|
|
await expect(createPresignedUploadPartUrl('images/a.png', 'upload-1', 1)).rejects.toThrow(
|
|
'Invalid video object key'
|
|
);
|
|
});
|
|
|
|
it.each([0, -1, 10001, 1.5, Number.NaN])('refuses part number %s', async (partNumber) => {
|
|
await expect(createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber)).rejects.toThrow(
|
|
'Invalid part number'
|
|
);
|
|
});
|
|
|
|
it.each([1, 10000])('accepts the boundary part number %i', async (partNumber) => {
|
|
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-1', partNumber));
|
|
|
|
expect(url.searchParams.get('partNumber')).toBe(String(partNumber));
|
|
});
|
|
|
|
it('signs the part number and upload id into the query string', async () => {
|
|
const url = new URL(await createPresignedUploadPartUrl(VIDEO_KEY, 'upload-abc', 7, 600));
|
|
|
|
expect(url.origin).toBe('http://minio.test:9000');
|
|
expect(url.pathname).toBe(`/${BUCKET}/${VIDEO_KEY}`);
|
|
expect(url.searchParams.get('partNumber')).toBe('7');
|
|
expect(url.searchParams.get('uploadId')).toBe('upload-abc');
|
|
expect(url.searchParams.get('X-Amz-Expires')).toBe('600');
|
|
expect(url.searchParams.get('x-id')).toBe('UploadPart');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('completeMultipartVideoUpload', () => {
|
|
it('refuses a key outside the videos prefix', async () => {
|
|
await expect(
|
|
completeMultipartVideoUpload('images/a.png', 'upload-1', [{ partNumber: 1, etag: 'e1' }])
|
|
).rejects.toThrow('Invalid video object key');
|
|
});
|
|
|
|
it('refuses an empty part list rather than completing an empty object', async () => {
|
|
await expect(completeMultipartVideoUpload(VIDEO_KEY, 'upload-1', [])).rejects.toThrow(
|
|
'No parts provided for multipart completion'
|
|
);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sorts the parts by number because S3 rejects an out-of-order manifest', async () => {
|
|
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', [
|
|
{ partNumber: 3, etag: 'etag-3' },
|
|
{ partNumber: 1, etag: 'etag-1' },
|
|
{ partNumber: 2, etag: 'etag-2' },
|
|
]);
|
|
|
|
expect(commandAt(0)).toBeInstanceOf(CompleteMultipartUploadCommand);
|
|
expect(inputAt(0)).toEqual({
|
|
Bucket: BUCKET,
|
|
Key: VIDEO_KEY,
|
|
UploadId: 'upload-abc',
|
|
MultipartUpload: {
|
|
Parts: [
|
|
{ PartNumber: 1, ETag: 'etag-1' },
|
|
{ PartNumber: 2, ETag: 'etag-2' },
|
|
{ PartNumber: 3, ETag: 'etag-3' },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('does not reorder the array the caller passed in', async () => {
|
|
const parts = [
|
|
{ partNumber: 2, etag: 'etag-2' },
|
|
{ partNumber: 1, etag: 'etag-1' },
|
|
];
|
|
|
|
await completeMultipartVideoUpload(VIDEO_KEY, 'upload-abc', parts);
|
|
|
|
expect(parts.map((part) => part.partNumber)).toEqual([2, 1]);
|
|
});
|
|
});
|
|
|
|
describe('abortMultipartVideoUpload', () => {
|
|
it('refuses a key outside the videos prefix', async () => {
|
|
await expect(abortMultipartVideoUpload('images/a.png', 'upload-1')).rejects.toThrow(
|
|
'Invalid video object key'
|
|
);
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('aborts the named upload on the named key', async () => {
|
|
await abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc');
|
|
|
|
expect(commandAt(0)).toBeInstanceOf(AbortMultipartUploadCommand);
|
|
expect(inputAt(0)).toEqual({
|
|
Bucket: BUCKET,
|
|
Key: VIDEO_KEY,
|
|
UploadId: 'upload-abc',
|
|
});
|
|
});
|
|
|
|
it('propagates a failed abort so the caller can retry or alarm', async () => {
|
|
send.mockRejectedValueOnce(s3Error(500));
|
|
|
|
await expect(abortMultipartVideoUpload(VIDEO_KEY, 'upload-abc')).rejects.toThrow(
|
|
's3 rejected the request'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('headVideoObject', () => {
|
|
it('returns null for a key outside the videos prefix without touching the network', async () => {
|
|
await expect(headVideoObject('images/a.png')).resolves.toBeNull();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports the length as a bigint and passes the content type through', async () => {
|
|
send.mockResolvedValueOnce({ ContentLength: 4096, ContentType: 'video/mp4' } as never);
|
|
|
|
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
|
contentLength: BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
});
|
|
expect(commandAt(0)).toBeInstanceOf(HeadObjectCommand);
|
|
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
|
});
|
|
|
|
it('falls back to zero when the service omits the length', async () => {
|
|
send.mockResolvedValueOnce({ ContentType: 'video/mp4' } as never);
|
|
|
|
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
|
contentLength: BigInt(0),
|
|
contentType: 'video/mp4',
|
|
});
|
|
});
|
|
|
|
it('falls back to zero rather than throwing on a negative length', async () => {
|
|
send.mockResolvedValueOnce({ ContentLength: -1 } as never);
|
|
|
|
await expect(headVideoObject(VIDEO_KEY)).resolves.toEqual({
|
|
contentLength: BigInt(0),
|
|
contentType: undefined,
|
|
});
|
|
});
|
|
|
|
it('returns null for a missing object', async () => {
|
|
send.mockRejectedValueOnce(s3Error(404));
|
|
|
|
await expect(headVideoObject(VIDEO_KEY)).resolves.toBeNull();
|
|
});
|
|
|
|
it('rethrows any other failure so a broken bucket is not read as an empty one', async () => {
|
|
send.mockRejectedValueOnce(s3Error(403));
|
|
|
|
await expect(headVideoObject(VIDEO_KEY)).rejects.toThrow('s3 rejected the request');
|
|
});
|
|
});
|
|
|
|
describe('readVideoObjectBytes', () => {
|
|
function bodyOf(bytes: Uint8Array) {
|
|
return { Body: { transformToByteArray: async () => bytes } };
|
|
}
|
|
|
|
it('returns null for a key outside the videos prefix', async () => {
|
|
await expect(readVideoObjectBytes('images/a.png', 16)).resolves.toBeNull();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([0, -5])('returns null for a byte length of %i', async (byteLength) => {
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, byteLength)).resolves.toBeNull();
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('requests an inclusive range that is one byte shorter than the length asked for', async () => {
|
|
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1, 2, 3, 4])) as never);
|
|
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toEqual(new Uint8Array([1, 2, 3, 4]));
|
|
expect(commandAt(0)).toBeInstanceOf(GetObjectCommand);
|
|
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY, Range: 'bytes=0-3' });
|
|
});
|
|
|
|
it('asks for a single byte when one byte is requested', async () => {
|
|
send.mockResolvedValueOnce(bodyOf(new Uint8Array([1])) as never);
|
|
|
|
await readVideoObjectBytes(VIDEO_KEY, 1);
|
|
|
|
expect(inputAt(0).Range).toBe('bytes=0-0');
|
|
});
|
|
|
|
it('returns null when the response carries no body', async () => {
|
|
send.mockResolvedValueOnce({} as never);
|
|
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
|
});
|
|
|
|
it('returns null when the body cannot be collected into bytes', async () => {
|
|
send.mockResolvedValueOnce({ Body: {} } as never);
|
|
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
|
});
|
|
|
|
it.each([404, 416])('returns null when the range read answers %i', async (status) => {
|
|
send.mockRejectedValueOnce(s3Error(status));
|
|
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).resolves.toBeNull();
|
|
});
|
|
|
|
it('rethrows any other read failure', async () => {
|
|
send.mockRejectedValueOnce(s3Error(500));
|
|
|
|
await expect(readVideoObjectBytes(VIDEO_KEY, 4)).rejects.toThrow('s3 rejected the request');
|
|
});
|
|
});
|
|
|
|
describe('deleteVideoObject and deleteR2Object', () => {
|
|
it('deletes a video key', async () => {
|
|
await deleteVideoObject(VIDEO_KEY);
|
|
|
|
expect(commandAt(0)).toBeInstanceOf(DeleteObjectCommand);
|
|
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: VIDEO_KEY });
|
|
});
|
|
|
|
it('refuses an image key through the video-specific entry point', async () => {
|
|
await expect(deleteVideoObject('images/a.png')).rejects.toThrow('Invalid video object key');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('deletes an image key through the general entry point', async () => {
|
|
await deleteR2Object('images/a.png');
|
|
|
|
expect(inputAt(0)).toEqual({ Bucket: BUCKET, Key: 'images/a.png' });
|
|
});
|
|
|
|
// The allowlist is the whole safety story for delete: anything that is not a
|
|
// video or an image key must never reach DeleteObject.
|
|
it.each([
|
|
'voice/note.webm',
|
|
'',
|
|
'/videos/a.mp4',
|
|
'other/videos/a.mp4',
|
|
'../videos/a.mp4',
|
|
'videos',
|
|
])('refuses to delete %s', async (key) => {
|
|
await expect(deleteR2Object(key)).rejects.toThrow('Invalid object key');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('getR2UploadCorsOrigins', () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv('NEXTAUTH_URL', undefined);
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
|
vi.stubEnv('NODE_ENV', 'test');
|
|
});
|
|
|
|
it('reduces each configured url to its origin', () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com/some/path');
|
|
|
|
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
|
});
|
|
|
|
it('deduplicates urls that share an origin', () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://app.example.com/');
|
|
|
|
expect(getR2UploadCorsOrigins()).toEqual(['https://app.example.com']);
|
|
});
|
|
|
|
it('keeps the port, which is what makes a local origin distinct', () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'http://localhost:3000');
|
|
|
|
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000']);
|
|
});
|
|
|
|
it('appends caller-supplied origins after the configured ones', () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
|
|
|
expect(getR2UploadCorsOrigins(['https://extra.example.com'])).toEqual([
|
|
'https://app.example.com',
|
|
'https://extra.example.com',
|
|
]);
|
|
});
|
|
|
|
it('skips blank and unparseable entries instead of throwing', () => {
|
|
vi.stubEnv('NEXTAUTH_URL', ' ');
|
|
|
|
expect(getR2UploadCorsOrigins(['not a url', ''])).toEqual([]);
|
|
});
|
|
|
|
it('adds the loopback development origins only in development', () => {
|
|
vi.stubEnv('NODE_ENV', 'development');
|
|
|
|
expect(getR2UploadCorsOrigins()).toEqual(['http://localhost:3000', 'http://127.0.0.1:3000']);
|
|
});
|
|
});
|
|
|
|
describe('ensureR2UploadCors', () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.com');
|
|
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
|
|
vi.stubEnv('NODE_ENV', 'test');
|
|
});
|
|
|
|
const managedRule = {
|
|
AllowedOrigins: ['https://app.example.com'],
|
|
AllowedMethods: ['GET', 'PUT', 'HEAD'],
|
|
AllowedHeaders: ['*'],
|
|
ExposeHeaders: ['ETag'],
|
|
MaxAgeSeconds: 3600,
|
|
};
|
|
|
|
it('refuses to run with no origins rather than opening the bucket to everyone', async () => {
|
|
vi.stubEnv('NEXTAUTH_URL', undefined);
|
|
|
|
await expect(ensureR2UploadCors()).rejects.toThrow('No origins configured for R2 upload CORS');
|
|
expect(send).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves an existing rule alone when it already covers the origin', async () => {
|
|
send.mockResolvedValueOnce({
|
|
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET', 'PUT'] }],
|
|
} as never);
|
|
|
|
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
|
expect(send).toHaveBeenCalledTimes(1);
|
|
expect(commandAt(0)).toBeInstanceOf(GetBucketCorsCommand);
|
|
});
|
|
|
|
it('accepts HEAD in place of GET on the existing rule', async () => {
|
|
send.mockResolvedValueOnce({
|
|
CORSRules: [{ AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['head', 'put'] }],
|
|
} as never);
|
|
|
|
await ensureR2UploadCors();
|
|
|
|
expect(send).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('appends its own rule when the existing rules omit PUT', async () => {
|
|
const existing = { AllowedOrigins: ['https://app.example.com'], AllowedMethods: ['GET'] };
|
|
send.mockResolvedValueOnce({ CORSRules: [existing] } as never);
|
|
|
|
await ensureR2UploadCors();
|
|
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
|
expect(inputAt(1)).toEqual({
|
|
Bucket: BUCKET,
|
|
CORSConfiguration: { CORSRules: [existing, managedRule] },
|
|
});
|
|
});
|
|
|
|
it('appends its own rule when the existing rules cover a different origin', async () => {
|
|
send.mockResolvedValueOnce({
|
|
CORSRules: [
|
|
{ AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET', 'PUT'] },
|
|
],
|
|
} as never);
|
|
|
|
await ensureR2UploadCors();
|
|
|
|
expect(commandAt(1)).toBeInstanceOf(PutBucketCorsCommand);
|
|
});
|
|
|
|
it('writes a fresh configuration when the bucket has no CORS config to read', async () => {
|
|
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
|
|
|
await expect(ensureR2UploadCors()).resolves.toEqual(['https://app.example.com']);
|
|
expect(send).toHaveBeenCalledTimes(2);
|
|
expect(inputAt(1)).toEqual({
|
|
Bucket: BUCKET,
|
|
CORSConfiguration: { CORSRules: [managedRule] },
|
|
});
|
|
});
|
|
|
|
// The try block wraps the write as well as the read, so a write that fails
|
|
// lands in the same catch as "no config to read" and the retry re-sends only
|
|
// the managed rule. Asserted as-is; see the review notes.
|
|
it('drops the pre-existing rules when the first write fails and the retry succeeds', async () => {
|
|
const existing = { AllowedOrigins: ['https://other.example.com'], AllowedMethods: ['GET'] };
|
|
send
|
|
.mockResolvedValueOnce({ CORSRules: [existing] } as never)
|
|
.mockRejectedValueOnce(s3Error(500))
|
|
.mockResolvedValueOnce({} as never);
|
|
|
|
await ensureR2UploadCors();
|
|
|
|
expect(send).toHaveBeenCalledTimes(3);
|
|
expect(inputAt(2)).toEqual({
|
|
Bucket: BUCKET,
|
|
CORSConfiguration: { CORSRules: [managedRule] },
|
|
});
|
|
});
|
|
|
|
it('includes the extra origins it was handed in the rule it writes', async () => {
|
|
send.mockRejectedValueOnce(s3Error(404)).mockResolvedValueOnce({} as never);
|
|
|
|
await ensureR2UploadCors(['https://preview.example.com']);
|
|
|
|
expect(
|
|
(inputAt(1).CORSConfiguration as { CORSRules: Array<{ AllowedOrigins: string[] }> })
|
|
.CORSRules[0].AllowedOrigins
|
|
).toEqual(['https://app.example.com', 'https://preview.example.com']);
|
|
});
|
|
});
|