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>; // 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 { const out: Record = {}; 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 { 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 = {}) { 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'); }); });