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.
398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import crypto from 'crypto';
|
|
import {
|
|
createR2UploadToken,
|
|
parseR2UploadToken,
|
|
verifyR2UploadToken,
|
|
type R2UploadTokenSubject,
|
|
} from '@/lib/r2-upload-token';
|
|
|
|
const SECRET = 'r2-upload-token-test-secret';
|
|
const OTHER_SECRET = 'a-completely-different-secret';
|
|
const NOW = new Date('2026-01-15T12:00:00.000Z');
|
|
const NOW_SECONDS = Math.floor(NOW.getTime() / 1000);
|
|
const ONE_HOUR = 60 * 60;
|
|
|
|
const SUBJECT = {
|
|
userId: 'user-1',
|
|
projectId: 'project-1',
|
|
objectKey: 'projects/project-1/videos/video-1/source.mp4',
|
|
sessionId: 'session-1',
|
|
tokenId: 'token-1',
|
|
thumbnailObjectKey: 'projects/project-1/videos/video-1/thumb.jpg',
|
|
} satisfies R2UploadTokenSubject & {
|
|
sessionId: string;
|
|
tokenId: string;
|
|
thumbnailObjectKey: string;
|
|
};
|
|
|
|
/**
|
|
* Mints a token over an arbitrary payload with a valid signature. Signatures are
|
|
* never hardcoded here: they depend on the secret, so every expectation is about
|
|
* behaviour. This exists only to reach the payload-shape checks, which a forged
|
|
* signature can never get past.
|
|
*/
|
|
function signArbitrary(payload: unknown, secret = SECRET): string {
|
|
const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
return `${encoded}.${signature}`;
|
|
}
|
|
|
|
/**
|
|
* Signs raw JSON text rather than an object. JSON.stringify cannot emit a
|
|
* non-finite number, so this is the only way to hand verify() a payload whose
|
|
* `iat` or `exp` parses back as Infinity: a decimal exponent that overflows to
|
|
* it, which JSON.parse accepts and turns into Infinity.
|
|
*/
|
|
function signRawJson(json: string, secret = SECRET): string {
|
|
const encoded = Buffer.from(json, 'utf8').toString('base64url');
|
|
const signature = crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
return `${encoded}.${signature}`;
|
|
}
|
|
|
|
function wellFormedPayload(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
typ: 'r2-upload',
|
|
uid: SUBJECT.userId,
|
|
pid: SUBJECT.projectId,
|
|
key: SUBJECT.objectKey,
|
|
sid: SUBJECT.sessionId,
|
|
jti: SUBJECT.tokenId,
|
|
tkey: SUBJECT.thumbnailObjectKey,
|
|
iat: NOW_SECONDS,
|
|
exp: NOW_SECONDS + ONE_HOUR,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(NOW);
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', SECRET);
|
|
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe('createR2UploadToken', () => {
|
|
it('produces a two-part token separated by a dot', () => {
|
|
expect(createR2UploadToken(SUBJECT).split('.')).toHaveLength(2);
|
|
});
|
|
|
|
it('encodes the subject and the issue and expiry times into the payload', () => {
|
|
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
|
|
|
expect(payload).toEqual({
|
|
typ: 'r2-upload',
|
|
uid: 'user-1',
|
|
pid: 'project-1',
|
|
key: 'projects/project-1/videos/video-1/source.mp4',
|
|
sid: 'session-1',
|
|
jti: 'token-1',
|
|
tkey: 'projects/project-1/videos/video-1/thumb.jpg',
|
|
iat: NOW_SECONDS,
|
|
exp: NOW_SECONDS + ONE_HOUR,
|
|
});
|
|
});
|
|
|
|
it('defaults to a one hour lifetime', () => {
|
|
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
|
|
|
expect(payload!.exp - payload!.iat).toBe(3600);
|
|
});
|
|
|
|
it('honours an explicit ttl', () => {
|
|
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT, 90));
|
|
|
|
expect(payload!.exp - payload!.iat).toBe(90);
|
|
});
|
|
|
|
it('uses base64url, so the token survives a query string unescaped', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
expect(token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
|
|
expect(encodeURIComponent(token)).toBe(token);
|
|
});
|
|
|
|
it('prefers R2_UPLOAD_TOKEN_SECRET over NEXTAUTH_SECRET', () => {
|
|
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
// Verifying with only NEXTAUTH_SECRET available must fail, which it can only
|
|
// do if the dedicated variable was the one that signed.
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('falls back to NEXTAUTH_SECRET when the dedicated secret is unset', () => {
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
|
vi.stubEnv('NEXTAUTH_SECRET', OTHER_SECRET);
|
|
|
|
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
|
});
|
|
|
|
it('refuses to mint a token when no secret is configured at all', () => {
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
|
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
|
|
|
expect(() => createR2UploadToken(SUBJECT)).toThrow(
|
|
'Missing R2_UPLOAD_TOKEN_SECRET or NEXTAUTH_SECRET.'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('verifyR2UploadToken', () => {
|
|
it('accepts a freshly signed token for the subject it was minted for', () => {
|
|
expect(verifyR2UploadToken(createR2UploadToken(SUBJECT), SUBJECT)).toBe(true);
|
|
});
|
|
|
|
it('rejects a token whose payload was tampered with', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
const [encodedPayload, signature] = token.split('.');
|
|
const payload = JSON.parse(Buffer.from(encodedPayload!, 'base64url').toString('utf8'));
|
|
payload.pid = 'project-victim';
|
|
const forged = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
|
|
expect(verifyR2UploadToken(`${forged}.${signature}`, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('rejects a token whose signature was tampered with', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
const [encodedPayload, signature] = token.split('.');
|
|
const flipped = (signature![0] === 'A' ? 'B' : 'A') + signature!.slice(1);
|
|
|
|
expect(verifyR2UploadToken(`${encodedPayload}.${flipped}`, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('rejects a token signed under a different secret', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
|
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('rejects a token that has expired', () => {
|
|
const token = createR2UploadToken(SUBJECT, 60);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
|
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('still accepts a token in its final second', () => {
|
|
const token = createR2UploadToken(SUBJECT, 60);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 59_000));
|
|
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
|
});
|
|
|
|
it('accepts a token at the exact expiry second and rejects it one second later', () => {
|
|
const token = createR2UploadToken(SUBJECT, 60);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 60_000));
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(true);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('rejects a token minted with a zero ttl once the clock moves on', () => {
|
|
const token = createR2UploadToken(SUBJECT, 0);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 1_000));
|
|
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['a different user', { userId: 'user-2' }],
|
|
['a different project', { projectId: 'project-2' }],
|
|
['a different object key', { objectKey: 'projects/project-1/videos/video-2/source.mp4' }],
|
|
['a different upload session', { sessionId: 'session-2' }],
|
|
['a different token id', { tokenId: 'token-2' }],
|
|
['a different thumbnail key', { thumbnailObjectKey: 'projects/other/thumb.jpg' }],
|
|
])('rejects a valid token presented for %s', (_label, override) => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
expect(verifyR2UploadToken(token, { ...SUBJECT, ...override })).toBe(false);
|
|
});
|
|
|
|
it('rejects an object key that differs only by a traversal segment', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
expect(
|
|
verifyR2UploadToken(token, {
|
|
...SUBJECT,
|
|
objectKey: 'projects/project-1/videos/video-1/../video-2/source.mp4',
|
|
})
|
|
).toBe(false);
|
|
});
|
|
|
|
it('skips the optional session, token id and thumbnail checks when the caller omits them', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
expect(
|
|
verifyR2UploadToken(token, {
|
|
userId: SUBJECT.userId,
|
|
projectId: SUBJECT.projectId,
|
|
objectKey: SUBJECT.objectKey,
|
|
})
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
['an empty string', ''],
|
|
['whitespace', ' '],
|
|
['a single segment', 'notatoken'],
|
|
['three segments', 'a.b.c'],
|
|
['a missing signature', 'YWJj.'],
|
|
['a missing payload', '.c2ln'],
|
|
['two empty segments', '.'],
|
|
['a jwt-shaped token', 'eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1c2VyLTEifQ.sig'],
|
|
['punctuation only', '!!!.???'],
|
|
])('refuses %s rather than throwing', (_label, token) => {
|
|
expect(() => verifyR2UploadToken(token, SUBJECT)).not.toThrow();
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('refuses a signature of the wrong length without letting timingSafeEqual throw', () => {
|
|
const [encodedPayload] = createR2UploadToken(SUBJECT).split('.');
|
|
|
|
// crypto.timingSafeEqual throws on unequal buffer lengths, so the length
|
|
// guard in front of it is load bearing.
|
|
expect(() => verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).not.toThrow();
|
|
expect(verifyR2UploadToken(`${encodedPayload}.short`, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('refuses a correctly signed payload that is not JSON', () => {
|
|
const encoded = Buffer.from('not json at all', 'utf8').toString('base64url');
|
|
const signature = crypto.createHmac('sha256', SECRET).update(encoded).digest('base64url');
|
|
|
|
expect(verifyR2UploadToken(`${encoded}.${signature}`, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('refuses a correctly signed payload that is a JSON scalar rather than an object', () => {
|
|
expect(verifyR2UploadToken(signArbitrary('user-1'), SUBJECT)).toBe(false);
|
|
expect(verifyR2UploadToken(signArbitrary(null), SUBJECT)).toBe(false);
|
|
expect(verifyR2UploadToken(signArbitrary(42), SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it.each([['typ'], ['uid'], ['pid'], ['key'], ['sid'], ['jti'], ['tkey'], ['iat'], ['exp']])(
|
|
'refuses a correctly signed payload missing %s',
|
|
(field) => {
|
|
const payload = wellFormedPayload();
|
|
delete (payload as Record<string, unknown>)[field];
|
|
|
|
expect(verifyR2UploadToken(signArbitrary(payload), SUBJECT)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('refuses a correctly signed token minted for a different token type', () => {
|
|
// Stops a bunny-upload grant, signed with the same NEXTAUTH_SECRET fallback,
|
|
// from being replayed against the R2 path.
|
|
expect(
|
|
verifyR2UploadToken(signArbitrary(wellFormedPayload({ typ: 'bunny-upload' })), SUBJECT)
|
|
).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['exp', 'Infinity', Number.POSITIVE_INFINITY],
|
|
['exp', 'NaN', Number.NaN],
|
|
['iat', 'Infinity', Number.POSITIVE_INFINITY],
|
|
])(
|
|
'refuses a correctly signed payload whose %s arrives as null, having been minted as %s',
|
|
(field, _label, value) => {
|
|
// Named for what it actually exercises. JSON.stringify writes both Infinity
|
|
// and NaN as `null`, so the payload reaches verify() with a null and is
|
|
// rejected one line earlier, by the `typeof === 'number'` check. The
|
|
// Number.isFinite guard is never consulted on this path; the case below is
|
|
// the one that reaches it.
|
|
const token = signArbitrary(wellFormedPayload({ [field]: value }));
|
|
|
|
expect(
|
|
JSON.parse(Buffer.from(token.split('.')[0]!, 'base64url').toString())[field]
|
|
).toBeNull();
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it.each([['iat'], ['exp']])(
|
|
'refuses a correctly signed payload whose %s is a JSON literal that overflows to Infinity',
|
|
(field) => {
|
|
// The one way a non-finite number survives the wire: `1e999` is legal JSON
|
|
// and JSON.parse turns it into Infinity, which passes the typeof check and
|
|
// leaves Number.isFinite as the only thing standing. For exp that matters,
|
|
// because Infinity < now is false, so without the guard the token would
|
|
// verify and never expire. Minting one still needs the server secret, so
|
|
// this is defence in depth rather than a reachable forgery.
|
|
const json = JSON.stringify(wellFormedPayload()).replace(
|
|
new RegExp(`"${field}":\\d+`),
|
|
`"${field}":1e999`
|
|
);
|
|
|
|
expect(JSON.parse(json)[field]).toBe(Number.POSITIVE_INFINITY);
|
|
expect(verifyR2UploadToken(signRawJson(json), SUBJECT)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('refuses a correctly signed payload whose exp is a numeric string', () => {
|
|
const token = signArbitrary(wellFormedPayload({ exp: String(NOW_SECONDS + ONE_HOUR) }));
|
|
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
|
|
it('returns false rather than throwing when the server has no secret configured', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', undefined);
|
|
vi.stubEnv('NEXTAUTH_SECRET', undefined);
|
|
|
|
// A misconfigured server is indistinguishable from a forged token here.
|
|
expect(verifyR2UploadToken(token, SUBJECT)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseR2UploadToken', () => {
|
|
it('returns the payload of a valid token', () => {
|
|
expect(parseR2UploadToken(createR2UploadToken(SUBJECT))).toMatchObject({
|
|
typ: 'r2-upload',
|
|
uid: 'user-1',
|
|
});
|
|
});
|
|
|
|
it('returns null for a token signed under a different secret', () => {
|
|
const token = createR2UploadToken(SUBJECT);
|
|
|
|
vi.stubEnv('R2_UPLOAD_TOKEN_SECRET', OTHER_SECRET);
|
|
|
|
expect(parseR2UploadToken(token)).toBeNull();
|
|
});
|
|
|
|
it('returns null for an expired token', () => {
|
|
const token = createR2UploadToken(SUBJECT, 60);
|
|
|
|
vi.setSystemTime(new Date(NOW.getTime() + 61_000));
|
|
|
|
expect(parseR2UploadToken(token)).toBeNull();
|
|
});
|
|
|
|
it('returns null for garbage input rather than throwing', () => {
|
|
expect(parseR2UploadToken('')).toBeNull();
|
|
expect(parseR2UploadToken('a.b.c')).toBeNull();
|
|
expect(parseR2UploadToken('%%%.%%%')).toBeNull();
|
|
});
|
|
|
|
it('does not check the payload against any subject, leaving that to the caller', () => {
|
|
// parseR2UploadToken only proves authenticity and freshness. Routes that use
|
|
// it directly must compare the fields themselves.
|
|
const payload = parseR2UploadToken(createR2UploadToken(SUBJECT));
|
|
|
|
expect(payload!.uid).toBe('user-1');
|
|
});
|
|
});
|