Files
OpenFrame/tests/api/lib-r2-video-finalize.test.ts
T
yusufipk 0187db5dc7 test: close the coverage gaps the first round left
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.
2026-07-26 13:25:11 +07:00

556 lines
20 KiB
TypeScript

// Exercises lib/r2-video-finalize.ts, the gate between "the browser says it
// finished uploading" and "a row is written that bills the user for it".
//
// Everything the caller supplies is hostile until proven otherwise: the object
// key, the proxy URL and the upload token all arrive in the request body. The
// module checks them against each other and then against the session row, and
// only then trusts storage. Each of those checks gets a test, because any one
// of them going missing turns the endpoint into "tell me a size and I will
// believe you".
//
// The failure branches matter as much as the happy path: when the object is
// missing, oversized or not a video, the session is cancelled and both objects
// are removed. Leaving an INITIATED session behind would pin the quota
// reservation for its whole TTL.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { randomUUID } from 'crypto';
import { db } from '@/lib/db';
import { deleteR2Object, deleteVideoObject, headVideoObject, readVideoObjectBytes } from '@/lib/r2';
import { createR2UploadToken } from '@/lib/r2-upload-token';
import { createR2UploadSession } from '@/lib/r2-upload-session';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { createUser, seedProject } from '../factories';
/** 12 bytes whose 5th to 8th spell `ftyp`, the ISO base media signature. */
function mp4Header(): Uint8Array {
const bytes = new Uint8Array(12);
bytes.set([0x66, 0x74, 0x79, 0x70], 4);
return bytes;
}
function bytesOf(...values: number[]): Uint8Array {
return Uint8Array.from(values);
}
/** Makes storage report an object of `sizeBytes` whose header is `header`. */
function storageHolds(sizeBytes: number, header: Uint8Array = mp4Header()): void {
vi.mocked(headVideoObject).mockResolvedValue({
contentLength: BigInt(sizeBytes),
contentType: 'video/mp4',
});
vi.mocked(readVideoObjectBytes).mockResolvedValue(header);
}
interface SeededUpload {
userId: string;
projectId: string;
sessionId: string;
objectKey: string;
thumbnailObjectKey: string;
proxyUrl: string;
uploadToken: string;
reservationId: string | null;
billedUserId: string;
}
/**
* An INITIATED session plus the token the r2-init route would have handed back
* for it, both built through the production helpers rather than by hand.
*/
async function seedUpload(
overrides: {
declaredSizeBytes?: bigint;
expiresAt?: Date;
thumbnailObjectKey?: string;
reservationId?: string | null;
billedUserId?: string;
userId?: string;
projectId?: string;
tokenUserId?: string;
tokenProjectId?: string;
objectKey?: string;
} = {}
): Promise<SeededUpload & { scenarioOwnerId: string }> {
const scenario = await seedProject();
const fileId = randomUUID();
const objectKey = overrides.objectKey ?? `videos/${fileId}.mp4`;
const thumbnailObjectKey = overrides.thumbnailObjectKey ?? `images/${fileId}.jpg`;
const uploadJti = randomUUID();
const userId = overrides.userId ?? scenario.owner.id;
const projectId = overrides.projectId ?? scenario.project.id;
const billedUserId = overrides.billedUserId ?? scenario.owner.id;
const session = await createR2UploadSession({
userId,
projectId,
billedUserId,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: overrides.declaredSizeBytes ?? BigInt(4096),
contentType: 'video/mp4',
reservationId: overrides.reservationId ?? null,
uploadJti,
expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000),
});
const uploadToken = createR2UploadToken({
userId: overrides.tokenUserId ?? userId,
projectId: overrides.tokenProjectId ?? projectId,
objectKey,
sessionId: session.id,
tokenId: uploadJti,
thumbnailObjectKey,
});
return {
userId,
projectId,
sessionId: session.id,
objectKey,
thumbnailObjectKey,
proxyUrl: `/api/upload/video/${fileId}.mp4`,
uploadToken,
reservationId: session.reservationId,
billedUserId,
scenarioOwnerId: scenario.owner.id,
};
}
function finalize(
upload: SeededUpload,
overrides: Partial<Parameters<typeof finalizeR2VideoUpload>[0]> = {}
) {
return finalizeR2VideoUpload({
userId: upload.userId,
projectId: upload.projectId,
videoUrl: upload.proxyUrl,
objectKey: upload.objectKey,
uploadToken: upload.uploadToken,
...overrides,
});
}
beforeEach(() => {
// tests/setup/api.ts already stubs both with the production shapes, but this
// suite is the one that varies them per test, so it sets its own baseline
// rather than depending on the shared default staying at 1024 bytes.
storageHolds(1024);
vi.mocked(deleteVideoObject).mockClear();
vi.mocked(deleteR2Object).mockClear();
});
describe('finalizeR2VideoUpload request shape', () => {
it('rejects a missing objectKey', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: '' });
expect(result).toEqual({
ok: false,
error: 'R2 uploads must include objectKey and uploadToken',
status: 400,
});
});
it('rejects a missing uploadToken', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { uploadToken: '' });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(400);
});
it('rejects an object key outside the videos prefix', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: 'images/not-a-video.jpg' });
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
});
it('rejects an object key whose basename is not a uuid', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { objectKey: 'videos/../../etc/passwd' });
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
});
// The proxy URL is what gets written into VideoVersion.originalUrl, so it has
// to name the same object the token authorises. Otherwise a caller could
// upload to their own key and point the row at somebody else's file.
it('rejects a video URL that does not match the object key', async () => {
const upload = await seedUpload();
const result = await finalize(upload, {
videoUrl: '/api/upload/video/99999999-9999-4999-8999-999999999999.mp4',
});
expect(result).toEqual({
ok: false,
error: 'Video URL does not match the uploaded object',
status: 400,
});
});
it('rejects a video URL that is not a proxy path at all', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { videoUrl: 'https://cdn.evil.test/clip.mp4' });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(400);
});
});
describe('finalizeR2VideoUpload token verification', () => {
it('rejects a token that is not even parseable', async () => {
const upload = await seedUpload();
const result = await finalize(upload, { uploadToken: 'forged.token' });
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a token whose signature has been tampered with', async () => {
const upload = await seedUpload();
const [payload] = upload.uploadToken.split('.');
const result = await finalize(upload, { uploadToken: `${payload}.notthesignature` });
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(403);
});
// A validly signed token issued to somebody else. The signature checks out,
// so only the subject comparison stands between the two accounts.
it('rejects a well-formed token issued to a different user', async () => {
const upload = await seedUpload({ tokenUserId: 'someone-else-entirely' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a well-formed token issued for a different project', async () => {
const upload = await seedUpload({ tokenProjectId: 'some-other-project' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
it('rejects a valid token presented by a different caller', async () => {
const upload = await seedUpload();
const impostor = await createUser();
const result = await finalize(upload, { userId: impostor.id });
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
});
describe('finalizeR2VideoUpload session lookup', () => {
it('rejects a session that has already been cancelled', async () => {
const upload = await seedUpload();
await db.videoUploadSession.update({
where: { id: upload.sessionId },
data: { status: 'CANCELLED' },
});
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
// Replay protection: a session that already produced a video row must not
// produce a second one from the same token.
it('rejects a session that has already been finalized', async () => {
const upload = await seedUpload();
await db.videoUploadSession.update({
where: { id: upload.sessionId },
data: { status: 'FINALIZED' },
});
const result = await finalize(upload);
expect(result.ok).toBe(false);
expect(result.ok === false && result.status).toBe(403);
});
it('rejects a session whose expiry has passed', async () => {
const upload = await seedUpload({ expiresAt: new Date(Date.now() - 60 * 1000) });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
// The thumbnail key is carried in the token and re-read off the session row.
// A key outside images/ would make the derived proxy URL point nowhere, and
// the module refuses rather than emitting a half-formed URL.
it('rejects a session whose thumbnail key is not under the images prefix', async () => {
const upload = await seedUpload({ thumbnailObjectKey: 'thumbs/elsewhere.jpg' });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid upload token', status: 403 });
});
});
describe('finalizeR2VideoUpload storage checks', () => {
/** Reads the session row back after a call that should have cancelled it. */
async function sessionOf(sessionId: string) {
return db.videoUploadSession.findUniqueOrThrow({ where: { id: sessionId } });
}
it('cancels the session and removes both objects when storage has no object', async () => {
const upload = await seedUpload();
vi.mocked(headVideoObject).mockResolvedValue(null);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video was not found in storage',
status: 400,
});
const session = await sessionOf(upload.sessionId);
expect(session.status).toBe('CANCELLED');
expect(session.consumedAt).toBeInstanceOf(Date);
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
expect(vi.mocked(deleteR2Object)).toHaveBeenCalledWith(upload.thumbnailObjectKey);
});
it('refuses an object key outside the videos/ prefix without asking storage', async () => {
// Two things at once, and the second is the reason the stubs are reset.
//
// The claim is that finalize refuses a key it has no business finalising
// *before* it reaches out to storage, so the refusal cannot depend on what
// the bucket happens to answer. Proving "before" means the storage stubs
// must not be able to rescue it: this file's beforeEach calls
// storageHolds(), which mockResolvedValues both functions for any key, so
// mockReset() puts back the prefix-aware implementations from
// tests/setup/api.ts (mockReset restores the implementation vi.fn() was
// created with). Neither is called at all, which is the point.
vi.mocked(headVideoObject).mockReset();
vi.mocked(readVideoObjectBytes).mockReset();
const upload = await seedUpload({ objectKey: `images/${randomUUID()}.mp4` });
const result = await finalize(upload);
expect(result).toEqual({ ok: false, error: 'Invalid object key', status: 400 });
expect(vi.mocked(headVideoObject)).not.toHaveBeenCalled();
expect(vi.mocked(readVideoObjectBytes)).not.toHaveBeenCalled();
// Refused, not cancelled: nothing was uploaded under a key this session
// could own, so there is nothing to clean up and the session is left alone.
expect((await sessionOf(upload.sessionId)).status).toBe('INITIATED');
});
it('cancels when storage reports a zero-byte object', async () => {
const upload = await seedUpload();
vi.mocked(headVideoObject).mockResolvedValue({
contentLength: BigInt(0),
contentType: 'video/mp4',
});
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded video was not found in storage');
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
it('cancels when the stored object is over the configured maximum', async () => {
vi.stubEnv('OPENFRAME_MAX_VIDEO_UPLOAD_BYTES', '2048');
const upload = await seedUpload({ declaredSizeBytes: BigInt(1_000_000) });
storageHolds(4096);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video exceeds the maximum allowed upload size',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
// The declared size is what the quota reservation was sized against. An
// object bigger than that has been billed for less than it costs.
it('cancels when the stored object is larger than the declared size', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
storageHolds(1025);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded video size does not match upload request',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
it('accepts an object exactly the declared size', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(1024) });
storageHolds(1024);
const result = await finalize(upload);
expect(result.ok).toBe(true);
});
it('accepts an object smaller than declared, because compression is allowed to win', async () => {
const upload = await seedUpload({ declaredSizeBytes: BigInt(4096) });
storageHolds(10);
const result = await finalize(upload);
expect(result.ok).toBe(true);
expect(result.ok === true && result.sizeBytes).toBe(BigInt(10));
});
it('cancels when the header bytes cannot be read at all', async () => {
const upload = await seedUpload();
vi.mocked(readVideoObjectBytes).mockResolvedValue(null);
const result = await finalize(upload);
expect(result).toEqual({
ok: false,
error: 'Uploaded file is not a valid video',
status: 400,
});
expect((await sessionOf(upload.sessionId)).status).toBe('CANCELLED');
});
// Content-type is caller-controlled, so the first 64 bytes are the only thing
// that decides whether this is really a video. A renamed .exe stops here.
it('cancels when the header bytes are not a known container', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x4d, 0x5a, 0x90, 0x00, 0x03, 0x00, 0x00, 0x00, 0, 0, 0, 0));
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
expect(vi.mocked(deleteVideoObject)).toHaveBeenCalledWith(upload.objectKey);
});
it('cancels when the object is too short to carry a signature', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x66, 0x74, 0x79));
const result = await finalize(upload);
expect(result.ok === false && result.error).toBe('Uploaded file is not a valid video');
});
it.each([
['an mp4 ftyp box', mp4Header()],
['a matroska EBML header', bytesOf(0x1a, 0x45, 0xdf, 0xa3)],
['an Ogg page header', bytesOf(0x4f, 0x67, 0x67, 0x53)],
['a RIFF AVI header', bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x41, 0x56, 0x49, 0x20)],
])('accepts %s', async (_label, header) => {
const upload = await seedUpload();
storageHolds(1024, header);
expect((await finalize(upload)).ok).toBe(true);
});
// A RIFF container that is not AVI (a .wav, say) has the same first four
// bytes and must not slip through on the prefix alone.
it('cancels a RIFF container that is not AVI', async () => {
const upload = await seedUpload();
storageHolds(1024, bytesOf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45));
expect((await finalize(upload)).ok).toBe(false);
});
});
describe('finalizeR2VideoUpload success', () => {
it('returns the proxy URLs, the session and the billing subject', async () => {
const billed = await createUser();
const upload = await seedUpload({ declaredSizeBytes: BigInt(8192), billedUserId: billed.id });
storageHolds(7000);
const result = await finalize(upload);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.sizeBytes).toBe(BigInt(7000));
expect(result.objectKey).toBe(upload.objectKey);
expect(result.proxyUrl).toBe(upload.proxyUrl);
expect(result.sessionId).toBe(upload.sessionId);
expect(result.reservationId).toBeNull();
expect(result.billedUserId).toBe(billed.id);
expect(result.thumbnailObjectKey).toBe(upload.thumbnailObjectKey);
expect(result.thumbnailProxyUrl).toBe(
`/api/upload/image/${upload.thumbnailObjectKey.slice('images/'.length)}`
);
});
// Finalisation validates; it does not consume. The caller writes the row and
// then marks the session FINALIZED, so a success here must leave the session
// exactly as it found it.
it('leaves the session INITIATED and deletes nothing', async () => {
const upload = await seedUpload();
const result = await finalize(upload);
expect(result.ok).toBe(true);
const session = await db.videoUploadSession.findUniqueOrThrow({
where: { id: upload.sessionId },
});
expect(session.status).toBe('INITIATED');
expect(session.consumedAt).toBeNull();
expect(vi.mocked(deleteVideoObject)).not.toHaveBeenCalled();
expect(vi.mocked(deleteR2Object)).not.toHaveBeenCalled();
});
it('carries the reservation id through so the caller can release it', async () => {
const scenario = await seedProject();
const reservation = await db.uploadReservation.create({
data: {
billedUserId: scenario.owner.id,
sizeBytes: BigInt(4096),
expiresAt: new Date(Date.now() + 30 * 60 * 1000),
},
});
const fileId = randomUUID();
const objectKey = `videos/${fileId}.mp4`;
const thumbnailObjectKey = `images/${fileId}.jpg`;
const uploadJti = randomUUID();
const session = await createR2UploadSession({
userId: scenario.owner.id,
projectId: scenario.project.id,
billedUserId: scenario.owner.id,
objectKey,
thumbnailObjectKey,
declaredSizeBytes: BigInt(4096),
contentType: 'video/mp4',
reservationId: reservation.id,
uploadJti,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const result = await finalizeR2VideoUpload({
userId: scenario.owner.id,
projectId: scenario.project.id,
videoUrl: `/api/upload/video/${fileId}.mp4`,
objectKey,
uploadToken: createR2UploadToken({
userId: scenario.owner.id,
projectId: scenario.project.id,
objectKey,
sessionId: session.id,
tokenId: uploadJti,
thumbnailObjectKey,
}),
});
expect(result.ok === true && result.reservationId).toBe(reservation.id);
});
});