mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-12 01:46:08 +00:00
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.
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user