diff --git a/.github/workflows/api-contract.yml b/.github/workflows/api-contract.yml index 6f89fec..4edfb11 100644 --- a/.github/workflows/api-contract.yml +++ b/.github/workflows/api-contract.yml @@ -4,6 +4,9 @@ on: pull_request: paths: - 'docs/openapi.yaml' + - 'docs/openapi-drift-baseline.json' + - 'scripts/check-openapi-drift.js' + - 'src/index.ts' - 'src/routes/**' - '.github/workflows/api-contract.yml' push: @@ -29,6 +32,9 @@ jobs: - name: Lint OpenAPI spec run: redocly lint docs/openapi.yaml --format=stylish + - name: Check route inventory against spec (drift) + run: node scripts/check-openapi-drift.js + - name: Bundle spec (verify all $refs resolve) run: redocly bundle docs/openapi.yaml --output /tmp/openapi-bundled.yaml diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index 1faa559..b311acf 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -362,6 +362,9 @@ HEALTHCHECK_URL=http://localhost:3001/health/ready \ # See docs/RUNBOOK.md for the incident procedure. ``` +If a migration failed part-way (rather than needing a rollback), see +[Appendix A: Common failure modes](#appendix-a-common-failure-modes). + After any rollback, redeploy the application version that matches the reverted schema. A tier-2 rollback that leaves the newer application running will fail at the first query against the dropped column — and the resulting 500s look like a @@ -388,7 +391,162 @@ The reviewer confirms these before approving. The PR template in ## 8. Related documentation +- [Appendix A: Common failure modes](#appendix-a-common-failure-modes) — recipes for failed migrations - [RUNBOOK.md](./RUNBOOK.md) — §6, the incident-time rollback procedure - [DEPLOYMENT.md](./DEPLOYMENT.md) — environment matrix, k8s deploy, secrets - [PRODUCTION_READINESS_CHECKLIST.md](./PRODUCTION_READINESS_CHECKLIST.md) - [RUNBOOK.md — incident contacts](./RUNBOOK.md#7-incident-contacts) + +--- + +## Appendix A. Common failure modes + +Recipes distilled from real incidents. Each one ends with the same two +commands: mark the failed row in `_prisma_migrations` so Prisma will retry, then +`npx prisma migrate deploy`. A migration that fails midway leaves a row with +`finished_at IS NULL AND rolled_back_at IS NULL`, and `migrate deploy` refuses +to proceed (`P3009`) until that row is resolved. + +```sql +-- Find the stuck row +SELECT migration_name, started_at, logs +FROM _prisma_migrations +WHERE finished_at IS NULL AND rolled_back_at IS NULL +ORDER BY started_at DESC; +``` + +### A.1 `CREATE INDEX CONCURRENTLY` inside a Prisma migration + +**Incident:** `20260928180000_add_webhook_query_indexes` + +**Symptom** + +``` +ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block +``` + +**Cause:** `prisma migrate deploy` runs each migration in a transaction. +PostgreSQL forbids `CONCURRENTLY` inside one. Any migration containing it fails +on first apply. + +**Recipe: production (keep `CONCURRENTLY`, no table lock)** + +```bash +# 1. Clear the failed row so Prisma will not block on it +npx prisma migrate resolve --rolled-back 20260928180000_add_webhook_query_indexes + +# 2. Apply the indexes by hand, outside any transaction. IF NOT EXISTS makes +# this safe to re-run after an interrupted build. +psql "$DATABASE_URL" <<'SQL' +CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_webhook_deliveries_endpointId_createdAt_idx" + ON "user_webhook_deliveries"("endpointId", "createdAt"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "webhook_deliveries_subscriptionId_status_createdAt_idx" + ON "webhook_deliveries"("subscriptionId", "status", "createdAt"); +CREATE INDEX CONCURRENTLY IF NOT EXISTS "webhook_dead_letters_subscriptionId_status_firstFailedAt_idx" + ON "webhook_dead_letters"("subscriptionId", "status", "firstFailedAt"); +SQL + +# 3. Record the migration as applied, then continue with the rest +npx prisma migrate resolve --applied 20260928180000_add_webhook_query_indexes +npx prisma migrate deploy +``` + +An interrupted `CONCURRENTLY` build leaves an `INVALID` index behind; `IF NOT +EXISTS` would then skip it. Check and drop before re-running: + +```sql +SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; +-- DROP INDEX CONCURRENTLY ""; +``` + +**Recipe: CI / throwaway databases:** remove `CONCURRENTLY` from the +migration file (blocking builds are fine where there is no traffic), then +`--rolled-back` and `migrate deploy`. Never do this to a migration that has +already been applied to a shared environment; add a new migration instead. + +**Verify** + +```sql +SELECT tablename, indexname FROM pg_indexes +WHERE tablename IN ('user_webhook_deliveries','webhook_deliveries','webhook_dead_letters') + AND indexname LIKE '%_idx'; +``` + +**Prevention:** do not put `CONCURRENTLY` in `migration.sql`. Ship the +blocking form in the migration and a documented manual step for production, or +classify the change per §2 and schedule the lock. + +### A.2 Duplicate column on `ADD COLUMN` + +**Incident:** `20260928172500_add_referral_fraud_detection` + +**Symptom:** the migration fails with `column "reviewedBy" of relation +"referral_conversions" already exists` (likewise `reviewedAt`). + +**Cause:** an earlier migration (`20260830163707_add_referral_fraud_review`) +already added those columns; the new migration added them again. + +**Recipe** + +```bash +# 1. Fix the SQL. Drop the duplicate columns, and make the genuinely new ones +# idempotent: +# ALTER TABLE "referral_conversions" +# ADD COLUMN IF NOT EXISTS "fraudCheckScore" INTEGER; +# ALTER TABLE "referral_conversions" +# ADD COLUMN IF NOT EXISTS "fraudCheckFlags" TEXT[] DEFAULT ARRAY[]::TEXT[]; +# Keep rollback.sql in step with it. + +# 2. Clear the failed row, then retry with the corrected file +npx prisma migrate resolve --rolled-back 20260928172500_add_referral_fraud_detection +npx prisma migrate deploy +``` + +Editing a migration file is only valid for one that has **not** succeeded +anywhere shared. If any environment recorded it as applied, leave it alone and +write a corrective migration. + +**Verify** + +```bash +psql "$DATABASE_URL" -c '\d referral_conversions' +``` + +**Prevention:** before adding a column, grep the migration history for it +(`grep -rn '"reviewedBy"' prisma/migrations`). Write `ADD COLUMN IF NOT EXISTS` +and `CREATE INDEX IF NOT EXISTS` by default. + +### A.3 Failed rehearsal database + +**Symptom:** `scripts/rehearse-migration-rollback.sh` fails on a migration, +you fix the SQL, and the next rehearsal still fails (or Prisma reports `P3009`) +because the rehearsal database remembers the earlier failed attempt. + +**Recipe** + +```bash +# Mark the failed migration rolled back so Prisma retries the fixed SQL +DATABASE_URL="$REHEARSAL_DATABASE_URL" \ + bash scripts/fix-failed-rehearsal.sh + +# Re-run the rehearsal +DATABASE_URL="$REHEARSAL_DATABASE_URL" \ + bash scripts/rehearse-migration-rollback.sh +``` + +`fix-failed-rehearsal.sh` is just `prisma migrate resolve --rolled-back` +with argument checking. If the failed migration applied part of its changes +before erroring, the retry can trip over them; in that case discard the +rehearsal database instead: + +```bash +psql -h -U postgres -c 'DROP DATABASE IF EXISTS rehearsal_db;' \ + -c 'CREATE DATABASE rehearsal_db;' +``` + +**Only ever point this at a rehearsal or throwaway database.** On a shared +environment, resolve the failure per §6 (rolling back) instead. + +**Prevention:** run the rehearsal (§5) before pushing, and write idempotent SQL +(`IF [NOT] EXISTS`, `DO` blocks instead of `ADD CONSTRAINT IF NOT EXISTS` for +PostgreSQL < 12). diff --git a/docs/OPENAPI_DRIFT_REPORT.md b/docs/OPENAPI_DRIFT_REPORT.md new file mode 100644 index 0000000..88c3b0c --- /dev/null +++ b/docs/OPENAPI_DRIFT_REPORT.md @@ -0,0 +1,277 @@ +# OpenAPI drift report + +Initial report for #601, generated by `npm run check:openapi-drift -- --report`. The check (`scripts/check-openapi-drift.js`) compares routes mounted under `/api/v1` in `src/index.ts` with `docs/openapi.yaml`, and runs in the `API contract` CI job. + +Known drift is frozen in [openapi-drift-baseline.json](./openapi-drift-baseline.json). CI fails on any **new** drift, and on baseline entries that have since been fixed. Backfill a route, then run `node scripts/check-openapi-drift.js --update` to shrink the baseline. + +Backfilled so far: the `/admin/erasure/requests*` and `/admin/erasure/preview` routes. + +## Mounted routes with no spec entry (156) + +### admin (48) + +- `DELETE /api/v1/admin/keys/{id}` +- `DELETE /api/v1/admin/messages/{id}` +- `GET /api/v1/admin/agent/breakers` +- `GET /api/v1/admin/audit/prove` +- `GET /api/v1/admin/audit/verify` +- `GET /api/v1/admin/dlq/inspect` +- `GET /api/v1/admin/fiat/providers` +- `GET /api/v1/admin/jobs/dashboard` +- `GET /api/v1/admin/keys` +- `GET /api/v1/admin/messages` +- `GET /api/v1/admin/messages/stats` +- `GET /api/v1/admin/messages/{id}` +- `GET /api/v1/admin/notifications/dlq` +- `GET /api/v1/admin/outbox` +- `GET /api/v1/admin/outbox/stats` +- `GET /api/v1/admin/outbox/{id}` +- `GET /api/v1/admin/referrals/flagged` +- `GET /api/v1/admin/referrals/review` +- `GET /api/v1/admin/reserves` +- `GET /api/v1/admin/stellar/metrics` +- `GET /api/v1/admin/treasury/policies` +- `GET /api/v1/admin/user-agent` +- `GET /api/v1/admin/users/{id}/sessions` +- `GET /api/v1/admin/wallets/rotation-status` +- `POST /api/v1/admin/agent/breakers` +- `POST /api/v1/admin/agent/breakers/{id}/reset` +- `POST /api/v1/admin/approvals/{id}/cancel` +- `POST /api/v1/admin/dlq/replay` +- `POST /api/v1/admin/dlq/resolve` +- `POST /api/v1/admin/dlq/retry` +- `POST /api/v1/admin/erasure` +- `POST /api/v1/admin/fiat/providers/{name}/failover` +- `POST /api/v1/admin/keys` +- `POST /api/v1/admin/keys/{id}/rotate` +- `POST /api/v1/admin/messages/retry-dead-letters` +- `POST /api/v1/admin/messages/{id}/retry` +- `POST /api/v1/admin/notifications/dlq/{id}/retry` +- `POST /api/v1/admin/outbox/{id}/cancel` +- `POST /api/v1/admin/outbox/{id}/retry` +- `POST /api/v1/admin/referrals/review/{id}/approve` +- `POST /api/v1/admin/referrals/review/{id}/reject` +- `POST /api/v1/admin/referrals/{id}/review` +- `POST /api/v1/admin/stellar/backfill` +- `POST /api/v1/admin/treasury/emergency-sweep` +- `POST /api/v1/admin/treasury/policies` +- `POST /api/v1/admin/treasury/signer-rotations` +- `POST /api/v1/admin/treasury/signer-rotations/{id}/finalize` +- `POST /api/v1/admin/users/{id}/sessions/revoke-all` + +### agent (2) + +- `GET /api/v1/agent/status` +- `GET /api/v1/agent/status/breakers` + +### alerts (5) + +- `GET /api/v1/alerts/{id}/fires` +- `GET /api/v1/alerts/{userId}` +- `POST /api/v1/alerts/{id}/ack` +- `POST /api/v1/alerts/{id}/restore` +- `POST /api/v1/alerts/{id}/snooze` + +### analytics (3) + +- `GET /api/v1/analytics/attribution` +- `GET /api/v1/analytics/attribution/export` +- `GET /api/v1/analytics/risk/strategy/{publishedStrategyId}` + +### approval-policies (4) + +- `GET /api/v1/approval-policies` +- `GET /api/v1/approval-policies/{id}` +- `POST /api/v1/approval-policies` +- `PUT /api/v1/approval-policies/{id}` + +### approvals (5) + +- `GET /api/v1/approvals` +- `GET /api/v1/approvals/{id}` +- `POST /api/v1/approvals/{id}/approve` +- `POST /api/v1/approvals/{id}/cancel` +- `POST /api/v1/approvals/{id}/reject` + +### auth (6) + +- `DELETE /api/v1/auth/webauthn/credentials/{id}` +- `GET /api/v1/auth/webauthn/credentials` +- `POST /api/v1/auth/challenge` +- `POST /api/v1/auth/logout` +- `POST /api/v1/auth/refresh` +- `POST /api/v1/auth/verify` + +### deposit (7) + +- `DELETE /api/v1/deposit/recurring/{id}` +- `GET /api/v1/deposit/recurring/by-user/{userId}` +- `GET /api/v1/deposit/recurring/preview` +- `GET /api/v1/deposit/recurring/{id}/runs` +- `PATCH /api/v1/deposit/recurring/{id}` +- `POST /api/v1/deposit` +- `POST /api/v1/deposit/recurring` + +### fiat (6) + +- `GET /api/v1/fiat/orders` +- `GET /api/v1/fiat/orders/{id}` +- `GET /api/v1/fiat/quotes` +- `POST /api/v1/fiat/orders` +- `POST /api/v1/fiat/quote` +- `POST /api/v1/fiat/webhook/{provider}` + +### keys (5) + +- `DELETE /api/v1/keys/{id}` +- `GET /api/v1/keys` +- `GET /api/v1/keys/{id}/usage` +- `POST /api/v1/keys` +- `POST /api/v1/keys/{id}/rotate` + +### liquidity-floor (2) + +- `GET /api/v1/liquidity-floor` +- `PATCH /api/v1/liquidity-floor` + +### loans (5) + +- `GET /api/v1/loans` +- `GET /api/v1/loans/capacity` +- `GET /api/v1/loans/{loanId}` +- `POST /api/v1/loans` +- `POST /api/v1/loans/{loanId}/repay` + +### notifications (2) + +- `GET /api/v1/notifications/email/verify` +- `POST /api/v1/notifications/email` + +### portfolio (9) + +- `DELETE /api/v1/portfolio/goals/{id}` +- `GET /api/v1/portfolio/goals/{userId}` +- `GET /api/v1/portfolio/{userId}` +- `GET /api/v1/portfolio/{userId}/earnings` +- `GET /api/v1/portfolio/{userId}/history` +- `GET /api/v1/portfolio/{userId}/suggestions` +- `GET /api/v1/portfolio/{userId}/tax-report` +- `POST /api/v1/portfolio/goals/{id}/simulate` +- `POST /api/v1/portfolio/{userId}/suggest-allocation` + +### protection-fund (6) + +- `GET /api/v1/protection-fund/my-coverage` +- `GET /api/v1/protection-fund/status` +- `POST /api/v1/protection-fund/claims/{claimId}/payout` +- `POST /api/v1/protection-fund/contributions` +- `POST /api/v1/protection-fund/events` +- `POST /api/v1/protection-fund/events/{eventId}/review` + +### protocols (3) + +- `GET /api/v1/protocols/agent/status` +- `GET /api/v1/protocols/rates` +- `GET /api/v1/protocols/risk` + +### recurring-withdrawals (1) + +- `POST /api/v1/recurring-withdrawals/preview` + +### referrals (3) + +- `GET /api/v1/referrals/code` +- `GET /api/v1/referrals/leaderboard` +- `GET /api/v1/referrals/{userId}` + +### sessions (5) + +- `DELETE /api/v1/sessions/{id}` +- `GET /api/v1/sessions` +- `PATCH /api/v1/sessions/{id}` +- `POST /api/v1/sessions/2fa/disable` +- `POST /api/v1/sessions/revoke-others` + +### settings (1) + +- `PUT /api/v1/settings` + +### stellar (1) + +- `GET /api/v1/stellar/metrics` + +### strategies (6) + +- `GET /api/v1/strategies/following` +- `GET /api/v1/strategies/marketplace` +- `POST /api/v1/strategies/publish` +- `POST /api/v1/strategies/unpublish` +- `POST /api/v1/strategies/{id}/follow` +- `POST /api/v1/strategies/{id}/unfollow` + +### stream (2) + +- `GET /api/v1/stream/sse` +- `POST /api/v1/stream/ticket` + +### sub-accounts (3) + +- `DELETE /api/v1/sub-accounts/{id}` +- `PATCH /api/v1/sub-accounts/{id}/permissions` +- `POST /api/v1/sub-accounts` + +### telegram (2) + +- `GET /api/v1/telegram/health` +- `POST /api/v1/telegram` + +### transactions (3) + +- `GET /api/v1/transactions/detail/{txHash}` +- `GET /api/v1/transactions/{id}/events` +- `GET /api/v1/transactions/{userId}` + +### vault (3) + +- `GET /api/v1/vault/balance` +- `GET /api/v1/vault/state` +- `POST /api/v1/vault/build-transaction` + +### webauthn (4) + +- `POST /api/v1/webauthn/login-options` +- `POST /api/v1/webauthn/login-verify` +- `POST /api/v1/webauthn/register-options` +- `POST /api/v1/webauthn/register-verify` + +### whatsapp (3) + +- `GET /api/v1/whatsapp/host` +- `GET /api/v1/whatsapp/webhook` +- `POST /api/v1/whatsapp/webhook` + +### withdraw (1) + +- `POST /api/v1/withdraw` + +## Spec entries with no mounted route (17) + +These are documented under `/api/v1` but are not mounted there (webhooks, health, 2FA, ws are mounted elsewhere or under other paths). Each needs either a spec correction or a mount. + +- `DELETE /api/v1/webhooks/endpoints/{id}` +- `GET /api/v1/alerts/{id}` +- `GET /api/v1/health/live` +- `GET /api/v1/health/ready` +- `GET /api/v1/webhooks/endpoints` +- `GET /api/v1/webhooks/endpoints/{id}` +- `GET /api/v1/webhooks/endpoints/{id}/deliveries` +- `GET /api/v1/ws` +- `PATCH /api/v1/webhooks/endpoints/{id}` +- `POST /api/v1/2fa/disable` +- `POST /api/v1/2fa/enroll` +- `POST /api/v1/2fa/recovery-codes/regenerate` +- `POST /api/v1/2fa/verify-enrollment` +- `POST /api/v1/auth/2fa/verify` +- `POST /api/v1/webhooks/endpoints` +- `POST /api/v1/webhooks/endpoints/{id}/rotate-secret` +- `POST /api/v1/webhooks/endpoints/{id}/test` diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 66b6010..42ee878 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -340,6 +340,10 @@ in `prisma/migrations//` therefore ships a hand-written `rollback.sql` alongside its `migration.sql`. CI enforces this via `scripts/check-migration-rollback.sh`. +For a migration that failed mid-apply (`P3009`, `CONCURRENTLY` in a +transaction, duplicate column, stuck rehearsal), follow +[MIGRATIONS.md Appendix A: Common failure modes](./MIGRATIONS.md#appendix-a-common-failure-modes). + ### When to roll back Roll back when a freshly deployed migration is itself the problem (broken schema, diff --git a/docs/openapi-drift-baseline.json b/docs/openapi-drift-baseline.json new file mode 100644 index 0000000..ac7825b --- /dev/null +++ b/docs/openapi-drift-baseline.json @@ -0,0 +1,179 @@ +{ + "unbacked": [ + "DELETE /api/v1/admin/keys/{id}", + "DELETE /api/v1/admin/messages/{id}", + "DELETE /api/v1/auth/webauthn/credentials/{id}", + "DELETE /api/v1/deposit/recurring/{id}", + "DELETE /api/v1/keys/{id}", + "DELETE /api/v1/portfolio/goals/{id}", + "DELETE /api/v1/sessions/{id}", + "DELETE /api/v1/sub-accounts/{id}", + "GET /api/v1/admin/agent/breakers", + "GET /api/v1/admin/audit/prove", + "GET /api/v1/admin/audit/verify", + "GET /api/v1/admin/dlq/inspect", + "GET /api/v1/admin/fiat/providers", + "GET /api/v1/admin/jobs/dashboard", + "GET /api/v1/admin/keys", + "GET /api/v1/admin/messages", + "GET /api/v1/admin/messages/stats", + "GET /api/v1/admin/messages/{id}", + "GET /api/v1/admin/notifications/dlq", + "GET /api/v1/admin/outbox", + "GET /api/v1/admin/outbox/stats", + "GET /api/v1/admin/outbox/{id}", + "GET /api/v1/admin/referrals/flagged", + "GET /api/v1/admin/referrals/review", + "GET /api/v1/admin/reserves", + "GET /api/v1/admin/stellar/metrics", + "GET /api/v1/admin/treasury/policies", + "GET /api/v1/admin/user-agent", + "GET /api/v1/admin/users/{id}/sessions", + "GET /api/v1/admin/wallets/rotation-status", + "GET /api/v1/agent/status", + "GET /api/v1/agent/status/breakers", + "GET /api/v1/alerts/{id}/fires", + "GET /api/v1/alerts/{userId}", + "GET /api/v1/analytics/attribution", + "GET /api/v1/analytics/attribution/export", + "GET /api/v1/analytics/risk/strategy/{publishedStrategyId}", + "GET /api/v1/approval-policies", + "GET /api/v1/approval-policies/{id}", + "GET /api/v1/approvals", + "GET /api/v1/approvals/{id}", + "GET /api/v1/auth/webauthn/credentials", + "GET /api/v1/deposit/recurring/by-user/{userId}", + "GET /api/v1/deposit/recurring/preview", + "GET /api/v1/deposit/recurring/{id}/runs", + "GET /api/v1/fiat/orders", + "GET /api/v1/fiat/orders/{id}", + "GET /api/v1/fiat/quotes", + "GET /api/v1/keys", + "GET /api/v1/keys/{id}/usage", + "GET /api/v1/liquidity-floor", + "GET /api/v1/loans", + "GET /api/v1/loans/capacity", + "GET /api/v1/loans/{loanId}", + "GET /api/v1/notifications/email/verify", + "GET /api/v1/portfolio/goals/{userId}", + "GET /api/v1/portfolio/{userId}", + "GET /api/v1/portfolio/{userId}/earnings", + "GET /api/v1/portfolio/{userId}/history", + "GET /api/v1/portfolio/{userId}/suggestions", + "GET /api/v1/portfolio/{userId}/tax-report", + "GET /api/v1/protection-fund/my-coverage", + "GET /api/v1/protection-fund/status", + "GET /api/v1/protocols/agent/status", + "GET /api/v1/protocols/rates", + "GET /api/v1/protocols/risk", + "GET /api/v1/referrals/code", + "GET /api/v1/referrals/leaderboard", + "GET /api/v1/referrals/{userId}", + "GET /api/v1/sessions", + "GET /api/v1/stellar/metrics", + "GET /api/v1/strategies/following", + "GET /api/v1/strategies/marketplace", + "GET /api/v1/stream/sse", + "GET /api/v1/telegram/health", + "GET /api/v1/transactions/detail/{txHash}", + "GET /api/v1/transactions/{id}/events", + "GET /api/v1/transactions/{userId}", + "GET /api/v1/vault/balance", + "GET /api/v1/vault/state", + "GET /api/v1/whatsapp/host", + "GET /api/v1/whatsapp/webhook", + "PATCH /api/v1/deposit/recurring/{id}", + "PATCH /api/v1/liquidity-floor", + "PATCH /api/v1/sessions/{id}", + "PATCH /api/v1/sub-accounts/{id}/permissions", + "POST /api/v1/admin/agent/breakers", + "POST /api/v1/admin/agent/breakers/{id}/reset", + "POST /api/v1/admin/approvals/{id}/cancel", + "POST /api/v1/admin/dlq/replay", + "POST /api/v1/admin/dlq/resolve", + "POST /api/v1/admin/dlq/retry", + "POST /api/v1/admin/erasure", + "POST /api/v1/admin/fiat/providers/{name}/failover", + "POST /api/v1/admin/keys", + "POST /api/v1/admin/keys/{id}/rotate", + "POST /api/v1/admin/messages/retry-dead-letters", + "POST /api/v1/admin/messages/{id}/retry", + "POST /api/v1/admin/notifications/dlq/{id}/retry", + "POST /api/v1/admin/outbox/{id}/cancel", + "POST /api/v1/admin/outbox/{id}/retry", + "POST /api/v1/admin/referrals/review/{id}/approve", + "POST /api/v1/admin/referrals/review/{id}/reject", + "POST /api/v1/admin/referrals/{id}/review", + "POST /api/v1/admin/stellar/backfill", + "POST /api/v1/admin/treasury/emergency-sweep", + "POST /api/v1/admin/treasury/policies", + "POST /api/v1/admin/treasury/signer-rotations", + "POST /api/v1/admin/treasury/signer-rotations/{id}/finalize", + "POST /api/v1/admin/users/{id}/sessions/revoke-all", + "POST /api/v1/alerts/{id}/ack", + "POST /api/v1/alerts/{id}/restore", + "POST /api/v1/alerts/{id}/snooze", + "POST /api/v1/approval-policies", + "POST /api/v1/approvals/{id}/approve", + "POST /api/v1/approvals/{id}/cancel", + "POST /api/v1/approvals/{id}/reject", + "POST /api/v1/auth/challenge", + "POST /api/v1/auth/logout", + "POST /api/v1/auth/refresh", + "POST /api/v1/auth/verify", + "POST /api/v1/deposit", + "POST /api/v1/deposit/recurring", + "POST /api/v1/fiat/orders", + "POST /api/v1/fiat/quote", + "POST /api/v1/fiat/webhook/{provider}", + "POST /api/v1/keys", + "POST /api/v1/keys/{id}/rotate", + "POST /api/v1/loans", + "POST /api/v1/loans/{loanId}/repay", + "POST /api/v1/notifications/email", + "POST /api/v1/portfolio/goals/{id}/simulate", + "POST /api/v1/portfolio/{userId}/suggest-allocation", + "POST /api/v1/protection-fund/claims/{claimId}/payout", + "POST /api/v1/protection-fund/contributions", + "POST /api/v1/protection-fund/events", + "POST /api/v1/protection-fund/events/{eventId}/review", + "POST /api/v1/recurring-withdrawals/preview", + "POST /api/v1/sessions/2fa/disable", + "POST /api/v1/sessions/revoke-others", + "POST /api/v1/strategies/publish", + "POST /api/v1/strategies/unpublish", + "POST /api/v1/strategies/{id}/follow", + "POST /api/v1/strategies/{id}/unfollow", + "POST /api/v1/stream/ticket", + "POST /api/v1/sub-accounts", + "POST /api/v1/telegram", + "POST /api/v1/vault/build-transaction", + "POST /api/v1/webauthn/login-options", + "POST /api/v1/webauthn/login-verify", + "POST /api/v1/webauthn/register-options", + "POST /api/v1/webauthn/register-verify", + "POST /api/v1/whatsapp/webhook", + "POST /api/v1/withdraw", + "PUT /api/v1/approval-policies/{id}", + "PUT /api/v1/settings" + ], + "stale": [ + "DELETE /api/v1/webhooks/endpoints/{id}", + "GET /api/v1/alerts/{id}", + "GET /api/v1/health/live", + "GET /api/v1/health/ready", + "GET /api/v1/webhooks/endpoints", + "GET /api/v1/webhooks/endpoints/{id}", + "GET /api/v1/webhooks/endpoints/{id}/deliveries", + "GET /api/v1/ws", + "PATCH /api/v1/webhooks/endpoints/{id}", + "POST /api/v1/2fa/disable", + "POST /api/v1/2fa/enroll", + "POST /api/v1/2fa/recovery-codes/regenerate", + "POST /api/v1/2fa/verify-enrollment", + "POST /api/v1/auth/2fa/verify", + "POST /api/v1/webhooks/endpoints", + "POST /api/v1/webhooks/endpoints/{id}/rotate-secret", + "POST /api/v1/webhooks/endpoints/{id}/test" + ] +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 93d011a..ceb3606 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4157,3 +4157,184 @@ paths: description: All services ready. '503': description: One or more services not ready. + /admin/erasure/requests: + get: + tags: + - Admin + operationId: adminListErasureRequests + summary: List privacy erasure requests + security: + - adminAuth: [] + parameters: + - name: status + in: query + schema: + type: string + enum: [PENDING, APPROVED, REJECTED, COMPLETED, FAILED] + - name: limit + in: query + schema: + type: integer + - name: offset + in: query + schema: + type: integer + responses: + '200': + description: Successful operation + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '429': + description: Erasure rate limit exceeded + post: + tags: + - Admin + operationId: adminCreateErasureRequest + summary: Create a privacy erasure request (does not delete anything) + security: + - adminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [userId, reason] + properties: + userId: + type: string + reason: + type: string + responses: + '201': + description: Request created in PENDING status + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '429': + description: Erasure rate limit exceeded + /admin/erasure/requests/{id}/approve: + post: + tags: + - Admin + operationId: adminApproveErasureRequest + summary: Approve an erasure request; the approver must differ from the requester + security: + - adminAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Request approved + '401': + description: Authentication required or verification failed + '403': + description: Forbidden, or self-approval attempted + '429': + description: Erasure rate limit exceeded + /admin/erasure/requests/{id}/reject: + post: + tags: + - Admin + operationId: adminRejectErasureRequest + summary: Reject a pending erasure request + security: + - adminAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [rejectionReason] + properties: + rejectionReason: + type: string + responses: + '200': + description: Request rejected + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '429': + description: Erasure rate limit exceeded + /admin/erasure/requests/{id}/execute: + post: + tags: + - Admin + operationId: adminExecuteErasureRequest + summary: Dry-run or execute an approved erasure (IRREVERSIBLE) + description: | + Without `confirm` this is a dry run that returns a preview and the + required phrase. With `confirm` equal to `DELETE ` the erasure + runs: the user's sessions are revoked first, then data is deleted. The + executing admin must differ from the approving admin. + security: + - adminAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + confirm: + type: string + example: DELETE 3f1c9a52-0000-0000-0000-000000000000 + responses: + '200': + description: Dry-run preview (`dryRun` true) or erasure summary (`dryRun` false) + '401': + description: Authentication required or verification failed + '403': + description: Forbidden, or the approver attempted to execute + '422': + description: Typed confirmation missing or wrong + '429': + description: Erasure rate limit exceeded + /admin/erasure/preview/{userId}: + get: + tags: + - Admin + operationId: adminPreviewErasure + summary: Preview the records an erasure would delete or anonymize + security: + - adminAuth: [] + parameters: + - name: userId + in: path + required: true + schema: + type: string + responses: + '200': + description: Successful operation + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + '429': + description: Erasure rate limit exceeded diff --git a/package.json b/package.json index 9174f68..2d1019c 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "audit:deps": "npm audit --audit-level=moderate", "outdated:deps": "npm outdated || true", "validate:spec": "npx --yes @redocly/cli@latest lint docs/openapi.yaml --format=stylish", + "check:openapi-drift": "node scripts/check-openapi-drift.js", "build:spec": "mkdir -p dist/docs && cp docs/openapi.yaml dist/docs/openapi.yaml", "prebuild": "npm run clean && npm run build:spec", "prestart": "npm run build", diff --git a/scripts/check-openapi-drift.js b/scripts/check-openapi-drift.js new file mode 100644 index 0000000..9e48136 --- /dev/null +++ b/scripts/check-openapi-drift.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node +/** + * OpenAPI drift check (#601). + * + * Builds a route inventory from the Express routers mounted under /api/v1 in + * src/index.ts and diffs it against the paths in docs/openapi.yaml. + * + * - "unbacked" : mounted route with no spec entry + * - "stale" : spec entry with no mounted route + * + * Known drift is recorded in docs/openapi-drift-baseline.json. The check fails + * on any drift NOT in the baseline, and on baseline entries that no longer + * drift (so the baseline only ever shrinks). + * + * Usage: + * node scripts/check-openapi-drift.js # check (CI) + * node scripts/check-openapi-drift.js --update # rewrite the baseline + * node scripts/check-openapi-drift.js --report # print full drift lists + */ + +const fs = require('fs') +const path = require('path') + +const ROOT = path.resolve(__dirname, '..') +const SRC = path.join(ROOT, 'src') +const SPEC = path.join(ROOT, 'docs', 'openapi.yaml') +const BASELINE = path.join(ROOT, 'docs', 'openapi-drift-baseline.json') +const METHODS = ['get', 'post', 'put', 'patch', 'delete'] + +function read(file) { + return fs.readFileSync(file, 'utf8') +} + +function resolveModule(fromFile, spec) { + const base = path.resolve(path.dirname(fromFile), spec) + for (const candidate of [base + '.ts', path.join(base, 'index.ts')]) { + if (fs.existsSync(candidate)) return candidate + } + return null +} + +function importMap(file, source) { + const map = {} + const re = /import\s+(\w+)\s+from\s+['"](\.[^'"]+)['"]/g + let m + while ((m = re.exec(source))) { + const resolved = resolveModule(file, m[2]) + if (resolved) map[m[1]] = resolved + } + return map +} + +function join(prefix, p) { + const joined = `${prefix}/${p}`.replace(/\/+/g, '/') + return joined.length > 1 ? joined.replace(/\/$/, '') : joined +} + +function normalize(p) { + return p.replace(/:(\w+)/g, '{$1}') +} + +function routesOf(file, prefix, seen = new Set()) { + if (seen.has(file)) return [] + seen.add(file) + const source = read(file) + const imports = importMap(file, source) + const out = [] + + const verb = new RegExp( + `\\b\\w+\\.(${METHODS.join('|')})\\(\\s*['"\`]([^'"\`]+)['"\`]`, + 'g' + ) + let m + while ((m = verb.exec(source))) { + out.push({ method: m[1], route: join(prefix, m[2]) }) + } + + const nested = /\b\w+\.use\(\s*['"`]([^'"`]+)['"`][^)]*?(\w+)\s*\)/g + while ((m = nested.exec(source))) { + if (imports[m[2]]) { + out.push(...routesOf(imports[m[2]], join(prefix, m[1]), seen)) + } + } + return out +} + +function mountedRoutes() { + const indexFile = path.join(SRC, 'index.ts') + const source = read(indexFile) + const imports = importMap(indexFile, source) + const all = new Set() + const re = /path:\s*'([^']+)',\s*handlers:\s*\[([^\]]*)\]/g + let m + while ((m = re.exec(source))) { + const ids = m[2].match(/\w+/g) || [] + const router = ids[ids.length - 1] + if (!imports[router]) continue + for (const r of routesOf(imports[router], `/api/v1/${m[1]}`)) { + all.add(`${r.method.toUpperCase()} ${normalize(r.route)}`) + } + } + return all +} + +function specRoutes() { + const all = new Set() + let inPaths = false + let current = null + for (const line of read(SPEC).split(/\r?\n/)) { + if (/^paths:\s*$/.test(line)) { + inPaths = true + continue + } + if (inPaths && /^\S/.test(line)) break + if (!inPaths) continue + const p = line.match(/^ {2}(\/\S*?):\s*$/) + if (p) { + current = p[1] + continue + } + const v = line.match(/^ {4}(get|post|put|patch|delete):\s*$/) + if (v && current) { + all.add(`${v[1].toUpperCase()} ${join('/api/v1', current)}`) + } + } + return all +} + +function diff(a, b) { + return [...a].filter((x) => !b.has(x)).sort() +} + +function list(title, items) { + if (!items.length) return + console.error(`\n${title} (${items.length}):`) + for (const i of items) console.error(` ${i}`) +} + +const mounted = mountedRoutes() +const spec = specRoutes() +const unbacked = diff(mounted, spec) +const stale = diff(spec, mounted) + +if (process.argv.includes('--update')) { + fs.writeFileSync( + BASELINE, + JSON.stringify({ unbacked, stale }, null, 2) + '\n' + ) + console.log( + `Baseline written: ${unbacked.length} unbacked, ${stale.length} stale` + ) + process.exit(0) +} + +if (process.argv.includes('--report')) { + list('Mounted routes with no OpenAPI entry', unbacked) + list('OpenAPI entries with no mounted route', stale) + process.exit(0) +} + +const baseline = fs.existsSync(BASELINE) + ? JSON.parse(read(BASELINE)) + : { unbacked: [], stale: [] } + +const newUnbacked = diff(new Set(unbacked), new Set(baseline.unbacked)) +const newStale = diff(new Set(stale), new Set(baseline.stale)) +const fixedUnbacked = diff(new Set(baseline.unbacked), new Set(unbacked)) +const fixedStale = diff(new Set(baseline.stale), new Set(stale)) + +list('Mounted routes missing from docs/openapi.yaml', newUnbacked) +list('docs/openapi.yaml entries with no mounted route', newStale) +list( + 'Baseline entries now resolved - run `node scripts/check-openapi-drift.js --update`', + [...fixedUnbacked, ...fixedStale] +) + +const failed = + newUnbacked.length + + newStale.length + + fixedUnbacked.length + + fixedStale.length +if (failed) { + console.error( + '\nOpenAPI drift detected. Document the route in docs/openapi.yaml (preferred).' + ) + process.exit(1) +} + +console.log( + `OpenAPI drift check passed (${mounted.size} routes, ${spec.size} spec operations, ` + + `${baseline.unbacked.length + baseline.stale.length} baselined)` +) diff --git a/src/compliance/privacyErasure.ts b/src/compliance/privacyErasure.ts index da27687..9238af7 100644 --- a/src/compliance/privacyErasure.ts +++ b/src/compliance/privacyErasure.ts @@ -18,6 +18,21 @@ import { Prisma } from '@prisma/client' import db from '../db' import { logger } from '../utils/logger' import { alertingService } from '../services/alerting' +import { revokeAllUserSessions } from '../services/refresh-token.service' + +export class ErasureGuardError extends Error { + constructor( + message: string, + public readonly httpStatus: 403 | 409 | 422 + ) { + super(message) + this.name = 'ErasureGuardError' + } +} + +export function erasureConfirmationPhrase(userId: string): string { + return `DELETE ${userId}` +} export enum ErasureRequestStatus { PENDING = 'PENDING', @@ -150,6 +165,13 @@ export async function approveErasureRequest( ) } + if (request.requestedBy === approvedBy) { + throw new ErasureGuardError( + 'Self-approval is not allowed: the approver must be a different admin than the requester.', + 403 + ) + } + const updated = await db.erasureRequest.update({ where: { id: requestId }, data: { @@ -213,7 +235,9 @@ export async function rejectErasureRequest( * This is irreversible. Only executes if request status is APPROVED. */ export async function executeErasure( - requestId: string + requestId: string, + executedBy: string, + confirmation: string | undefined ): Promise { const request = await db.erasureRequest.findUnique({ where: { id: requestId }, @@ -237,7 +261,25 @@ export async function executeErasure( throw new Error(`User ${userId} not found`) } + if (request.approvedBy === executedBy) { + throw new ErasureGuardError( + 'Dual control: the admin who approved this request cannot also execute it.', + 403 + ) + } + + if (confirmation !== erasureConfirmationPhrase(userId)) { + throw new ErasureGuardError( + `Typed confirmation mismatch. Send confirm: "${erasureConfirmationPhrase(userId)}".`, + 422 + ) + } + try { + // Cut off live access first so no session can act on or re-create data + // while the deletion transaction runs. + await revokeAllUserSessions(userId, 'admin') + const summary: ErasureSummary = await db.$transaction( async (tx) => { // 1. Count records before deletion (for summary) @@ -458,6 +500,38 @@ export async function listErasureRequests(params: { return { requests: requests as any, total } } +/** + * Dry run of an approved erasure request: nothing is deleted or revoked. + * This is what the execute endpoint returns unless a typed confirmation is sent. + */ +export async function previewErasureRequest(requestId: string): Promise<{ + requestId: string + userId: string + requiredConfirmation: string + preview: Omit +}> { + const request = await db.erasureRequest.findUnique({ + where: { id: requestId }, + }) + + if (!request) { + throw new Error(`Erasure request ${requestId} not found`) + } + + if (request.status !== ErasureRequestStatus.APPROVED) { + throw new Error( + `Cannot execute request in status ${request.status}. Must be APPROVED.` + ) + } + + return { + requestId, + userId: request.userId, + requiredConfirmation: erasureConfirmationPhrase(request.userId), + preview: await previewErasure(request.userId), + } +} + /** * Get a preview of what would be deleted for a user without executing. */ diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index fd71a23..20a0aa1 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -401,6 +401,18 @@ export const adminRateLimiter = buildRateLimiter({ message: 'Too many requests to the admin API. Please try again later.', }) +/** + * Privacy-erasure limiter — irreversible operations, kept far below the + * general admin budget. 5 req / 15 min per admin principal. + */ +export const erasureRateLimiter = buildRateLimiter({ + windowMs: 15 * 60 * 1000, + max: 5, + skip: isHealthProbe, + limiterType: 'admin', + message: 'Too many erasure operations. Please try again later.', +}) + /** * Webhook rate limiter — applied to unauthenticated inbound webhooks. * Defaults: 30 req / 1 min (env: WEBHOOK_RATE_LIMIT_MAX / WEBHOOK_RATE_LIMIT_WINDOW_MS). diff --git a/src/routes/admin.ts b/src/routes/admin.ts index db3c8c8..4b40b2a 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -2560,7 +2560,10 @@ import { executeErasure, listErasureRequests, previewErasure, + previewErasureRequest, + ErasureGuardError, } from '../compliance/privacyErasure' +import { erasureRateLimiter } from '../middleware/rateLimiter' /** * GET /api/admin/erasure/requests @@ -2605,6 +2608,7 @@ router.get( */ router.post( '/erasure/requests', + erasureRateLimiter, requireAdminScope('write'), async (req: Request, res: Response) => { try { @@ -2653,6 +2657,7 @@ router.post( */ router.post( '/erasure/requests/:id/approve', + erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { try { @@ -2677,11 +2682,15 @@ router.post( auditLog(req, res, 'APPROVE_ERASURE_REQUEST', 'failure', { error: error instanceof Error ? error.message : 'Unknown error', }) - res.status(500).json({ - success: false, - error: - error instanceof Error ? error.message : 'Failed to approve request', - }) + res + .status(error instanceof ErasureGuardError ? error.httpStatus : 500) + .json({ + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to approve request', + }) } } ) @@ -2695,6 +2704,7 @@ router.post( */ router.post( '/erasure/requests/:id/reject', + erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { try { @@ -2744,15 +2754,38 @@ router.post( * POST /api/admin/erasure/requests/:id/execute * Execute an approved erasure request. THIS IS IRREVERSIBLE. * Required scope: super + * + * Guardrails: + * - Without `confirm` in the body this is a dry run: it returns the preview and + * the exact phrase required, and changes nothing. + * - With `confirm: "DELETE "` the erasure runs, provided the caller is + * a different admin than the approver (dual control). + * - The user's sessions are revoked before any data is deleted. + * + * Body: { confirm?: string } */ router.post( '/erasure/requests/:id/execute', + erasureRateLimiter, requireAdminScope('super'), async (req: Request, res: Response) => { try { const { id } = req.params + const adminAuth = res.locals.adminAuth + const confirm = + typeof req.body?.confirm === 'string' ? req.body.confirm : undefined + + if (confirm === undefined) { + const dryRun = await previewErasureRequest(id) + return res.json({ + success: true, + dryRun: true, + data: dryRun, + timestamp: new Date().toISOString(), + }) + } - const summary = await executeErasure(id) + const summary = await executeErasure(id, adminAuth.name, confirm) auditLog(req, res, 'EXECUTE_ERASURE', 'success', { requestId: id, @@ -2761,6 +2794,7 @@ router.post( res.json({ success: true, + dryRun: false, data: summary, timestamp: new Date().toISOString(), }) @@ -2771,11 +2805,15 @@ router.post( auditLog(req, res, 'EXECUTE_ERASURE', 'failure', { error: error instanceof Error ? error.message : 'Unknown error', }) - res.status(500).json({ - success: false, - error: - error instanceof Error ? error.message : 'Failed to execute erasure', - }) + res + .status(error instanceof ErasureGuardError ? error.httpStatus : 500) + .json({ + success: false, + error: + error instanceof Error + ? error.message + : 'Failed to execute erasure', + }) } } ) @@ -2787,6 +2825,7 @@ router.post( */ router.get( '/erasure/preview/:userId', + erasureRateLimiter, requireAdminScope('read'), async (req: Request, res: Response) => { try { diff --git a/src/services/refresh-token.service.ts b/src/services/refresh-token.service.ts index 424c2ef..0cc2419 100644 --- a/src/services/refresh-token.service.ts +++ b/src/services/refresh-token.service.ts @@ -326,3 +326,23 @@ export async function revokeSession( ) } } + +/** + * Revoke every live session for a user. Used before irreversible account + * operations (privacy erasure) so no refresh token survives. + */ +export async function revokeAllUserSessions( + userId: string, + reason: RevocationReason +): Promise { + const sessions = await db.session.findMany({ + where: { userId, revokedAt: null }, + select: { id: true }, + }) + + for (const { id } of sessions) { + await revokeSession(id, reason, { userId }) + } + + return sessions.length +} diff --git a/tests/integration/erasure-rate-limit.integration.test.ts b/tests/integration/erasure-rate-limit.integration.test.ts new file mode 100644 index 0000000..c79adb0 --- /dev/null +++ b/tests/integration/erasure-rate-limit.integration.test.ts @@ -0,0 +1,17 @@ +import express from 'express' +import request from 'supertest' +import { erasureRateLimiter } from '../../src/middleware/rateLimiter' + +describe('erasureRateLimiter', () => { + it('rejects the sixth erasure call inside the window', async () => { + const app = express() + app.post('/erasure', erasureRateLimiter, (_req, res) => { + res.json({ ok: true }) + }) + + for (let i = 0; i < 5; i++) { + expect((await request(app).post('/erasure')).status).toBe(200) + } + expect((await request(app).post('/erasure')).status).toBe(429) + }) +}) diff --git a/tests/integration/privacy-erasure.integration.test.ts b/tests/integration/privacy-erasure.integration.test.ts new file mode 100644 index 0000000..4020a87 --- /dev/null +++ b/tests/integration/privacy-erasure.integration.test.ts @@ -0,0 +1,243 @@ +import express from 'express' +import request from 'supertest' + +const USER_ID = 'user-123' + +interface FakeRequest { + id: string + userId: string + requestedBy: string + reason: string + status: string + approvedBy?: string + approvedAt?: Date + completedAt?: Date +} + +const state: { requests: FakeRequest[]; events: string[] } = { + requests: [], + events: [], +} + +jest.mock('../../src/db', () => { + const count = () => jest.fn(async () => 0) + const tx = { + session: { count: count() }, + position: { count: count() }, + transaction: { count: count() }, + agentLog: { count: count() }, + webhookSubscription: { count: count() }, + fiatOrder: { count: count() }, + recurringDepositPlan: { count: count() }, + alertRule: { count: count() }, + savingsGoal: { count: count() }, + subAccount: { count: count() }, + publishedStrategy: { count: count() }, + strategyFollow: { count: count() }, + costBasisLot: { count: count(), updateMany: jest.fn() }, + lotDisposal: { count: count(), updateMany: jest.fn() }, + portfolioAttribution: { count: count(), updateMany: jest.fn() }, + adminAuditLog: { count: count() }, + user: { + delete: jest.fn(async () => { + state.events.push('user.delete') + return {} + }), + }, + } + const find = (id: string) => state.requests.find((r) => r.id === id) ?? null + return { + __esModule: true, + default: { + ...tx, + user: { + findUnique: jest.fn(async () => ({ + id: USER_ID, + walletAddress: 'GABC', + email: 'u@example.com', + })), + delete: tx.user.delete, + }, + adminAuditLog: { create: jest.fn(async () => ({})), count: count() }, + erasureRequest: { + findFirst: jest.fn( + async ({ where }: any) => + state.requests.find( + (r) => + r.userId === where.userId && where.status.in.includes(r.status) + ) ?? null + ), + create: jest.fn(async ({ data }: any) => { + const row = { id: `req-${state.requests.length + 1}`, ...data } + state.requests.push(row) + return row + }), + findUnique: jest.fn(async ({ where }: any) => { + const row = find(where.id) + return row + ? { ...row, user: { id: USER_ID, walletAddress: 'GABC' } } + : null + }), + update: jest.fn(async ({ where, data }: any) => { + const row = find(where.id)! + Object.assign(row, data) + return { ...row } + }), + }, + $transaction: jest.fn(async (cb: any) => cb(tx)), + }, + } +}) + +jest.mock('../../src/services/refresh-token.service', () => ({ + revokeSession: jest.fn(), + revokeAllUserSessions: jest.fn(async () => { + state.events.push('sessions.revoked') + return 1 + }), +})) + +jest.mock('../../src/middleware/rateLimiter', () => ({ + erasureRateLimiter: (_req: any, _res: any, next: any) => next(), +})) + +jest.mock('../../src/services/alerting', () => ({ + alertingService: { emit: jest.fn(async () => ({ sent: true })) }, +})) + +jest.mock('../../src/middleware/adminAuth', () => ({ + ...jest.requireActual('../../src/middleware/adminAuth'), + requireAdminAuth: (req: any, res: any, next: any) => { + res.locals.adminAuth = { + id: req.header('x-admin'), + name: req.header('x-admin'), + role: 'SUPER', + scopes: ['super', 'read', 'write'], + } + next() + }, + requireAdminScope: () => (_req: any, _res: any, next: any) => next(), +})) + +import adminRouter from '../../src/routes/admin' +import { revokeAllUserSessions } from '../../src/services/refresh-token.service' + +const app = express() +app.use(express.json()) +app.use('/admin', adminRouter) + +const as = (admin: string) => ({ 'x-admin': admin }) + +async function createApprovedRequest(): Promise { + const created = await request(app) + .post('/admin/erasure/requests') + .set(as('alice')) + .send({ userId: USER_ID, reason: 'GDPR Art. 17' }) + expect(created.status).toBe(201) + const id = created.body.data.id as string + + const approved = await request(app) + .post(`/admin/erasure/requests/${id}/approve`) + .set(as('bob')) + expect(approved.status).toBe(200) + return id +} + +describe('privacy erasure lifecycle guardrails', () => { + beforeEach(() => { + state.requests = [] + state.events = [] + jest.clearAllMocks() + }) + + it('rejects approval by the admin who requested the erasure', async () => { + const created = await request(app) + .post('/admin/erasure/requests') + .set(as('alice')) + .send({ userId: USER_ID, reason: 'GDPR Art. 17' }) + const id = created.body.data.id + + const res = await request(app) + .post(`/admin/erasure/requests/${id}/approve`) + .set(as('alice')) + + expect(res.status).toBe(403) + expect(res.body.error).toMatch(/Self-approval/) + expect(state.requests[0].status).toBe('PENDING') + }) + + it('rejects execution by the admin who approved it', async () => { + const id = await createApprovedRequest() + + const res = await request(app) + .post(`/admin/erasure/requests/${id}/execute`) + .set(as('bob')) + .send({ confirm: `DELETE ${USER_ID}` }) + + expect(res.status).toBe(403) + expect(res.body.error).toMatch(/Dual control/) + expect(state.requests[0].status).toBe('APPROVED') + expect(revokeAllUserSessions).not.toHaveBeenCalled() + }) + + it('returns a dry-run preview and changes nothing when no confirmation is sent', async () => { + const id = await createApprovedRequest() + + const res = await request(app) + .post(`/admin/erasure/requests/${id}/execute`) + .set(as('carol')) + .send({}) + + expect(res.status).toBe(200) + expect(res.body.dryRun).toBe(true) + expect(res.body.data.requiredConfirmation).toBe(`DELETE ${USER_ID}`) + expect(state.requests[0].status).toBe('APPROVED') + expect(state.events).toEqual([]) + expect(revokeAllUserSessions).not.toHaveBeenCalled() + }) + + it('rejects a wrong typed confirmation', async () => { + const id = await createApprovedRequest() + + const res = await request(app) + .post(`/admin/erasure/requests/${id}/execute`) + .set(as('carol')) + .send({ confirm: 'DELETE someone-else' }) + + expect(res.status).toBe(422) + expect(state.requests[0].status).toBe('APPROVED') + expect(state.events).toEqual([]) + }) + + it('runs the full lifecycle, revoking sessions before deleting the user', async () => { + const id = await createApprovedRequest() + + const res = await request(app) + .post(`/admin/erasure/requests/${id}/execute`) + .set(as('carol')) + .send({ confirm: `DELETE ${USER_ID}` }) + + expect(res.status).toBe(200) + expect(res.body.dryRun).toBe(false) + expect(res.body.data.userId).toBe(USER_ID) + expect(state.requests[0].status).toBe('COMPLETED') + expect(state.events).toEqual(['sessions.revoked', 'user.delete']) + expect(revokeAllUserSessions).toHaveBeenCalledWith(USER_ID, 'admin') + }) + + it('refuses to execute a request that was never approved', async () => { + const created = await request(app) + .post('/admin/erasure/requests') + .set(as('alice')) + .send({ userId: USER_ID, reason: 'GDPR Art. 17' }) + + const res = await request(app) + .post(`/admin/erasure/requests/${created.body.data.id}/execute`) + .set(as('carol')) + .send({ confirm: `DELETE ${USER_ID}` }) + + expect(res.status).toBe(500) + expect(res.body.error).toMatch(/Must be APPROVED/) + expect(state.events).toEqual([]) + }) +}) diff --git a/tests/unit/compliance/privacyErasure.test.ts b/tests/unit/compliance/privacyErasure.test.ts index 5b8c126..f6871ef 100644 --- a/tests/unit/compliance/privacyErasure.test.ts +++ b/tests/unit/compliance/privacyErasure.test.ts @@ -19,6 +19,7 @@ import { ErasureRequestStatus, } from '../../../src/compliance/privacyErasure' import { alertingService } from '../../../src/services/alerting' +import { revokeAllUserSessions } from '../../../src/services/refresh-token.service' jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) jest.mock('../../../src/utils/logger', () => ({ @@ -28,6 +29,9 @@ jest.mock('../../../src/utils/logger', () => ({ info: jest.fn(), }, })) +jest.mock('../../../src/services/refresh-token.service', () => ({ + revokeAllUserSessions: jest.fn().mockResolvedValue(2), +})) jest.mock('../../../src/services/alerting', () => ({ alertingService: { emit: jest.fn().mockResolvedValue({ sent: true }), @@ -167,6 +171,7 @@ describe('Privacy Erasure Workflow', () => { id: requestId, status: ErasureRequestStatus.PENDING, userId: 'user-123', + requestedBy: 'admin@example.com', }) mockDb.erasureRequest.update.mockResolvedValue({ id: requestId, @@ -181,6 +186,20 @@ describe('Privacy Erasure Workflow', () => { expect(result.approvedBy).toBe(approvedBy) }) + it('rejects self-approval by the requester', async () => { + mockDb.erasureRequest.findUnique.mockResolvedValue({ + id: 'request-1', + status: ErasureRequestStatus.PENDING, + userId: 'user-123', + requestedBy: 'admin-a', + }) + + await expect( + approveErasureRequest('request-1', 'admin-a') + ).rejects.toThrow('Self-approval is not allowed') + expect(mockDb.erasureRequest.update).not.toHaveBeenCalled() + }) + it('throws when request not found', async () => { mockDb.erasureRequest.findUnique.mockResolvedValue(null) @@ -237,13 +256,20 @@ describe('Privacy Erasure Workflow', () => { id: requestId, userId, status: ErasureRequestStatus.APPROVED, + approvedBy: 'admin-b', user: { id: userId, walletAddress: 'GABC...', }, }) - const summary = await executeErasure(requestId) + const summary = await executeErasure( + requestId, + 'admin-c', + 'DELETE user-123' + ) + + expect(revokeAllUserSessions).toHaveBeenCalledWith(userId, 'admin') expect(summary.userId).toBe(userId) expect(summary.deletedRecords.sessions).toBe(5) @@ -259,8 +285,7 @@ describe('Privacy Erasure Workflow', () => { expect(alertingService.emit).toHaveBeenCalledWith( expect.objectContaining({ title: 'Privacy erasure completed', - }), - expect.any(Number) + }) ) }) @@ -270,9 +295,9 @@ describe('Privacy Erasure Workflow', () => { status: ErasureRequestStatus.PENDING, }) - await expect(executeErasure('request-1')).rejects.toThrow( - 'Must be APPROVED' - ) + await expect( + executeErasure('request-1', 'admin-c', 'DELETE user-123') + ).rejects.toThrow('Must be APPROVED') }) it('marks request as failed on error and alerts', async () => { @@ -281,12 +306,15 @@ describe('Privacy Erasure Workflow', () => { id: requestId, userId: 'user-123', status: ErasureRequestStatus.APPROVED, + approvedBy: 'admin-b', user: { id: 'user-123', walletAddress: 'GA...' }, }) mockDb.$transaction.mockRejectedValue(new Error('Database error')) - await expect(executeErasure(requestId)).rejects.toThrow('Database error') + await expect( + executeErasure(requestId, 'admin-c', 'DELETE user-123') + ).rejects.toThrow('Database error') expect(mockDb.erasureRequest.update).toHaveBeenCalledWith({ where: { id: requestId }, @@ -298,9 +326,8 @@ describe('Privacy Erasure Workflow', () => { expect(alertingService.emit).toHaveBeenCalledWith( expect.objectContaining({ title: 'Privacy erasure failed', - severity: 'error', - }), - expect.any(Number) + severity: 'critical', + }) ) }) })