Add e2e auth test suite, retention crons, and email-verification UX
CI / build-and-test (push) Has been cancelled

- E2E suite (23 tests, `npm run test:e2e -w @lawdesk/api`): boots the real
  Fastify app against a disposable Dockerized Postgres (never a real DB) and
  covers signup/login/lockout/rate limits, CSRF (incl. forged-token
  rejection), logout, password reset, email verification, the superadmin
  verified-email promotion gate, and cross-firm tenancy isolation
- packages/db: DATABASE_SSL=disable opt-out for local/test databases that
  don't speak TLS; refused in production
- retention-sweep.ts cron enforcing Privacy Policy windows (sessions,
  tokens, login attempts, tool usage, contact messages, audit log) +
  sweep-orphaned-storage.ts Spaces reconciliation + scripts/README
- Expose emailVerified on the session user; in-app verify-email banner
  with resend, and verified=1|0 toasts on the login page
- Silence Fastify logger under NODE_ENV=test; fix footer resource link;
  document login-attempt/tool-usage retention in the Privacy Policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-16 14:32:55 -04:00
co-authored by Claude Fable 5
parent 97e1d4c60b
commit d9b807662a
20 changed files with 983 additions and 6 deletions
+595
View File
@@ -0,0 +1,595 @@
import crypto from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { E2E_DATABASE_URL } from './env';
type InjectResponse = Awaited<ReturnType<FastifyInstance['inject']>>;
// Loaded dynamically in beforeAll, AFTER the safety gate confirms we point at the
// disposable container — never at a real database.
let app: FastifyInstance;
let db: typeof import('@lawdesk/db');
let eq: typeof import('drizzle-orm').eq;
let ipCounter = 0;
/** Fresh source IP per scenario so per-IP rate limits never bleed between tests. */
function nextIp(): string {
ipCounter += 1;
return `10.99.${Math.floor(ipCounter / 250)}.${(ipCounter % 250) + 1}`;
}
let emailCounter = 0;
function nextEmail(): string {
emailCounter += 1;
return `e2e-user-${emailCounter}@example.com`;
}
const PASSWORD = 'correct-horse-battery';
function cookiesOf(res: InjectResponse): Record<string, string> {
const out: Record<string, string> = {};
for (const c of res.cookies) out[c.name] = c.value;
return out;
}
interface Session {
sid: string;
csrf: string;
user: { id: string; email: string; firmId: string; isSuperadmin: boolean };
ip: string;
}
async function signup(overrides: { email?: string; ip?: string } = {}): Promise<Session> {
const ip = overrides.ip ?? nextIp();
const email = overrides.email ?? nextEmail();
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: { email, password: PASSWORD, fullName: 'E2e Tester', firmName: 'E2e Firm' },
});
expect(res.statusCode).toBe(201);
const cookies = cookiesOf(res);
expect(cookies.sid).toBeTruthy();
expect(cookies.csrf).toBeTruthy();
return { sid: cookies.sid, csrf: cookies.csrf, user: res.json().user, ip };
}
function authed(s: Session, extra: Record<string, string> = {}) {
return {
cookies: { sid: s.sid, csrf: s.csrf },
headers: { 'x-csrf-token': s.csrf, ...extra },
remoteAddress: s.ip,
};
}
function sha256(raw: string): string {
return crypto.createHash('sha256').update(raw).digest('hex');
}
beforeAll(async () => {
// Hard safety gate: this suite creates/mutates/deletes users. Refuse to run against
// anything but the disposable e2e container defined in ./env.ts.
if (process.env.DATABASE_URL !== E2E_DATABASE_URL) {
throw new Error(
'REFUSING to run e2e tests: DATABASE_URL is not the disposable e2e database. ' +
'Run via `npm run test:e2e` (vitest.e2e.config.ts), never against a real DB.',
);
}
db = await import('@lawdesk/db');
({ eq } = await import('drizzle-orm'));
const { buildServer } = await import('../src/server');
app = await buildServer();
await app.ready();
});
afterAll(async () => {
await app?.close();
});
describe('signup', () => {
it('creates a user + firm, sets session and csrf cookies, and /me works', async () => {
const ip = nextIp();
const email = nextEmail();
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: { email, password: PASSWORD, fullName: 'Jane Doe', firmName: 'Doe Law' },
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.user.email).toBe(email);
expect(body.user.firmId).toBeTruthy();
expect(body.user.role).toBe('owner');
expect(body.user.isSuperadmin).toBe(false);
const setCookie = res.headers['set-cookie'] as string[] | string;
const raw = Array.isArray(setCookie) ? setCookie.join('\n') : String(setCookie);
expect(raw).toMatch(/sid=[^;]+;[^\n]*HttpOnly/i); // session cookie must be HttpOnly
const csrfLine = raw.split('\n').find((l) => l.startsWith('csrf='));
expect(csrfLine).toBeTruthy();
expect(csrfLine!).not.toMatch(/HttpOnly/i); // csrf cookie must be JS-readable
const cookies = cookiesOf(res);
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: cookies.sid },
remoteAddress: ip,
});
expect(me.statusCode).toBe(200);
expect(me.json().user.email).toBe(email);
});
it('rejects a duplicate email with 409', async () => {
const email = nextEmail();
await signup({ email });
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: nextIp(),
payload: { email, password: PASSWORD, fullName: 'Dup', firmName: 'Dup Firm' },
});
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('email_taken');
});
it('rejects passwords shorter than 10 chars with 400', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: nextIp(),
payload: { email: nextEmail(), password: 'short', fullName: 'X', firmName: 'Y' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('validation');
});
it('rate-limits signup to 5 requests/hour per IP', async () => {
const ip = nextIp();
for (let i = 0; i < 5; i++) {
// Invalid payloads still count against the limiter and are cheap (no argon2).
const res = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: {},
});
expect(res.statusCode).toBe(400);
}
const sixth = await app.inject({
method: 'POST',
url: '/api/auth/signup',
remoteAddress: ip,
payload: {},
});
expect(sixth.statusCode).toBe(429);
});
});
describe('login', () => {
it('rejects a wrong password with 401 and accepts the right one', async () => {
const s = await signup();
const ip = nextIp();
const bad = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: s.user.email, password: 'definitely-wrong-pw' },
});
expect(bad.statusCode).toBe(401);
expect(bad.json().error).toBe('invalid_credentials');
const good = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: s.user.email, password: PASSWORD },
});
expect(good.statusCode).toBe(200);
const cookies = cookiesOf(good);
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: cookies.sid },
remoteAddress: ip,
});
expect(me.statusCode).toBe(200);
});
it('returns 401 for a nonexistent email (no enumeration via status)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: 'nobody-here@example.com', password: PASSWORD },
});
expect(res.statusCode).toBe(401);
expect(res.json().error).toBe('invalid_credentials');
});
it('blocks a suspended user at login and on API routes', async () => {
const s = await signup();
await db
.getDb()
.update(db.users)
.set({ isSuspended: true })
.where(eq(db.users.id, s.user.id));
const login = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(login.statusCode).toBe(403);
expect(login.json().error).toBe('account_suspended');
// Existing session must also be blocked by requireAuth on business routes.
const list = await app.inject({
method: 'GET',
url: '/api/clients',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(list.statusCode).toBe(403);
expect(list.json().error).toBe('account_suspended');
});
it('locks out after 5 failed attempts per (email, IP) but not from another IP', async () => {
const s = await signup();
const attackerIp = nextIp();
for (let i = 0; i < 5; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: attackerIp,
payload: { email: s.user.email, password: `wrong-${i}-padding` },
});
expect(res.statusCode).toBe(401);
}
// 6th attempt from the same IP is locked out even with the CORRECT password.
const locked = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: attackerIp,
payload: { email: s.user.email, password: PASSWORD },
});
expect(locked.statusCode).toBe(429);
expect(locked.json().error).toBe('too_many_attempts');
// The legitimate owner on a different IP is NOT locked out by the attacker.
const owner = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(owner.statusCode).toBe(200);
});
it('rate-limits the login route to 10 requests/15min per IP', async () => {
const ip = nextIp();
for (let i = 0; i < 10; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: `ratelimit-${i}@example.com`, password: 'whatever-pw' },
});
expect(res.statusCode).toBe(401);
}
const eleventh = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: ip,
payload: { email: 'ratelimit-x@example.com', password: 'whatever-pw' },
});
expect(eleventh.statusCode).toBe(429);
});
});
describe('csrf', () => {
it('rejects an authenticated state-changing request without the CSRF header', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: s.csrf },
remoteAddress: s.ip,
payload: { name: 'No Header Client' },
});
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('csrf_failed');
});
it('rejects a mismatched cookie/header pair', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: s.csrf },
headers: { 'x-csrf-token': 'not-the-cookie-value' },
remoteAddress: s.ip,
payload: { name: 'Mismatch Client' },
});
expect(res.statusCode).toBe(403);
});
it('rejects an attacker-planted (unsigned) matching pair — signature required', async () => {
const s = await signup();
const planted = 'attacker-random.attacker-fake-signature';
const res = await app.inject({
method: 'POST',
url: '/api/clients',
cookies: { sid: s.sid, csrf: planted },
headers: { 'x-csrf-token': planted },
remoteAddress: s.ip,
payload: { name: 'Planted Client' },
});
expect(res.statusCode).toBe(403);
});
it('accepts the legitimate cookie+header pair', async () => {
const s = await signup();
const res = await app.inject({
method: 'POST',
url: '/api/clients',
...authed(s),
payload: { name: 'Legit Client' },
});
expect(res.statusCode).toBe(201);
expect(res.json().name).toBe('Legit Client');
});
});
describe('logout', () => {
it('destroys the session server-side', async () => {
const s = await signup();
const out = await app.inject({ method: 'POST', url: '/api/auth/logout', ...authed(s) });
expect(out.statusCode).toBe(200);
// Replaying the old cookie after logout must fail — session is gone from the DB.
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(401);
});
});
describe('password reset', () => {
it('always returns ok (no account enumeration)', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/auth/request-password-reset',
remoteAddress: nextIp(),
payload: { email: 'ghost-account@example.com' },
});
expect(res.statusCode).toBe(200);
expect(res.json().ok).toBe(true);
});
it('resets the password, revokes all sessions, and consumes the token', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.passwordResets).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const newPassword = 'brand-new-password-42';
const reset = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: newPassword },
});
expect(reset.statusCode).toBe(200);
// All prior sessions revoked.
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(401);
// Old password dead, new password works.
const oldLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: PASSWORD },
});
expect(oldLogin.statusCode).toBe(401);
const newLogin = await app.inject({
method: 'POST',
url: '/api/auth/login',
remoteAddress: nextIp(),
payload: { email: s.user.email, password: newPassword },
});
expect(newLogin.statusCode).toBe(200);
// Token is single-use.
const replay = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: 'yet-another-password' },
});
expect(replay.statusCode).toBe(400);
expect(replay.json().error).toBe('invalid_or_used_token');
});
it('rejects an expired token', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.passwordResets).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() - 1000),
});
const res = await app.inject({
method: 'POST',
url: '/api/auth/reset-password',
remoteAddress: nextIp(),
payload: { token: rawToken, password: 'whatever-new-pass' },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('token_expired');
});
});
describe('email verification', () => {
it('verifies via the emailed token, exactly once', async () => {
const s = await signup();
const rawToken = crypto.randomBytes(32).toString('base64url');
await db.getDb().insert(db.emailVerifications).values({
tokenHash: sha256(rawToken),
userId: s.user.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
const ok = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${rawToken}`,
remoteAddress: nextIp(),
});
expect(ok.statusCode).toBe(302);
expect(ok.headers.location).toContain('verified=1');
const [row] = await db
.getDb()
.select()
.from(db.users)
.where(eq(db.users.id, s.user.id))
.limit(1);
expect(row!.emailVerifiedAt).not.toBeNull();
// Consumed token cannot be replayed.
const replay = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${rawToken}`,
remoteAddress: nextIp(),
});
expect(replay.statusCode).toBe(302);
expect(replay.headers.location).toContain('verified=0');
});
it('rejects a garbage token', async () => {
const res = await app.inject({
method: 'GET',
url: `/api/auth/verify-email?token=${crypto.randomBytes(32).toString('base64url')}`,
remoteAddress: nextIp(),
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toContain('verified=0');
});
});
describe('superadmin promotion gate', () => {
it('does NOT promote an allowlisted email until it is verified', async () => {
// superadmin-e2e@example.com is on SUPERADMIN_EMAILS (see env.ts).
const s = await signup({ email: 'superadmin-e2e@example.com' });
expect(s.user.isSuperadmin).toBe(false);
const denied = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(denied.statusCode).toBe(403);
// Simulate clicking the verification link, then the next request auto-promotes.
await db
.getDb()
.update(db.users)
.set({ emailVerifiedAt: new Date() })
.where(eq(db.users.id, s.user.id));
const me = await app.inject({
method: 'GET',
url: '/api/auth/me',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(me.statusCode).toBe(200);
expect(me.json().user.isSuperadmin).toBe(true);
const allowed = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(allowed.statusCode).toBe(200);
});
it('keeps admin routes closed to normal users', async () => {
const s = await signup();
const res = await app.inject({
method: 'GET',
url: '/api/admin/stats',
cookies: { sid: s.sid },
remoteAddress: s.ip,
});
expect(res.statusCode).toBe(403);
expect(res.json().error).toBe('forbidden');
});
});
describe('tenancy isolation (auth-adjacent)', () => {
it('blocks unauthenticated access to business routes', async () => {
const res = await app.inject({ method: 'GET', url: '/api/clients', remoteAddress: nextIp() });
expect(res.statusCode).toBe(401);
});
it("prevents one firm from reading or writing another firm's data", async () => {
const alice = await signup();
const created = await app.inject({
method: 'POST',
url: '/api/clients',
...authed(alice),
payload: { name: 'Alice Secret Client' },
});
expect(created.statusCode).toBe(201);
const clientId = created.json().id;
const bob = await signup();
const read = await app.inject({
method: 'GET',
url: `/api/clients/${clientId}`,
cookies: { sid: bob.sid },
remoteAddress: bob.ip,
});
expect(read.statusCode).toBe(404);
const write = await app.inject({
method: 'PATCH',
url: `/api/clients/${clientId}`,
...authed(bob),
payload: { name: 'Bob Was Here' },
});
expect(write.statusCode).toBe(404);
// And Alice still sees her client untouched.
const alicRead = await app.inject({
method: 'GET',
url: `/api/clients/${clientId}`,
cookies: { sid: alice.sid },
remoteAddress: alice.ip,
});
expect(alicRead.statusCode).toBe(200);
expect(alicRead.json().name).toBe('Alice Secret Client');
});
});