mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +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.
221 lines
8.0 KiB
TypeScript
221 lines
8.0 KiB
TypeScript
// Exercises lib/r2-upload-session.ts, the bookkeeping either side of a direct
|
|
// upload.
|
|
//
|
|
// Small module, but the `where` clause on the cancel is load-bearing in two
|
|
// directions: it must not let a caller cancel a session that is not theirs to
|
|
// cancel, and it must actually match the session they do own, because the
|
|
// r2-init DELETE route releases the quota reservation only when the update
|
|
// reports a row. A cancel that quietly matches nothing leaves the reservation
|
|
// pinned for its whole TTL.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import { randomUUID } from 'crypto';
|
|
import { db } from '@/lib/db';
|
|
import { cancelR2UploadSession, createR2UploadSession } from '@/lib/r2-upload-session';
|
|
import { seedProject } from '../factories';
|
|
|
|
const HOUR_MS = 60 * 60 * 1000;
|
|
|
|
async function newSession(
|
|
overrides: { expiresAt?: Date; multipartUploadId?: string | null; reservationId?: string } = {}
|
|
) {
|
|
const scenario = await seedProject();
|
|
const fileId = randomUUID();
|
|
const session = await createR2UploadSession({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
billedUserId: scenario.owner.id,
|
|
objectKey: `videos/${fileId}.mp4`,
|
|
thumbnailObjectKey: `images/${fileId}.jpg`,
|
|
declaredSizeBytes: BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
reservationId: overrides.reservationId ?? null,
|
|
uploadJti: randomUUID(),
|
|
expiresAt: overrides.expiresAt ?? new Date(Date.now() + HOUR_MS),
|
|
...(overrides.multipartUploadId === undefined
|
|
? {}
|
|
: { multipartUploadId: overrides.multipartUploadId }),
|
|
});
|
|
return { scenario, session, fileId };
|
|
}
|
|
|
|
describe('createR2UploadSession', () => {
|
|
it('writes an INITIATED row carrying every field the finalizer reads back', async () => {
|
|
const scenario = await seedProject();
|
|
const fileId = randomUUID();
|
|
const uploadJti = randomUUID();
|
|
const expiresAt = new Date(Date.now() + HOUR_MS);
|
|
|
|
const created = await createR2UploadSession({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
billedUserId: scenario.owner.id,
|
|
objectKey: `videos/${fileId}.mp4`,
|
|
thumbnailObjectKey: `images/${fileId}.jpg`,
|
|
declaredSizeBytes: BigInt(123_456),
|
|
contentType: 'video/webm',
|
|
reservationId: null,
|
|
uploadJti,
|
|
expiresAt,
|
|
});
|
|
|
|
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: created.id } });
|
|
expect(row.status).toBe('INITIATED');
|
|
expect(row.userId).toBe(scenario.owner.id);
|
|
expect(row.projectId).toBe(scenario.project.id);
|
|
expect(row.billedUserId).toBe(scenario.owner.id);
|
|
expect(row.objectKey).toBe(`videos/${fileId}.mp4`);
|
|
expect(row.thumbnailObjectKey).toBe(`images/${fileId}.jpg`);
|
|
expect(row.declaredSizeBytes).toBe(BigInt(123_456));
|
|
expect(row.contentType).toBe('video/webm');
|
|
expect(row.uploadJti).toBe(uploadJti);
|
|
expect(row.expiresAt.getTime()).toBe(expiresAt.getTime());
|
|
expect(row.reservationId).toBeNull();
|
|
expect(row.consumedAt).toBeNull();
|
|
});
|
|
|
|
// The field is optional on the input but the column is not nullable-by-
|
|
// accident: a single-shot PUT must store null rather than undefined, because
|
|
// the complete route branches on it to decide whether to assemble parts.
|
|
it('stores a null multipart id when none is supplied', async () => {
|
|
const { session } = await newSession();
|
|
|
|
expect(session.multipartUploadId).toBeNull();
|
|
});
|
|
|
|
it('stores an explicit null multipart id as null', async () => {
|
|
const { session } = await newSession({ multipartUploadId: null });
|
|
|
|
expect(session.multipartUploadId).toBeNull();
|
|
});
|
|
|
|
it('records the multipart upload id when the upload is chunked', async () => {
|
|
const { session } = await newSession({ multipartUploadId: 'multipart-upload-id-1' });
|
|
|
|
expect(session.multipartUploadId).toBe('multipart-upload-id-1');
|
|
});
|
|
|
|
it('links the quota reservation the caller already took', async () => {
|
|
const scenario = await seedProject();
|
|
const reservation = await db.uploadReservation.create({
|
|
data: {
|
|
billedUserId: scenario.owner.id,
|
|
sizeBytes: BigInt(4096),
|
|
expiresAt: new Date(Date.now() + HOUR_MS),
|
|
},
|
|
});
|
|
const fileId = randomUUID();
|
|
|
|
const created = await createR2UploadSession({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
billedUserId: scenario.owner.id,
|
|
objectKey: `videos/${fileId}.mp4`,
|
|
thumbnailObjectKey: `images/${fileId}.jpg`,
|
|
declaredSizeBytes: BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
reservationId: reservation.id,
|
|
uploadJti: randomUUID(),
|
|
expiresAt: new Date(Date.now() + HOUR_MS),
|
|
});
|
|
|
|
expect(created.reservationId).toBe(reservation.id);
|
|
});
|
|
|
|
// objectKey is unique in the schema, which is what stops two sessions from
|
|
// ever pointing at the same object and racing each other's cleanup.
|
|
it('refuses a second session for the same object key', async () => {
|
|
const { scenario, fileId } = await newSession();
|
|
|
|
await expect(
|
|
createR2UploadSession({
|
|
userId: scenario.owner.id,
|
|
projectId: scenario.project.id,
|
|
billedUserId: scenario.owner.id,
|
|
objectKey: `videos/${fileId}.mp4`,
|
|
thumbnailObjectKey: `images/${fileId}.jpg`,
|
|
declaredSizeBytes: BigInt(4096),
|
|
contentType: 'video/mp4',
|
|
reservationId: null,
|
|
uploadJti: randomUUID(),
|
|
expiresAt: new Date(Date.now() + HOUR_MS),
|
|
})
|
|
).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('cancelR2UploadSession', () => {
|
|
it('flips an INITIATED session to CANCELLED and stamps consumedAt', async () => {
|
|
const { session } = await newSession();
|
|
|
|
const result = await cancelR2UploadSession(session.id);
|
|
|
|
expect(result.count).toBe(1);
|
|
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } });
|
|
expect(row.status).toBe('CANCELLED');
|
|
expect(row.consumedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('matches nothing on a second cancel, so the route cannot double-release', async () => {
|
|
const { session } = await newSession();
|
|
await cancelR2UploadSession(session.id);
|
|
|
|
const result = await cancelR2UploadSession(session.id);
|
|
|
|
expect(result.count).toBe(0);
|
|
});
|
|
|
|
it('refuses to cancel a session that was already finalized', async () => {
|
|
const { session } = await newSession();
|
|
await db.videoUploadSession.update({
|
|
where: { id: session.id },
|
|
data: { status: 'FINALIZED' },
|
|
});
|
|
|
|
const result = await cancelR2UploadSession(session.id);
|
|
|
|
expect(result.count).toBe(0);
|
|
expect(
|
|
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } })).status
|
|
).toBe('FINALIZED');
|
|
});
|
|
|
|
// The `expiresAt: { gt: now }` clause means an expired session cannot be
|
|
// cancelled at all: the row stays INITIATED and consumedAt stays null. That
|
|
// is the current contract, and it is why the sweeper rather than the route
|
|
// has to be the thing that reclaims those reservations. See the report.
|
|
it('matches nothing once the session has expired, leaving it INITIATED', async () => {
|
|
const { session } = await newSession({ expiresAt: new Date(Date.now() - 60_000) });
|
|
|
|
const result = await cancelR2UploadSession(session.id);
|
|
|
|
expect(result.count).toBe(0);
|
|
const row = await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } });
|
|
expect(row.status).toBe('INITIATED');
|
|
expect(row.consumedAt).toBeNull();
|
|
});
|
|
|
|
it('does nothing for an id that matches no row', async () => {
|
|
const { session } = await newSession();
|
|
|
|
const result = await cancelR2UploadSession('no-such-session');
|
|
|
|
expect(result.count).toBe(0);
|
|
expect(
|
|
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: session.id } })).status
|
|
).toBe('INITIATED');
|
|
});
|
|
|
|
it('leaves every other session alone', async () => {
|
|
const target = await newSession();
|
|
const bystander = await newSession();
|
|
|
|
await cancelR2UploadSession(target.session.id);
|
|
|
|
expect(
|
|
(await db.videoUploadSession.findUniqueOrThrow({ where: { id: bystander.session.id } }))
|
|
.status
|
|
).toBe('INITIATED');
|
|
});
|
|
});
|