diff --git a/apps/api/package.json b/apps/api/package.json index e411e38..cb0858d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "start": "tsx src/server.ts", "test": "vitest run", "test:watch": "vitest", + "test:e2e": "vitest run -c vitest.e2e.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/apps/api/src/auth/plugin.ts b/apps/api/src/auth/plugin.ts index 23e4a98..41d0b8f 100644 --- a/apps/api/src/auth/plugin.ts +++ b/apps/api/src/auth/plugin.ts @@ -13,6 +13,7 @@ declare module 'fastify' { role: string; isSuperadmin: boolean; isSuspended: boolean; + emailVerified: boolean; }; } interface FastifyInstance { @@ -47,6 +48,7 @@ async function plugin(app: FastifyInstance) { role: session.user.role, isSuperadmin, isSuspended: session.user.isSuspended, + emailVerified: Boolean(session.user.emailVerifiedAt), }; }); diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 58e617c..6ab2704 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -96,7 +96,7 @@ export async function accountRoutes(app: FastifyInstance) { .header('Content-Type', 'application/json; charset=utf-8') .header( 'Content-Disposition', - `attachment; filename="lawdesk-export-${new Date().toISOString().slice(0, 10)}.json"`, + `attachment; filename="elegal-export-${new Date().toISOString().slice(0, 10)}.json"`, ); return JSON.stringify(dump, null, 2); }); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index eb171ea..3902b1f 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -134,6 +134,7 @@ export async function authRoutes(app: FastifyInstance) { role: user.role, isSuperadmin, isSuspended: user.isSuspended, + emailVerified: Boolean(user.emailVerifiedAt), }, }); }); @@ -191,6 +192,7 @@ export async function authRoutes(app: FastifyInstance) { role: user.role, isSuperadmin, isSuspended: user.isSuspended, + emailVerified: Boolean(user.emailVerifiedAt), }, }; }); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 548cc54..01efdb5 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -33,9 +33,12 @@ initSentry(); export async function buildServer() { const app = Fastify({ - logger: isProd - ? { level: 'info' } - : { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } }, + logger: + env.NODE_ENV === 'test' + ? false + : isProd + ? { level: 'info' } + : { level: 'debug', transport: { target: 'pino-pretty', options: { colorize: true } } }, // Trust exactly ONE proxy hop (the Plesk/nginx reverse proxy in front of Passenger). // `true` would trust the entire X-Forwarded-For chain, letting any client spoof req.ip and // evade the IP-keyed rate limits (including auth brute-force protection). diff --git a/apps/api/test-e2e/auth.e2e.test.ts b/apps/api/test-e2e/auth.e2e.test.ts new file mode 100644 index 0000000..40e856d --- /dev/null +++ b/apps/api/test-e2e/auth.e2e.test.ts @@ -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>; + +// 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'); + }); +}); diff --git a/apps/api/test-e2e/env.ts b/apps/api/test-e2e/env.ts new file mode 100644 index 0000000..a080ae2 --- /dev/null +++ b/apps/api/test-e2e/env.ts @@ -0,0 +1,33 @@ +// Shared constants for the e2e harness. The database is a DISPOSABLE Docker container — +// these tests must never point at a real database. global-setup.ts creates/destroys the +// container; auth.e2e.test.ts refuses to run unless DATABASE_URL matches this URL exactly. + +export const E2E_CONTAINER = 'elegal-e2e-pg'; +export const E2E_DB_PORT = 54329; +export const E2E_DB_NAME = 'elegal_e2e'; +export const E2E_DB_PASSWORD = 'e2e_throwaway_password'; + +export const E2E_DATABASE_URL = `postgres://postgres:${E2E_DB_PASSWORD}@127.0.0.1:${E2E_DB_PORT}/${E2E_DB_NAME}`; + +export const E2E_ENV: Record = { + NODE_ENV: 'test', + DATABASE_URL: E2E_DATABASE_URL, + DATABASE_SSL: 'disable', + DATABASE_CA_CERT_PATH: '', + PUBLIC_URL: 'http://localhost:8080', + COOKIE_DOMAIN: '', + SESSION_SECRET: 'e2e-session-secret-not-real-0123456789abcdef', + CSRF_SECRET: 'e2e-csrf-secret-not-real-0123456789abcdef', + SUPERADMIN_EMAILS: 'superadmin-e2e@example.com', + // Dummy Spaces config — env.ts requires these; no storage calls happen in auth tests. + SPACES_ENDPOINT: 'https://e2e-invalid.example.com', + SPACES_REGION: 'e2e', + SPACES_BUCKET: 'e2e-bucket', + SPACES_KEY: 'e2e-key', + SPACES_SECRET: 'e2e-secret', + // Empty → email sends are skipped, Stripe/Sentry stay inert. + SMTP2GO_API_KEY: '', + STRIPE_SECRET_KEY: '', + STRIPE_WEBHOOK_SECRET: '', + SENTRY_DSN_API: '', +}; diff --git a/apps/api/test-e2e/global-setup.ts b/apps/api/test-e2e/global-setup.ts new file mode 100644 index 0000000..db9b614 --- /dev/null +++ b/apps/api/test-e2e/global-setup.ts @@ -0,0 +1,67 @@ +import { execSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { + E2E_CONTAINER, + E2E_DATABASE_URL, + E2E_DB_NAME, + E2E_DB_PASSWORD, + E2E_DB_PORT, +} from './env'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS = path.resolve(__dirname, '../../../packages/db/migrations'); + +async function waitForPostgres(timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + let lastErr: unknown; + while (Date.now() < deadline) { + const client = new pg.Client({ connectionString: E2E_DATABASE_URL }); + try { + await client.connect(); + await client.query('select 1'); + await client.end(); + return; + } catch (err) { + lastErr = err; + await client.end().catch(() => {}); + await new Promise((r) => setTimeout(r, 500)); + } + } + throw new Error(`e2e postgres did not become ready in ${timeoutMs}ms: ${lastErr}`); +} + +export default async function setup() { + try { + execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' }); + } catch { + // no stale container — fine + } + + execSync( + `docker run --rm -d --name ${E2E_CONTAINER} -p ${E2E_DB_PORT}:5432 ` + + `-e POSTGRES_PASSWORD=${E2E_DB_PASSWORD} -e POSTGRES_DB=${E2E_DB_NAME} postgres:16-alpine`, + { stdio: 'inherit' }, + ); + + try { + await waitForPostgres(); + + const pool = new pg.Pool({ connectionString: E2E_DATABASE_URL, max: 2 }); + try { + await migrate(drizzle(pool), { migrationsFolder: MIGRATIONS }); + } finally { + await pool.end(); + } + } catch (err) { + execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' }); + throw err; + } + + return () => { + execSync(`docker rm -f ${E2E_CONTAINER}`, { stdio: 'ignore' }); + }; +} diff --git a/apps/api/vitest.e2e.config.ts b/apps/api/vitest.e2e.config.ts new file mode 100644 index 0000000..b66cd37 --- /dev/null +++ b/apps/api/vitest.e2e.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { E2E_ENV } from './test-e2e/env'; + +// E2E suite: real Fastify app + real (disposable, Dockerized) Postgres. +// Kept out of the default `npm test` (unit) config on purpose — requires Docker. +export default defineConfig({ + test: { + include: ['test-e2e/**/*.e2e.test.ts'], + environment: 'node', + env: E2E_ENV, + globalSetup: ['./test-e2e/global-setup.ts'], + // One worker: tests share the app instance and rate-limiter state is IP-partitioned per test. + fileParallelism: false, + testTimeout: 60_000, + hookTimeout: 120_000, + }, +}); diff --git a/apps/web/src/components/app/AppLayout.tsx b/apps/web/src/components/app/AppLayout.tsx index 83f3095..eb7f71c 100644 --- a/apps/web/src/components/app/AppLayout.tsx +++ b/apps/web/src/components/app/AppLayout.tsx @@ -2,6 +2,7 @@ import { Navigate, Outlet } from 'react-router-dom'; import { useMe } from '@/hooks/useAuth'; import { Sidebar } from './Sidebar'; import { Topbar } from './Topbar'; +import { VerifyEmailBanner } from './VerifyEmailBanner'; export function AppLayout() { const me = useMe(); @@ -19,6 +20,7 @@ export function AppLayout() {
+
diff --git a/apps/web/src/components/app/VerifyEmailBanner.tsx b/apps/web/src/components/app/VerifyEmailBanner.tsx new file mode 100644 index 0000000..a50365e --- /dev/null +++ b/apps/web/src/components/app/VerifyEmailBanner.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { MailWarning } from 'lucide-react'; +import { useMe } from '@/hooks/useAuth'; +import { api } from '@/lib/api'; + +export function VerifyEmailBanner() { + const me = useMe(); + const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); + + // Only when the API explicitly says unverified — older cached sessions omit the flag. + if (!me.data || me.data.emailVerified !== false) return null; + + async function resend() { + setState('sending'); + try { + await api.post('/api/auth/resend-verification'); + setState('sent'); + } catch { + setState('error'); + } + } + + return ( +
+ + + Please verify your email — we sent a link to {me.data.email}. + + {state === 'sent' ? ( + Verification email sent ✓ + ) : ( + + )} + {state === 'error' && ( + Couldn't send — try again in a few minutes. + )} +
+ ); +} diff --git a/apps/web/src/components/marketing/Footer.tsx b/apps/web/src/components/marketing/Footer.tsx index 88a7470..95dedcf 100644 --- a/apps/web/src/components/marketing/Footer.tsx +++ b/apps/web/src/components/marketing/Footer.tsx @@ -21,7 +21,7 @@ const COLUMNS = [ { title: 'Resources', links: [ - { href: '/resources', label: 'Resource Hub' }, + { href: '/#resources', label: 'Resource Hub' }, { href: '/blog', label: 'Blog' }, ], }, diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index e91b4f6..c745f8a 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -9,6 +9,7 @@ export interface AuthUser { role: string; isSuperadmin?: boolean; isSuspended?: boolean; + emailVerified?: boolean; } interface MeResponse { diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx index 0d23689..b19c151 100644 --- a/apps/web/src/pages/LoginPage.tsx +++ b/apps/web/src/pages/LoginPage.tsx @@ -25,6 +25,9 @@ export default function LoginPage() { const me = useMe(); const login = useLogin(); + // Landing target of the email-verification link: /login?verified=1|0 + const verified = new URLSearchParams(location.search).get('verified'); + const { register, handleSubmit, @@ -60,6 +63,18 @@ export default function LoginPage() { } > + {verified === '1' && ( +

+ Email verified — thanks! Sign in to continue. +

+ )} + {verified === '0' && ( +

+ That verification link is invalid or has expired. Sign in and use “Resend + email” to get a fresh one. +

+ )} +
diff --git a/package.json b/package.json index 1a84a9f..a973430 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,9 @@ "start": "node server.cjs", "db:generate": "npm run generate -w packages/db", "db:migrate": "npm run migrate -w packages/db", + "cron:retention": "tsx scripts/retention-sweep.ts", + "cron:overdue": "tsx scripts/send-overdue-reminders.ts", + "storage:sweep": "tsx scripts/sweep-orphaned-storage.ts", "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run test --workspaces --if-present" }, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index eb73e92..40fa208 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -35,7 +35,14 @@ export function getPool(): pg.Pool { // Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior. // Without this, pg merges URL-derived settings which can conflict with the options below. let ssl: pg.PoolConfig['ssl']; - if (ca) { + if (process.env.DATABASE_SSL === 'disable') { + // Explicit opt-out for local dev/test databases that don't speak TLS at all (e.g. a + // disposable Docker Postgres). Refused in production — prod must always verify TLS. + if (isProd) { + throw new Error('DATABASE_SSL=disable is not allowed in production'); + } + ssl = false; + } else if (ca) { // Verified TLS against the managed-DB CA — the correct posture everywhere. ssl = { ca, rejectUnauthorized: true }; } else if (isProd) { diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..64525cf --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,28 @@ +# Operational scripts + +All scripts load `.env` from the repo root and run against the **live** database/bucket — +there is no staging environment. Run them from the monorepo root. + +## Scheduled jobs (set up as daily crons on the production server) + +```cron +# Daily at 03:00 — enforce Privacy Policy retention windows +0 3 * * * cd /path/to/app && npm run cron:retention >> logs/retention.log 2>&1 + +# Daily at 08:00 — mark past-due invoices overdue + email client reminders +0 8 * * * cd /path/to/app && npm run cron:overdue >> logs/overdue.log 2>&1 +``` + +| Script | npm alias | What it does | +| --- | --- | --- | +| `retention-sweep.ts` | `npm run cron:retention` | Purges expired sessions, consumed/expired reset & verification tokens, login attempts and tool usage > 90 days, contact messages and audit log > 24 months. | +| `send-overdue-reminders.ts` | `npm run cron:overdue` | Flips past-due `sent` invoices to `overdue` and emails the client once, on the transition. | + +## Maintenance / one-off + +| Script | npm alias | What it does | +| --- | --- | --- | +| `sweep-orphaned-storage.ts` | `npm run storage:sweep` | Reconciles DO Spaces against the `documents` table. Dry-run by default; add `-- --delete` to remove orphans. Only document-shaped keys (`uuid/uuid/uuid.ext`) are ever deleted. | +| `create-admin.ts` | — | Creates a superadmin user (credentials via env/argv). | +| `seed-demo.ts` | — | Seeds demo data. **Live DB — use with care.** | +| `migrate-storage-to-spaces.ts` | — | One-time migration of legacy local files to Spaces (historical). | diff --git a/scripts/retention-sweep.ts b/scripts/retention-sweep.ts new file mode 100644 index 0000000..1e53786 --- /dev/null +++ b/scripts/retention-sweep.ts @@ -0,0 +1,79 @@ +// Enforces the data-retention windows promised in the Privacy Policy. Run daily: +// npx tsx scripts/retention-sweep.ts +// +// Windows enforced: +// - sessions: expired → deleted +// - password resets / email verifications: consumed or expired → deleted +// - login attempts (email, IP): 90 days +// - tool usage (IP): 90 days +// - contact messages: 24 months +// - audit log: 24 months + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +import { isNotNull, lt, or } from 'drizzle-orm'; +import { + getDb, + getPool, + sessions, + passwordResets, + emailVerifications, + loginAttempts, + contactMessages, + toolUsage, + auditLog, +} from '@lawdesk/db'; + +function daysAgo(n: number): Date { + return new Date(Date.now() - n * 24 * 60 * 60 * 1000); +} + +async function main() { + const db = getDb(); + const now = new Date(); + + const report: Array<[string, number]> = []; + const run = async (label: string, fn: () => Promise<{ rowCount?: number | null }>) => { + const res = await fn(); + report.push([label, res.rowCount ?? 0]); + }; + + await run('expired sessions', () => db.delete(sessions).where(lt(sessions.expiresAt, now))); + await run('consumed/expired password resets', () => + db + .delete(passwordResets) + .where(or(isNotNull(passwordResets.consumedAt), lt(passwordResets.expiresAt, now))), + ); + await run('consumed/expired email verifications', () => + db + .delete(emailVerifications) + .where(or(isNotNull(emailVerifications.consumedAt), lt(emailVerifications.expiresAt, now))), + ); + await run('login attempts > 90 days', () => + db.delete(loginAttempts).where(lt(loginAttempts.attemptedAt, daysAgo(90))), + ); + await run('tool usage > 90 days', () => + db.delete(toolUsage).where(lt(toolUsage.createdAt, daysAgo(90))), + ); + await run('contact messages > 24 months', () => + db.delete(contactMessages).where(lt(contactMessages.createdAt, daysAgo(730))), + ); + await run('audit log > 24 months', () => + db.delete(auditLog).where(lt(auditLog.createdAt, daysAgo(730))), + ); + + console.log('Retention sweep complete:'); + for (const [label, count] of report) console.log(` ${label}: ${count} row(s) deleted`); + + await getPool().end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/sweep-orphaned-storage.ts b/scripts/sweep-orphaned-storage.ts new file mode 100644 index 0000000..29f6bb0 --- /dev/null +++ b/scripts/sweep-orphaned-storage.ts @@ -0,0 +1,75 @@ +// Reconciles DO Spaces against the documents table and reports/deletes orphaned objects — +// files whose DB rows were removed before storage cleanup existed (deleted firms/cases/clients). +// +// Dry run (default): npx tsx scripts/sweep-orphaned-storage.ts +// Actually delete: npx tsx scripts/sweep-orphaned-storage.ts --delete +// +// Only keys matching the document layout `//.` are eligible for +// deletion; anything else in the bucket is reported but never touched. + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../.env') }); + +import { getDb, getPool, documents } from '@lawdesk/db'; +import { listAllKeys, deleteFile } from '../apps/api/src/lib/storage'; + +const DELETE = process.argv.includes('--delete'); +const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; +const DOC_KEY = new RegExp(`^${UUID}/${UUID}/${UUID}(\\.[A-Za-z0-9]+)?$`, 'i'); + +async function main() { + const db = getDb(); + + console.log('Listing bucket contents...'); + const bucketKeys = await listAllKeys(); + console.log(` ${bucketKeys.length} object(s) in bucket.`); + + const rows = await db.select({ storageKey: documents.storageKey }).from(documents); + const dbKeys = new Set(rows.map((r) => r.storageKey)); + console.log(` ${dbKeys.size} document row(s) in database.`); + + const orphans: string[] = []; + const unrecognized: string[] = []; + for (const key of bucketKeys) { + if (dbKeys.has(key)) continue; + if (DOC_KEY.test(key)) orphans.push(key); + else unrecognized.push(key); + } + + // Reverse check: DB rows whose file is missing from the bucket (report only). + const bucketSet = new Set(bucketKeys); + const missing = [...dbKeys].filter((k) => !bucketSet.has(k)); + + console.log(`\nOrphaned objects (in bucket, no DB row): ${orphans.length}`); + for (const k of orphans) console.log(` ${k}`); + if (unrecognized.length) { + console.log(`\nUnrecognized keys (not document-shaped — never deleted): ${unrecognized.length}`); + for (const k of unrecognized) console.log(` ${k}`); + } + if (missing.length) { + console.log(`\nWARNING — DB rows whose file is MISSING from the bucket: ${missing.length}`); + for (const k of missing) console.log(` ${k}`); + } + + if (!DELETE) { + console.log(`\nDry run — nothing deleted. Re-run with --delete to remove the ${orphans.length} orphan(s).`); + } else { + let deleted = 0; + for (const key of orphans) { + await deleteFile(key); + deleted++; + } + console.log(`\nDeleted ${deleted} orphaned object(s).`); + } + + await getPool().end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});