Add e2e auth test suite, retention crons, and email-verification UX
CI / build-and-test (push) Has been cancelled
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:
co-authored by
Claude Fable 5
parent
97e1d4c60b
commit
d9b807662a
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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: '',
|
||||
};
|
||||
@@ -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<void> {
|
||||
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' });
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user