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
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -33,7 +33,10 @@ initSentry();
|
||||
|
||||
export async function buildServer() {
|
||||
const app = Fastify({
|
||||
logger: isProd
|
||||
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).
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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() {
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<Topbar />
|
||||
<VerifyEmailBanner />
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -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 (
|
||||
<div className="border-b border-amber-200 bg-amber-50 px-4 py-2.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-amber-900">
|
||||
<MailWarning className="h-4 w-4 flex-none text-amber-600" />
|
||||
<span>
|
||||
Please verify your email — we sent a link to <strong>{me.data.email}</strong>.
|
||||
</span>
|
||||
{state === 'sent' ? (
|
||||
<span className="font-medium text-emerald-700">Verification email sent ✓</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resend}
|
||||
disabled={state === 'sending'}
|
||||
className="font-semibold text-amber-800 underline underline-offset-2 hover:text-amber-950 disabled:opacity-60"
|
||||
>
|
||||
{state === 'sending' ? 'Sending…' : 'Resend email'}
|
||||
</button>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<span className="text-rose-700">Couldn't send — try again in a few minutes.</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ const COLUMNS = [
|
||||
{
|
||||
title: 'Resources',
|
||||
links: [
|
||||
{ href: '/resources', label: 'Resource Hub' },
|
||||
{ href: '/#resources', label: 'Resource Hub' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AuthUser {
|
||||
role: string;
|
||||
isSuperadmin?: boolean;
|
||||
isSuspended?: boolean;
|
||||
emailVerified?: boolean;
|
||||
}
|
||||
|
||||
interface MeResponse {
|
||||
|
||||
@@ -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' && (
|
||||
<p className="mb-4 rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||
Email verified — thanks! Sign in to continue.
|
||||
</p>
|
||||
)}
|
||||
{verified === '0' && (
|
||||
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
That verification link is invalid or has expired. Sign in and use “Resend
|
||||
email” to get a fresh one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Field
|
||||
label="Email"
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function PrivacyPage() {
|
||||
'Audit log — kept for 24 months.',
|
||||
'Payment records — kept as required by tax and accounting laws (typically 7 years).',
|
||||
'Server logs — kept for 30 days.',
|
||||
'Login-attempt and free-tool usage records (email, IP address) — kept for 90 days, then deleted.',
|
||||
'Contact-form messages — kept for 24 months, then deleted.',
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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). |
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 `<uuid>/<uuid>/<uuid>.<ext>` 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);
|
||||
});
|
||||
Reference in New Issue
Block a user