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.
393 lines
15 KiB
TypeScript
393 lines
15 KiB
TypeScript
// lib/email-verification.ts and the two routes that drive it.
|
|
//
|
|
// The property the whole module rests on is that the database never holds a
|
|
// usable verification link: it stores a SHA-256 digest, and the raw token
|
|
// exists only in the mail. Everything below is written so that storing the raw
|
|
// token, or dropping the expiry check, or letting a spent token be replayed,
|
|
// fails a test rather than a security review.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import nodemailer from 'nodemailer';
|
|
import { db } from '@/lib/db';
|
|
import {
|
|
consumeVerificationToken,
|
|
createVerificationToken,
|
|
isEmailVerificationEnabled,
|
|
sendVerificationEmail,
|
|
} from '@/lib/email-verification';
|
|
import { GET as verifyEmail } from '@/app/api/auth/verify-email/route';
|
|
import { POST as resendVerification } from '@/app/api/auth/verify-email/resend/route';
|
|
import { apiRequest, callRoute, readData, readError } from '../helpers/request';
|
|
import { mailTo, sentMail } from '../helpers/mail';
|
|
import { createUser } from '../factories';
|
|
|
|
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
|
|
const MINUTE_MS = 60 * 1000;
|
|
|
|
const RESEND_MESSAGE =
|
|
'If that email has an unverified account, a new verification link has been sent.';
|
|
|
|
function sha256(value: string): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
/** Backdates the stored token so the expiry branch is reachable without waiting. */
|
|
async function expireToken(tokenHash: string): Promise<void> {
|
|
await db.verificationToken.update({
|
|
where: { token: tokenHash },
|
|
data: { expires: new Date(Date.now() - MINUTE_MS) },
|
|
});
|
|
}
|
|
|
|
describe('createVerificationToken', () => {
|
|
it('hands back a raw token and stores only its digest', async () => {
|
|
const token = await createVerificationToken('[email protected]');
|
|
|
|
const record = await db.verificationToken.findFirstOrThrow();
|
|
expect(token).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(record.identifier).toBe('[email protected]');
|
|
// The load-bearing assertion: a dump of verification_tokens must not be a
|
|
// list of working verification links.
|
|
expect(record.token).not.toBe(token);
|
|
expect(record.token).toBe(sha256(token));
|
|
});
|
|
|
|
it('expires the token two hours out', async () => {
|
|
await createVerificationToken('[email protected]');
|
|
|
|
const record = await db.verificationToken.findFirstOrThrow();
|
|
const ttl = record.expires.getTime() - Date.now();
|
|
expect(ttl).toBeGreaterThan(TWO_HOURS_MS - MINUTE_MS);
|
|
expect(ttl).toBeLessThanOrEqual(TWO_HOURS_MS);
|
|
});
|
|
|
|
it('replaces the previous token for the address, so the older link stops working', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const first = await createVerificationToken('[email protected]');
|
|
const second = await createVerificationToken('[email protected]');
|
|
|
|
expect(second).not.toBe(first);
|
|
expect(await db.verificationToken.count()).toBe(1);
|
|
expect(await consumeVerificationToken(first)).toBeNull();
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
|
|
expect(await consumeVerificationToken(second)).toBe('[email protected]');
|
|
});
|
|
|
|
it('leaves tokens for other addresses alone', async () => {
|
|
const ada = await createVerificationToken('[email protected]');
|
|
await createVerificationToken('[email protected]');
|
|
|
|
expect(await db.verificationToken.count()).toBe(2);
|
|
expect(await db.verificationToken.findUnique({ where: { token: sha256(ada) } })).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('consumeVerificationToken', () => {
|
|
it('verifies the account and clears the token', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
|
|
expect(await consumeVerificationToken(token)).toBe('[email protected]');
|
|
|
|
expect(
|
|
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified
|
|
).toBeInstanceOf(Date);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
|
|
it('refuses a replayed token and keeps the original verification timestamp', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
await consumeVerificationToken(token);
|
|
const verifiedAt = (await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified;
|
|
|
|
expect(await consumeVerificationToken(token)).toBeNull();
|
|
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual(
|
|
verifiedAt
|
|
);
|
|
});
|
|
|
|
it('refuses an expired token, verifies nobody, and deletes the row', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
await expireToken(sha256(token));
|
|
|
|
expect(await consumeVerificationToken(token)).toBeNull();
|
|
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
|
|
// Whoever reads the database sees the digest. Presenting it back must not
|
|
// verify anything, and must not burn the live token either.
|
|
it('refuses the stored digest offered as if it were the token', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
const stored = (await db.verificationToken.findFirstOrThrow()).token;
|
|
|
|
expect(await consumeVerificationToken(stored)).toBeNull();
|
|
|
|
expect(await consumeVerificationToken(token)).toBe('[email protected]');
|
|
});
|
|
|
|
it('refuses a token nobody was ever issued', async () => {
|
|
expect(await consumeVerificationToken('f'.repeat(64))).toBeNull();
|
|
});
|
|
|
|
it('refuses a token for an account that is already verified, and clears it', async () => {
|
|
const verifiedAt = new Date(Date.now() - 60 * MINUTE_MS);
|
|
const user = await createUser({ email: '[email protected]', emailVerified: verifiedAt });
|
|
const token = await createVerificationToken('[email protected]');
|
|
|
|
expect(await consumeVerificationToken(token)).toBeNull();
|
|
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toEqual(
|
|
verifiedAt
|
|
);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
|
|
it('refuses a token whose account no longer exists', async () => {
|
|
const token = await createVerificationToken('[email protected]');
|
|
|
|
expect(await consumeVerificationToken(token)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('isEmailVerificationEnabled', () => {
|
|
it('is on with the SMTP trio configured, as .env.test has it', () => {
|
|
expect(isEmailVerificationEnabled()).toBe(true);
|
|
});
|
|
|
|
// A self-hosted deployment without a mail server has to keep working, so any
|
|
// one of the three going missing turns verification off entirely.
|
|
it.each(['SMTP_HOST', 'SMTP_USER', 'SMTP_PASSWORD'])('is off without %s', (variable) => {
|
|
vi.stubEnv(variable, '');
|
|
|
|
expect(isEmailVerificationEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('sendVerificationEmail', () => {
|
|
it('mails a link carrying the raw token', async () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
|
|
const token = 'a'.repeat(64);
|
|
|
|
await sendVerificationEmail('[email protected]', token);
|
|
|
|
const mails = mailTo('[email protected]');
|
|
expect(mails).toHaveLength(1);
|
|
expect(mails[0].subject).toBe('Verify your OpenFrame email address');
|
|
expect(mails[0].html).toContain(
|
|
`https://app.example.test/api/auth/verify-email?token=${token}`
|
|
);
|
|
});
|
|
|
|
it('escapes a token that carries query syntax', async () => {
|
|
vi.stubEnv('NEXTAUTH_URL', 'https://app.example.test');
|
|
|
|
await sendVerificationEmail('[email protected]', 'a b&c');
|
|
|
|
expect(mailTo('[email protected]')[0].html).toContain(
|
|
'https://app.example.test/api/auth/verify-email?token=a%20b%26c'
|
|
);
|
|
});
|
|
|
|
// Without an origin the link would be relative and the account unreachable.
|
|
// Sending a broken link is worse than sending nothing.
|
|
it('sends nothing when NEXTAUTH_URL is missing', async () => {
|
|
vi.stubEnv('NEXTAUTH_URL', '');
|
|
|
|
await sendVerificationEmail('[email protected]', 'a'.repeat(64));
|
|
|
|
expect(sentMail()).toEqual([]);
|
|
});
|
|
|
|
it('sends nothing when SMTP is not configured', async () => {
|
|
vi.stubEnv('SMTP_HOST', '');
|
|
vi.stubEnv('SMTP_USER', '');
|
|
vi.stubEnv('SMTP_PASSWORD', '');
|
|
|
|
await sendVerificationEmail('[email protected]', 'a'.repeat(64));
|
|
|
|
expect(sentMail()).toEqual([]);
|
|
});
|
|
|
|
// A mail server that is refusing connections must not turn a successful
|
|
// registration into a 500, so the rejection is swallowed here.
|
|
it('swallows a rejecting transport', async () => {
|
|
vi.mocked(nodemailer.createTransport).mockReturnValueOnce({
|
|
sendMail: vi.fn(async () => {
|
|
throw new Error('smtp is down');
|
|
}),
|
|
} as unknown as ReturnType<typeof nodemailer.createTransport>);
|
|
|
|
await expect(sendVerificationEmail('[email protected]', 'a'.repeat(64))).resolves.toBeUndefined();
|
|
expect(sentMail()).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// The route is anonymous by design: the token in the query string is the only
|
|
// credential, so there is no forbidden case to test, only good and bad tokens.
|
|
describe('GET /api/auth/verify-email', () => {
|
|
function verifyRequest(token: string) {
|
|
return apiRequest('/api/auth/verify-email', { searchParams: { token } });
|
|
}
|
|
|
|
it('verifies the account and sends the visitor to the login page', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
|
|
const response = await callRoute(verifyEmail, verifyRequest(token));
|
|
|
|
expect(response.headers.get('location')).toBe('http://localhost:3000/login?verified=true');
|
|
expect(
|
|
(await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified
|
|
).toBeInstanceOf(Date);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
|
|
// A raw token is 64 hex characters. Anything else is rejected before it can
|
|
// reach the database, which is what keeps enumeration cheap for us and not
|
|
// for the attacker.
|
|
it.each([['short'], ['g'.repeat(64)], ['A'.repeat(64)], ['']])(
|
|
'rejects the malformed token %j without touching the stored one',
|
|
async (token) => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
await createVerificationToken('[email protected]');
|
|
|
|
const response = await callRoute(verifyEmail, verifyRequest(token));
|
|
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=InvalidVerificationToken'
|
|
);
|
|
expect(await db.verificationToken.count()).toBe(1);
|
|
}
|
|
);
|
|
|
|
it('rejects an expired token and leaves the account unverified', async () => {
|
|
const user = await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
await expireToken(sha256(token));
|
|
|
|
const response = await callRoute(verifyEmail, verifyRequest(token));
|
|
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=InvalidVerificationToken'
|
|
);
|
|
expect((await db.user.findUniqueOrThrow({ where: { id: user.id } })).emailVerified).toBeNull();
|
|
});
|
|
|
|
it('rejects a replayed token', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
const token = await createVerificationToken('[email protected]');
|
|
await callRoute(verifyEmail, verifyRequest(token));
|
|
|
|
const response = await callRoute(verifyEmail, verifyRequest(token));
|
|
|
|
expect(response.headers.get('location')).toBe(
|
|
'http://localhost:3000/login?error=InvalidVerificationToken'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/auth/verify-email/resend', () => {
|
|
function resendRequest(body: unknown) {
|
|
return apiRequest('/api/auth/verify-email/resend', { body });
|
|
}
|
|
|
|
it('issues a fresh token to an unverified account and kills the previous link', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
const firstToken = await createVerificationToken('[email protected]');
|
|
|
|
const response = await callRoute(
|
|
resendVerification,
|
|
resendRequest({ email: '[email protected]' })
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(await db.verificationToken.count()).toBe(1);
|
|
const mails = mailTo('[email protected]');
|
|
expect(mails).toHaveLength(1);
|
|
const mailedToken = mails[0].html?.match(/token=([0-9a-f]{64})/)?.[1];
|
|
expect(mailedToken).toBeTruthy();
|
|
expect(mailedToken).not.toBe(firstToken);
|
|
// The mailed token is the raw one and the row still holds only a digest.
|
|
expect((await db.verificationToken.findFirstOrThrow()).token).toBe(sha256(mailedToken!));
|
|
expect(await consumeVerificationToken(firstToken)).toBeNull();
|
|
});
|
|
|
|
it('normalizes the address before looking the account up', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
|
|
const response = await callRoute(
|
|
resendVerification,
|
|
resendRequest({ email: ' [email protected] ' })
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(mailTo('[email protected]')).toHaveLength(1);
|
|
});
|
|
|
|
// The endpoint is unauthenticated, so a different answer for a known address
|
|
// would turn it into an account-existence oracle.
|
|
it('answers an unknown address exactly as it answers a real one, and mails nothing', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
|
|
const known = await callRoute(resendVerification, resendRequest({ email: '[email protected]' }));
|
|
const unknown = await callRoute(
|
|
resendVerification,
|
|
resendRequest({ email: '[email protected]' })
|
|
);
|
|
|
|
const knownBody = await readData<{ message: string }>(known);
|
|
const unknownBody = await readData<{ message: string }>(unknown);
|
|
expect(unknown.status).toBe(known.status);
|
|
expect(unknownBody).toEqual(knownBody);
|
|
expect(unknownBody.message).toBe(RESEND_MESSAGE);
|
|
expect(mailTo('[email protected]')).toEqual([]);
|
|
});
|
|
|
|
it('mails nothing to an account that is already verified', async () => {
|
|
await createUser({ email: '[email protected]', emailVerified: new Date() });
|
|
|
|
const response = await callRoute(
|
|
resendVerification,
|
|
resendRequest({ email: '[email protected]' })
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(sentMail()).toEqual([]);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
|
|
it.each([[{}], [{ email: 42 }], [{ email: 'no-at-sign' }], [{ email: 'sp [email protected]' }]])(
|
|
'rejects %j with 400',
|
|
async (body) => {
|
|
const response = await callRoute(resendVerification, resendRequest(body));
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
expect(sentMail()).toEqual([]);
|
|
}
|
|
);
|
|
|
|
it('refuses to run at all when SMTP is not configured', async () => {
|
|
vi.stubEnv('SMTP_HOST', '');
|
|
vi.stubEnv('SMTP_USER', '');
|
|
vi.stubEnv('SMTP_PASSWORD', '');
|
|
await createUser({ email: '[email protected]', emailVerified: null });
|
|
|
|
const response = await callRoute(
|
|
resendVerification,
|
|
resendRequest({ email: '[email protected]' })
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(await readError(response)).toBe('Email verification is not enabled');
|
|
expect(await db.verificationToken.count()).toBe(0);
|
|
});
|
|
});
|