Files
OpenFrame/tests/api/email-verification.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

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);
});
});