From e2d6bb3d695b88665e08d28d3fceaa59ef3282ad Mon Sep 17 00:00:00 2001 From: m0rph3us1987 Date: Thu, 23 Jul 2026 13:28:29 +0000 Subject: [PATCH] AI Implementation feature(918): Admin Area System: Database Backup/Restore and Danger Zone 1.01 (#63) --- .kilo/plans/916.md | 155 ------------ .kilo/plans/918.md | 32 +++ .../modules/admin/system/restore.service.ts | 74 +++++- docs/api/admin-system.md | 29 ++- docs/architecture/key-files.md | 5 +- docs/database/users.md | 12 +- docs/index.md | 2 +- .../admin-system-restore-commit.spec.ts | 236 ++++++++++++++++++ 8 files changed, 382 insertions(+), 163 deletions(-) delete mode 100644 .kilo/plans/916.md create mode 100644 .kilo/plans/918.md diff --git a/.kilo/plans/916.md b/.kilo/plans/916.md deleted file mode 100644 index dca732f..0000000 --- a/.kilo/plans/916.md +++ /dev/null @@ -1,155 +0,0 @@ -# Implementation Plan: Job 916 — Admin System Restore Commit (stagingId stripped from request body) - -## Root cause (read-only investigation) - -The destructive restore never executes because the Zod schema used to validate -`POST /api/v1/admin/system/restore/commit` **drops the `stagingId` field** from -the request body before the controller runs. - -`backend/src/modules/admin/system/dto/admin-system.dto.ts:23`: - -```ts -export const AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema; -``` - -`AdminDangerConfirmBodySchema` (line 18) only allows `{ operation, token }`. -Zod's `safeParse` / the NestJS `ZodValidationPipe` therefore strip every -unknown field — including `stagingId` — from the parsed body. The controller -then receives `body.stagingId === undefined`, falls back to the empty string -(`body.stagingId ?? ''`), and `RestoreService.commitRestore('')` finds no -matching stage and throws `SYSTEM_RESTORE_STAGE_EXPIRED` -(`RestoreService.commitRestore` at `restore.service.ts:223-231`). - -The frontend already sends the field correctly -(`frontend/src/app/features/admin/system/system.service.ts:42`): - -```ts -async commitRestore(payload: { operation: 'restore-backup'; token: string; stagingId: string }) -``` - -so the deviation is purely server-side. The negative path (random JSON) still -fails with `SYSTEM_RESTORE_VALIDATION_FAILED` because the malformed archive -rejects at `validate`, never reaching `commit`. - -The fix is to give `AdminRestoreCommitBodySchema` its own declaration that -**includes** `stagingId` (mirroring `AdminReauthBodySchema` at line 12-16), and -to tighten the controller so a missing `stagingId` returns a precise -`SYSTEM_RESTORE_STAGE_EXPIRED` instead of relying on the empty-string fallback. - -## 1. Architectural Reconnaissance - -- **Codebase style & conventions:** TypeScript monorepo (`backend` + `frontend`). - Backend is NestJS with `class-validator`/`zod` mixed; the admin-system module - uses `ZodValidationPipe` per-route. Strict async/await, error handling via - `ApiError` + `ERROR_CODES` (`backend/src/common/errors/`). -- **Data Layer:** SQLite via `better-sqlite3` + TypeORM. Restore streams are - in-memory (`RestoreService.stages: Map`) plus a - sibling directory tree under `/.system-staging/restore-/`. -- **Test Framework & Structure:** Jest with two projects in - `tests/jest.config.js`. Tests live in `tests/backend/*.spec.ts` and - `tests/frontend/*.spec.ts`. Single command from the root: - `npm test` (already wired in `package.json`). -- **Required Tools & Dependencies:** No new tools. Existing: Node 20, NestJS, - better-sqlite3, zod, Jest, ts-jest. `setup.sh` already builds the app and - rebuilds the native binding — no updates needed. - -## 2. Impacted Files - -- **To Modify:** - - `backend/src/modules/admin/system/dto/admin-system.dto.ts` — replace the - `AdminRestoreCommitBodySchema` alias with a proper schema that accepts - `stagingId` (required for `restore-backup`). - - `backend/src/modules/admin/system/admin-system.controller.ts` — validate - `stagingId` is present before consuming the token, and stop feeding an - empty string into `commitRestore`. -- **To Create:** - - `tests/backend/admin-system-restore-commit.spec.ts` — focused e2e test - that the `restore/commit` endpoint forwards `stagingId` to - `RestoreService.commitRestore` and atomically swaps the live DB. - -## 3. Proposed Changes - -1. **DTO fix** — `backend/src/modules/admin/system/dto/admin-system.dto.ts` - - Remove the `AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema` - alias (line 23). - - Add a dedicated schema that requires `stagingId` for `restore-backup` - while still letting the safe schema accept the other two operations in - shared code paths: - - ```ts - export const AdminRestoreCommitBodySchema = z.object({ - operation: z.enum(ADMIN_OPERATION_KINDS), - token: z.string().min(1).max(512), - stagingId: z.string().min(1).max(128).optional(), - }); - ``` - - - Keep the `AdminOperationKind` re-export and the - `AdminRestoreCommitBody` `z.infer` type aligned so the controller - signature remains `body: { operation; token; stagingId? }`. - -2. **Controller hardening** — `backend/src/modules/admin/system/admin-system.controller.ts:95-121` - - After the `body.operation === 'restore-backup'` check, throw - `SYSTEM_RESTORE_STAGE_EXPIRED` with HTTP 400 if `body.stagingId` is - falsy. This produces a precise error instead of the empty-string - fallback that currently slips through to `RestoreService`. - - Pass `body.stagingId` directly to `commitRestore` (no `?? ''`). - - Existing behavior for `reset-scores` / `wipe-challenges` (which use - `AdminDangerConfirmBodySchema`) is unchanged. - -3. **No frontend changes required.** `system.service.ts` already POSTs - `{ operation, token, stagingId }`; the modal already calls it after - `confirmations`. Once the server schema preserves `stagingId` the happy - path works end-to-end. - -4. **No DB migration needed.** `admin_operation_token` already exists; - `stagingId` is bound through the in-memory `RestoreService.stages` map. - -## 4. Test Strategy - -- **Target Unit Test File:** `tests/backend/admin-system-restore-commit.spec.ts` (new). - Companion lightweight spec additions to - `tests/backend/admin-system-restore-validation.spec.ts` are optional; the - new file is sufficient and keeps the diff small. -- **Mocking Strategy:** - - Build a minimal `Test.createTestingModule` with just - `AdminSystemController`, `RestoreService`, `ConfirmationTokenService`, - `AuthService`, `BackupService`, `DangerZoneService`, the - `AdminOperationTokenEntity` repository, the `RestoreArchiveSchema` DTO, - and a `Role`-aware stub for `AdminGuard` (re-use the approach from - `tests/backend/admin-system-authorization.spec.ts`). - - Stub `ConfirmationTokenService.consume` to record the - `{ userId, operation, stagingId }` argument and resolve `{ id: 'tok' }`. - - Stub `RestoreService.commitRestore` to record its argument and resolve - `{ restoresPerformed: true, revokeUserId: userId }`. - - Stub `AuthService.reauthenticateAdmin` to resolve an admin user, and - `AuthService.revokeAllRefreshSessions` to a no-op. - - Stub `RestoreService.stageArchive` to return a fixed - `{ stagingId: 'stage-1', expiresAt, summary }` so the validate→confirm - →commit pipeline can be exercised when needed. - -- **Cases covered (minimal, focused):** - 1. `commitRestore` forwards `stagingId` to `RestoreService.commitRestore` - and returns `{ ok: true, operation: 'restore-backup' }` when the body - contains `{ operation, token, stagingId }`. - 2. `commitRestore` returns - `SYSTEM_RESTORE_STAGE_EXPIRED` (HTTP 400) when `stagingId` is omitted - **before** consuming the token (i.e. the fix returns the error early - rather than reaching `RestoreService`). - 3. `commitRestore` returns `SYSTEM_TOKEN_MISMATCH` when `operation` is - `reset-scores` (regression guard for the existing controller branch). - -- **Run command:** `npm test` from the repo root (already wired in - `package.json:test`). - -## 5. Verification - -- Manual smoke test against the dev stack (`npm run dev`): - 1. POST `/api/v1/admin/system/restore/validate` with a valid backup → expect - `stagingId`. - 2. POST `/api/v1/admin/system/confirmations` with `{ operation: 'restore-backup', password, stagingId }` → expect `token`. - 3. POST `/api/v1/admin/system/restore/commit` with `{ operation: 'restore-backup', token, stagingId }` - → expect `{ ok: true, operation: 'restore-backup' }`, the live DB swapped, - and the admin session revoked (redirect to `/login`). -- The error path must still surface - `SYSTEM_RESTORE_VALIDATION_FAILED` for malformed JSON bodies. diff --git a/.kilo/plans/918.md b/.kilo/plans/918.md new file mode 100644 index 0000000..4605bbd --- /dev/null +++ b/.kilo/plans/918.md @@ -0,0 +1,32 @@ +# Implementation Plan: Job 918 — Admin Area System Database Backup/Restore and Danger Zone 1.01 + +## 1. Architectural Reconnaissance +- **Codebase style & conventions:** Node.js monorepo using strict TypeScript, NestJS controllers/services on the backend, Angular standalone components and signals on the frontend, async/await, dependency injection, Zod request/archive validation, and stable `ApiError` codes. The requested UI and API flow already exists; the defect is isolated to the backend restore rebuild. `RestoreService.cloneAndOverwriteDatabase()` clones the live SQLite file and executes `DELETE` statements while the live schema's `trg_user_last_admin_delete` trigger remains active. `PRAGMA foreign_keys = OFF` does not disable triggers, so deleting the sole live admin raises `LAST_ADMIN` before archived users can be inserted. +- **Data Layer:** SQLite through TypeORM's `better-sqlite3` driver. Runtime migrations create a database-level invariant with `trg_user_last_admin_update` and `trg_user_last_admin_delete`. Backups dynamically include application tables but exclude `admin_operation_token`, `migrations`, and SQLite internals. Restore uses a cloned database, direct parameterized `better-sqlite3` statements, and a database/uploads filesystem swap with rollback handles. +- **Test Framework & Structure:** Jest 29 with `ts-jest`; backend tests live under `/repo/tests/backend` and are selected by `/repo/tests/jest.config.js`. Root `npm test` runs backend and frontend projects, while `npm run test:backend` runs the focused backend suite. Tests must remain CLI-only and use isolated temporary SQLite/upload/staging paths rather than shared mutable resources. +- **Required Tools & Dependencies:** No new package, system tool, global CLI, or persistent `/data` asset is required. Existing Node.js/npm, TypeScript, Jest, Nest testing utilities, TypeORM, and `better-sqlite3` are sufficient. `setup.sh` already installs dependencies and rebuilds the native SQLite binding, so it should not be changed. + +## 2. Impacted Files +- **To Modify:** + - `backend/src/modules/admin/system/restore.service.ts` — make the offline clone rebuild temporarily suspend the last-admin triggers, restore all archived rows, validate invariants, and reinstate the triggers before the file is eligible for swapping. + - `tests/backend/admin-system-restore-commit.spec.ts` — add a focused real-file restore regression covering a backup that contains the same sole admin as the live database, restored table content, uploads, and trigger preservation; retain the existing controller/DTO contract coverage. +- **To Create:** None. + +## 3. Proposed Changes +1. **Database / Schema Migration:** + - Do not add or alter a migration: the `LAST_ADMIN` triggers are correct for ordinary runtime user mutations. + - In the private staged database clone only, read the exact SQL definitions of `trg_user_last_admin_update` and `trg_user_last_admin_delete` from `sqlite_master`, fail closed if the expected trigger definitions cannot be captured, and drop those triggers before clearing application tables. + - Keep foreign keys disabled during the bulk replacement, clear all known application tables, and insert archived rows in the existing dependency-safe order with parameterized values. + - Before closing the staged clone, verify the archive produced at least one admin user so a restore cannot bypass the deployment-wide invariant. Recreate both captured triggers exactly, re-enable foreign keys, run `PRAGMA foreign_key_check`, and treat any violations or trigger-restoration failure as a restore failure. This confines the temporary invariant suspension to an offline candidate file and ensures the candidate has full protections before swap. +2. **Backend Logic & APIs:** + - Preserve the existing `POST /api/v1/admin/system/restore/validate`, confirmation-token, and `POST /restore/commit` contracts; `stagingId` already survives Zod validation and reaches `RestoreService.commitRestore()`. + - Refactor the staged rebuild cleanup so database closure and pragma/trigger restoration are deterministic and original failures are not masked by cleanup. Any rebuild, integrity, swap, or verification error must continue through the existing rollback path and return `SYSTEM_RESTORE_ROLLED_BACK` without changing live data/uploads. + - Keep the existing filesystem transaction: build and validate the candidate first, copy staged uploads beside the live upload root, swap database and uploads together, verify the swapped database, then commit rollback artifacts. + - Keep post-success session revocation in `AdminSystemController.commitRestore()`. After the restored database is live, revoke refresh sessions for the authenticated admin ID; the frontend's already-wired `forceServerInvalidation()` then clears the access session and redirects all tabs to login. +3. **Frontend UI Integration:** + - No frontend changes are planned. The file picker, staging summary, reauthentication modal, confirmation phrase, restore commit request, rollback alert, data-cache invalidation, and forced-login behavior are already wired in `frontend/src/app/features/admin/system/system.component.ts`, `system.service.ts`, and the auth/system data-change services. + +## 4. Test Strategy +- **Target Unit Test File:** `tests/backend/admin-system-restore-commit.spec.ts`. +- **Mocking Strategy:** Keep existing controller dependencies mocked for request forwarding/token/session-revocation assertions. Add one minimal service-level integration regression using a unique temporary directory, a real file-backed `better-sqlite3` database, real `RestoreService`, and real `FilesystemTransactionService`; mock only `ConfigService` and the live `DataSource.query` verification boundary as needed. Arrange a live database with the migrated last-admin triggers, one sole admin, pre-restore challenge/blog/setting rows, and live uploads; stage a valid archive containing that same admin plus different table values and replacement challenge/icon files; commit; then open the swapped file independently and assert archived counts/settings, exact upload replacement, and both last-admin triggers still exist and reject deleting the sole restored admin. Add one focused invalid-archive invariant case with no admin and assert `SYSTEM_RESTORE_ROLLED_BACK` plus unchanged live database/uploads. Clean all temporary files in `afterEach`/`afterAll`; no UI, network, visual checks, shared `/data`, or large mock environment. +- **Execution:** Follow red-green-refactor: first run the focused backend spec to confirm the `LAST_ADMIN` failure, implement the smallest restore-service correction, rerun the focused spec, then run `npm run test:backend`, root `npm test`, `npm --workspace backend run build`, and the repository lint/typecheck commands if present (the current package scripts expose build but no dedicated lint/typecheck command). diff --git a/backend/src/modules/admin/system/restore.service.ts b/backend/src/modules/admin/system/restore.service.ts index 571aad3..b057264 100644 --- a/backend/src/modules/admin/system/restore.service.ts +++ b/backend/src/modules/admin/system/restore.service.ts @@ -343,7 +343,32 @@ export class RestoreService { } const db = new Database(targetPath); db.pragma('foreign_keys = OFF'); + const capturedTriggerSqls: string[] = []; try { + // Capture and temporarily drop the deployment-wide last-admin triggers. + // SQLite triggers fire regardless of foreign_keys, so clearing the + // sole admin row before inserting the archive's admin would abort the + // rebuild. We only mutate an offline candidate file and reinstate + // them before swap eligibility. + const triggerRows = db + .prepare( + `SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete') AND sql IS NOT NULL`, + ) + .all() as Array<{ name: string; sql: string }>; + const capturedTriggerNames = new Set(triggerRows.map((r) => r.name)); + if ( + !capturedTriggerNames.has('trg_user_last_admin_update') || + !capturedTriggerNames.has('trg_user_last_admin_delete') + ) { + throw new Error( + 'Required LAST_ADMIN triggers missing from cloned schema; refusing to rebuild', + ); + } + for (const row of triggerRows) { + capturedTriggerSqls.push(row.sql); + db.prepare(`DROP TRIGGER IF EXISTS "${row.name}"`).run(); + } + // Discover the tables present in the live schema; only these may be // restored. This protects against accidentally injecting unknown // table rows into the live system. @@ -384,10 +409,55 @@ export class RestoreService { }); tx(rows); } - } finally { + + // Re-establish the deployment-wide invariant in the candidate + // database before swap eligibility. A restore that ships zero + // admins must fail here so the live system never lands in that + // state, even momentarily. + const adminCount = ( + db.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE "role" = 'admin'`).get() as { c: number } + ).c; + if (!adminCount || adminCount < 1) { + throw new Error('Restored archive contains no admin user; refusing to swap'); + } + + for (const sql of capturedTriggerSqls) { + db.exec(sql); + } + db.pragma('foreign_keys = ON'); db.pragma('foreign_key_check'); - db.close(); + } catch (innerErr) { + // Make sure we never leak a candidate missing the triggers: re-create + // them best-effort before rethrowing so the rolled-back candidate is + // not left in an unsafe state on disk. + try { + const existing = new Set( + ( + db + .prepare( + `SELECT name FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete')`, + ) + .all() as Array<{ name: string }> + ).map((r) => r.name), + ); + for (const sql of capturedTriggerSqls) { + const m = /"([^"]+)"/.exec(sql); + const name = m?.[1]; + if (name && !existing.has(name)) { + db.exec(sql); + } + } + } catch { + // ignore best-effort restore errors + } + throw innerErr; + } finally { + try { + db.close(); + } catch { + // ignore + } } } diff --git a/docs/api/admin-system.md b/docs/api/admin-system.md index ef1a252..9276b31 100644 --- a/docs/api/admin-system.md +++ b/docs/api/admin-system.md @@ -4,7 +4,7 @@ title: Admin System Endpoints description: Admin-only destructive System operations: backup download, restore validate/commit, re-authenticated confirmation tokens, reset-scores, and wipe-challenges. resource: backend/src/modules/admin/system/admin-system.controller.ts tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens] -timestamp: 2026-07-23T12:04:50Z +timestamp: 2026-07-23T13:27:25Z --- # Overview @@ -130,6 +130,31 @@ Flow: database + uploads are swapped atomically via `FilesystemTransactionService.stageSwap()` (with a `restore-backup` rollback handle). + + The offline clone rebuild begins by capturing the exact SQL of the + deployment-wide `trg_user_last_admin_update` and + `trg_user_last_admin_delete` triggers from `sqlite_master`, + dropping them in the candidate database, then clearing the + application tables and re-inserting the archived rows. SQLite + triggers fire regardless of `PRAGMA foreign_keys = OFF`, so + clearing the sole admin row before the archive's admin is inserted + would otherwise abort the rebuild with `LAST_ADMIN`. The suspension + is confined to the offline candidate file. Before the candidate is + eligible to swap, the rebuild: + + - asserts at least one `user.role = 'admin'` row exists in the + rebuilt data (zero-admin archives fail with + `SYSTEM_RESTORE_ROLLED_BACK` so the live system never lands in a + no-admin state, even momentarily), + - re-creates both captured triggers via `db.exec(sql)` so the + `LAST_ADMIN` invariant is restored on the candidate, and + - re-enables `PRAGMA foreign_keys` and runs `PRAGMA foreign_key_check`. + + On any rebuild, integrity, swap, or verification error the capture + loop is wrapped in a `try/catch` that best-effort re-creates any + missing trigger before rethrowing, so a rolled-back candidate never + leaks to disk missing the `LAST_ADMIN` safety net. The original + failure is then rethrown unchanged. 4. On any failure the swap is rolled back, the staged tree is removed, and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500). 5. On success, every refresh token belonging to the current admin is @@ -288,4 +313,4 @@ curl -sS ... -d '{"operation":"reset-scores","token":""}' | Front-end route | `frontend/src/app/app.routes.ts` (admin child `system`) | | Cross-store invalidation | `frontend/src/app/core/services/system-data-change.service.ts` | | Forced logout helper | `frontend/src/app/core/services/auth.service.ts` (`forceServerInvalidation`) | -| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` | +| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`, `admin-system-restore-commit.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` | diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 1268feb..62167e8 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -3,7 +3,7 @@ type: architecture title: Key Files Index description: One-line responsibility for important source and contract-test files, including event-window, challenge, scoreboard, session, notification, and blog flows. tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session, blog] -timestamp: 2026-07-23T10:12:24Z +timestamp: 2026-07-23T13:27:25Z --- # Backend @@ -126,7 +126,7 @@ timestamp: 2026-07-23T10:12:24Z | `backend/src/modules/admin/system/admin-system.controller.ts` | Mounts `/api/v1/admin/system/*` (backup download, restore validate/commit, confirmations, scores/reset, challenges/wipe); all handlers require `AdminGuard` + `@Roles('admin')` and the destructive ones also consume a confirmation token. | | `backend/src/modules/admin/system/admin-system.module.ts` | Wires `AdminSystemController`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`; imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule`. | | `backend/src/modules/admin/system/backup.service.ts` | Builds the full backup JSON: every application table (`sqlite_master` discovery, excluding `admin_operation_token`/`migrations`/`sqlite_*`), with base64-encoded uploads walked recursively from `UPLOAD_DIR` (skipping `.staging`). Exposes `discoverTables()`, `getUploadDir()`, and `BackupService.stringify(doc)`. | -| `backend/src/modules/admin/system/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `/.system-staging/restore-/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, clears every application table, re-inserts archived rows in FK-safe order, then atomically swaps the live DB + uploads via `FilesystemTransactionService.stageSwap({ name: 'restore-backup', ... })`. On any failure the swap is rolled back and `SYSTEM_RESTORE_ROLLED_BACK` is returned. | +| `backend/src/modules/admin/system/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `/.system-staging/restore-/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, captures and temporarily drops the `trg_user_last_admin_update` / `trg_user_last_admin_delete` triggers in the offline candidate, clears every application table, re-inserts archived rows in FK-safe order, asserts the archive still contains at least one admin, re-creates the captured triggers, then atomically swaps the live DB + uploads via `FilesystemTransactionService.stageSwap({ name: 'restore-backup', ... })`. On any failure the swap is rolled back and `SYSTEM_RESTORE_ROLLED_BACK` is returned; the rebuild also best-effort re-creates any missing trigger before rethrowing so a rolled-back candidate never leaks missing the `LAST_ADMIN` safety net. | | `backend/src/modules/admin/system/danger-zone.service.ts` | `resetScores()` deletes every `solve` row transactionally; `wipeChallenges()` snapshots `/challenges` to a side directory, deletes every challenge inside a transaction, physically removes the live `-challenges` directory post-commit, and either deletes the snapshot or restores from it on failure. | | `backend/src/modules/admin/system/filesystem-transaction.service.ts` | Generic file/directory swap primitive: `stageSwap({ pairs, hooks })` renames each live path to a rollback location, then renames the staged path into place (with copy+unlink cross-device fallback); `commit(handle)` removes rollback artifacts; `rollback(handle)` restores the original live paths. Provides static helpers `rmSafe`, `copyDirSync`, `ensureDir`. | | `backend/src/modules/admin/system/confirmation-token.service.ts` | Issues SHA-256-hashed single-use tokens with `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` TTL; `consume()` runs in a transaction with conditional `WHERE consumedAt IS NULL` updates so only the first concurrent caller succeeds. `purgeExpired()` deletes expired and >24h-old consumed rows. | @@ -142,6 +142,7 @@ timestamp: 2026-07-23T10:12:24Z | `tests/backend/admin-system-confirmation-token.spec.ts` | Issue/consume/expire/reuse/mismatch flow and TTL config. | | `tests/backend/admin-system-danger.spec.ts` | Reset-scores and wipe-challenges happy path + DB/file rollback. | | `tests/backend/admin-system-restore-validation.spec.ts` | Archive schema validation, base64 size mismatch, sha256 mismatch, safe-path rejection, and duplicate-path rejection. | +| `tests/backend/admin-system-restore-commit.spec.ts` | Controller forwards `stagingId` to `RestoreService.commitRestore`, plus a real-file service-level regression that restores a backup containing the same admin as the live system, asserts swapped table settings + uploads + trigger preservation, and verifies an admin-less archive is rejected with `SYSTEM_RESTORE_ROLLED_BACK`. | | `frontend/src/app/features/admin/system/system.component.ts` | `/admin/system` smart page: two panels (Database + Danger Zone), backup download, restore pick + validate, confirm modal trigger, and per-operation success/error surfacing + cross-store invalidation. | | `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | Re-authentication + confirmation-phrase modal with operation-specific copy from `system.pure.ts`. | | `frontend/src/app/features/admin/system/system.service.ts` | HTTP client for the six `/api/v1/admin/system/*` endpoints. | diff --git a/docs/database/users.md b/docs/database/users.md index 30b0e66..13982de 100644 --- a/docs/database/users.md +++ b/docs/database/users.md @@ -3,7 +3,7 @@ type: database title: User Table description: The `user` table — accounts, roles, and statuses. tags: [database, user, auth] -timestamp: 2026-07-21T17:37:18Z +timestamp: 2026-07-23T13:27:25Z --- # Schema @@ -31,6 +31,16 @@ timestamp: 2026-07-21T17:37:18Z demote or delete the last admin are rejected by `UsersService.enforceLastAdminInvariant` (used by `AdminService.updateUserRole` and `AdminService.deleteUser`). +- A deployment-wide database safety net backs the invariant up at the + SQLite level via two triggers created by the + `AddUserLastAdminTriggers1700000000800` migration: + `trg_user_last_admin_update` (fires on `UPDATE OF role` when the old + row was admin, the new role is not, and no other admin row exists) + and `trg_user_last_admin_delete` (fires on `DELETE` of an admin row + when no other admin row exists). Both abort the offending statement + with `RAISE(ABORT, 'LAST_ADMIN')`. They are recreated by the restore + rebuild after the candidate database is rebuilt — see + [Admin System Endpoints — POST /restore/commit](/api/admin-system.md). # First-admin bootstrap diff --git a/docs/index.md b/docs/index.md index 03b9307..444ae6a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and signed-in blog views. The docs below are organized by purpose so agents can pull just the slice -they need. Last regenerated 2026-07-23T12:04:50Z. +they need. Last regenerated 2026-07-23T13:27:25Z. # Architecture diff --git a/tests/backend/admin-system-restore-commit.spec.ts b/tests/backend/admin-system-restore-commit.spec.ts index 139b487..3ace4fd 100644 --- a/tests/backend/admin-system-restore-commit.spec.ts +++ b/tests/backend/admin-system-restore-commit.spec.ts @@ -120,3 +120,239 @@ describe('Job 916: Admin restore commit body passes stagingId through', () => { void ApiError; }); }); + +describe('Job 918: Restore commit succeeds end-to-end with a real backup file', () => { + // Minimal valid archive shape used to drive RestoreService.stageArchive + commitRestore + // against a real file-backed SQLite database and uploads directory. + + beforeEach(() => { + jest.resetModules(); + delete process.env.SYSTEM_OP_RESTORE_STAGE_TTL_MS; + delete process.env.SYSTEM_OP_CONFIRM_TOKEN_TTL_MS; + delete process.env.SYSTEM_OP_STAGING_DIR; + delete process.env.UPLOAD_DIR; + delete process.env.DATABASE_PATH; + }); + + async function buildArchive(adminId: string, challengeCount: number, blogCount: number) { + const Database = require('better-sqlite3'); + const path = require('path'); + const fs = require('fs'); + const tmp = fs.mkdtempSync(require('path').join(require('os').tmpdir(), 'hipctf-archive-')); + const dbPath = path.join(tmp, 'source.sqlite'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT); + CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT); + CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT); + CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user" + FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) + BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; + CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user" + FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) + BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; + `); + db.prepare(`INSERT INTO "user" (id, username, role, passwordHash, status) VALUES (?, ?, 'admin', 'hash', 'enabled')`).run(adminId, 'admin'); + db.prepare(`INSERT INTO "setting" (key, value) VALUES (?, ?)`).run('site_title', 'ARCHIVE'); + for (let i = 0; i < challengeCount; i++) { + db.prepare(`INSERT INTO "challenge" (id, title) VALUES (?, ?)`).run(`c${i}`, `Archived ${i}`); + } + for (let i = 0; i < blogCount; i++) { + db.prepare(`INSERT INTO "blog_post" (id, title) VALUES (?, ?)`).run(`b${i}`, `Archived blog ${i}`); + } + const tables = { + user: db.prepare('SELECT * FROM "user"').all(), + setting: db.prepare('SELECT * FROM "setting"').all(), + challenge: db.prepare('SELECT * FROM "challenge"').all(), + blog_post: db.prepare('SELECT * FROM "blog_post"').all(), + }; + const uploads = [ + { + path: 'icons/replaced.png', + originalFilename: 'replaced.png', + mimeType: 'image/png', + sizeBytes: 4, + sha256: '', + dataBase64: Buffer.from('ARCH').toString('base64'), + }, + ]; + uploads[0].sha256 = require('crypto').createHash('sha256').update(Buffer.from('ARCH')).digest('hex'); + db.close(); + const archive = { + format: 'hipctf-system-backup', + version: 1, + exportedAt: new Date().toISOString(), + tables, + tableCounts: { + user: tables.user.length, + setting: tables.setting.length, + challenge: tables.challenge.length, + blog_post: tables.blog_post.length, + }, + uploads, + }; + return { archiveText: JSON.stringify(archive), cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) }; + } + + function makeLiveDbWithTrigger(dbPath: string): void { + const fs = require('fs'); + fs.rmSync(dbPath, { recursive: true, force: true }); + const Database = require('better-sqlite3'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT); + CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT); + CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT); + CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT); + INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('admin-live', 'admin', 'admin', 'hash', 'enabled'); + INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('player-live', 'p1', 'player', 'hash', 'enabled'); + INSERT INTO "setting" (key, value) VALUES ('site_title', 'MODIFIED TITLE'); + INSERT INTO "challenge" (id, title) VALUES ('live-1', 'Live 1'); + INSERT INTO "blog_post" (id, title) VALUES ('live-b1', 'Live blog'); + CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user" + FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) + BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; + CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user" + FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id) + BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END; + `); + db.close(); + } + + it('successfully swaps live DB and uploads for an archive that contains the live admin', async () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const Database = require('better-sqlite3'); + + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-')); + const dataDir = path.join(work, 'data'); + const uploadDir = path.join(dataDir, 'uploads'); + fs.mkdirSync(uploadDir, { recursive: true }); + fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true }); + fs.writeFileSync(path.join(uploadDir, 'icons', 'old.png'), Buffer.from('OLD')); + const dbPath = path.join(dataDir, 'db.sqlite'); + makeLiveDbWithTrigger(dbPath); + + process.env.DATABASE_PATH = dbPath; + process.env.UPLOAD_DIR = uploadDir; + process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging'); + + const { ConfigService } = require('@nestjs/config'); + const config = new ConfigService({ + DATABASE_PATH: dbPath, + UPLOAD_DIR: uploadDir, + SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'), + SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000, + SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000, + BODY_SIZE_LIMIT: '50mb', + }); + const ttl = config.get('SYSTEM_OP_RESTORE_STAGE_TTL_MS'); + expect(typeof ttl).toBe('number'); + const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service'); + const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service'); + + const fakeDataSource = { + query: jest.fn().mockResolvedValue([{ c: 1 }]), + }; + + const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService()); + + const { archiveText, cleanup } = await buildArchive('admin-live', 3, 2); + const staged = await restore.stageArchive(archiveText, 'admin-live'); + expect(staged.stagingId).toBeTruthy(); + + const result = await restore.commitRestore(staged.stagingId); + expect(result.restoresPerformed).toBe(true); + + const liveDb = new Database(dbPath, { readonly: true }); + const counts = { + user: liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c, + challenge: liveDb.prepare(`SELECT COUNT(*) AS c FROM "challenge"`).get().c, + blog_post: liveDb.prepare(`SELECT COUNT(*) AS c FROM "blog_post"`).get().c, + }; + const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value; + const adminCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE role='admin'`).get().c; + const triggerNames = liveDb + .prepare(`SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'trg_user_last_admin_%'`) + .all() + .map((r: any) => r.name) + .sort(); + liveDb.close(); + + expect(counts).toEqual({ user: 1, challenge: 3, blog_post: 2 }); + expect(title).toBe('ARCHIVE'); + expect(adminCount).toBe(1); + expect(triggerNames).toEqual(['trg_user_last_admin_delete', 'trg_user_last_admin_update']); + + expect(fs.existsSync(path.join(uploadDir, 'icons', 'replaced.png'))).toBe(true); + expect(fs.existsSync(path.join(uploadDir, 'icons', 'old.png'))).toBe(false); + expect(fs.readFileSync(path.join(uploadDir, 'icons', 'replaced.png')).toString()).toBe('ARCH'); + + cleanup(); + fs.rmSync(work, { recursive: true, force: true }); + }); + + it('rejects an archive with no admin user without swapping live data', async () => { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const Database = require('better-sqlite3'); + + const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-noadmin-')); + const dataDir = path.join(work, 'data'); + const uploadDir = path.join(dataDir, 'uploads'); + fs.mkdirSync(uploadDir, { recursive: true }); + fs.writeFileSync(path.join(uploadDir, 'keep.txt'), 'KEEP'); + const dbPath = path.join(dataDir, 'db.sqlite'); + makeLiveDbWithTrigger(dbPath); + + process.env.DATABASE_PATH = dbPath; + process.env.UPLOAD_DIR = uploadDir; + process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging'); + + const { ConfigService } = require('@nestjs/config'); + const config = new ConfigService({ + DATABASE_PATH: dbPath, + UPLOAD_DIR: uploadDir, + SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'), + SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000, + SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000, + BODY_SIZE_LIMIT: '50mb', + }); + const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service'); + const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service'); + const fakeDataSource = { query: jest.fn().mockResolvedValue([{ c: 1 }]) }; + const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService()); + + const Database2 = require('better-sqlite3'); + const db = new Database2(':memory:'); + db.exec(`CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);`); + const archive = { + format: 'hipctf-system-backup', + version: 1, + exportedAt: new Date().toISOString(), + tables: { user: db.prepare(`SELECT * FROM "user"`).all(), setting: [], challenge: [], blog_post: [] }, + tableCounts: { user: 0, setting: 0, challenge: 0, blog_post: 0 }, + uploads: [], + }; + db.close(); + + const staged = await restore.stageArchive(JSON.stringify(archive), 'admin-live'); + await expect(restore.commitRestore(staged.stagingId)).rejects.toMatchObject({ + code: ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK, + }); + + const liveDb = new Database(dbPath, { readonly: true }); + const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value; + const userCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c; + liveDb.close(); + + expect(title).toBe('MODIFIED TITLE'); + expect(userCount).toBe(2); + expect(fs.readFileSync(path.join(uploadDir, 'keep.txt')).toString()).toBe('KEEP'); + + fs.rmSync(work, { recursive: true, force: true }); + }); +});