7.6 KiB
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:
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):
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 withclass-validator/zodmixed; the admin-system module usesZodValidationPipeper-route. Strict async/await, error handling viaApiError+ERROR_CODES(backend/src/common/errors/). - Data Layer: SQLite via
better-sqlite3+ TypeORM. Restore streams are in-memory (RestoreService.stages: Map<stagingId, StagedRestore>) plus a sibling directory tree under<DATA_DIR>/.system-staging/restore-<id>/. - Test Framework & Structure: Jest with two projects in
tests/jest.config.js. Tests live intests/backend/*.spec.tsandtests/frontend/*.spec.ts. Single command from the root:npm test(already wired inpackage.json). - Required Tools & Dependencies: No new tools. Existing: Node 20, NestJS,
better-sqlite3, zod, Jest, ts-jest.
setup.shalready 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 theAdminRestoreCommitBodySchemaalias with a proper schema that acceptsstagingId(required forrestore-backup).backend/src/modules/admin/system/admin-system.controller.ts— validatestagingIdis present before consuming the token, and stop feeding an empty string intocommitRestore.
- To Create:
tests/backend/admin-system-restore-commit.spec.ts— focused e2e test that therestore/commitendpoint forwardsstagingIdtoRestoreService.commitRestoreand atomically swaps the live DB.
3. Proposed Changes
-
DTO fix —
backend/src/modules/admin/system/dto/admin-system.dto.ts-
Remove the
AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchemaalias (line 23). -
Add a dedicated schema that requires
stagingIdforrestore-backupwhile still letting the safe schema accept the other two operations in shared code paths: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
AdminOperationKindre-export and theAdminRestoreCommitBodyz.infertype aligned so the controller signature remainsbody: { operation; token; stagingId? }.
-
-
Controller hardening —
backend/src/modules/admin/system/admin-system.controller.ts:95-121- After the
body.operation === 'restore-backup'check, throwSYSTEM_RESTORE_STAGE_EXPIREDwith HTTP 400 ifbody.stagingIdis falsy. This produces a precise error instead of the empty-string fallback that currently slips through toRestoreService. - Pass
body.stagingIddirectly tocommitRestore(no?? ''). - Existing behavior for
reset-scores/wipe-challenges(which useAdminDangerConfirmBodySchema) is unchanged.
- After the
-
No frontend changes required.
system.service.tsalready POSTs{ operation, token, stagingId }; the modal already calls it afterconfirmations. Once the server schema preservesstagingIdthe happy path works end-to-end. -
No DB migration needed.
admin_operation_tokenalready exists;stagingIdis bound through the in-memoryRestoreService.stagesmap.
4. Test Strategy
-
Target Unit Test File:
tests/backend/admin-system-restore-commit.spec.ts(new). Companion lightweight spec additions totests/backend/admin-system-restore-validation.spec.tsare optional; the new file is sufficient and keeps the diff small. -
Mocking Strategy:
- Build a minimal
Test.createTestingModulewith justAdminSystemController,RestoreService,ConfirmationTokenService,AuthService,BackupService,DangerZoneService, theAdminOperationTokenEntityrepository, theRestoreArchiveSchemaDTO, and aRole-aware stub forAdminGuard(re-use the approach fromtests/backend/admin-system-authorization.spec.ts). - Stub
ConfirmationTokenService.consumeto record the{ userId, operation, stagingId }argument and resolve{ id: 'tok' }. - Stub
RestoreService.commitRestoreto record its argument and resolve{ restoresPerformed: true, revokeUserId: userId }. - Stub
AuthService.reauthenticateAdminto resolve an admin user, andAuthService.revokeAllRefreshSessionsto a no-op. - Stub
RestoreService.stageArchiveto return a fixed{ stagingId: 'stage-1', expiresAt, summary }so the validate→confirm →commit pipeline can be exercised when needed.
- Build a minimal
-
Cases covered (minimal, focused):
commitRestoreforwardsstagingIdtoRestoreService.commitRestoreand returns{ ok: true, operation: 'restore-backup' }when the body contains{ operation, token, stagingId }.commitRestorereturnsSYSTEM_RESTORE_STAGE_EXPIRED(HTTP 400) whenstagingIdis omitted before consuming the token (i.e. the fix returns the error early rather than reachingRestoreService).commitRestorereturnsSYSTEM_TOKEN_MISMATCHwhenoperationisreset-scores(regression guard for the existing controller branch).
-
Run command:
npm testfrom the repo root (already wired inpackage.json:test).
5. Verification
- Manual smoke test against the dev stack (
npm run dev):- POST
/api/v1/admin/system/restore/validatewith a valid backup → expectstagingId. - POST
/api/v1/admin/system/confirmationswith{ operation: 'restore-backup', password, stagingId }→ expecttoken. - POST
/api/v1/admin/system/restore/commitwith{ operation: 'restore-backup', token, stagingId }→ expect{ ok: true, operation: 'restore-backup' }, the live DB swapped, and the admin session revoked (redirect to/login).
- POST
- The error path must still surface
SYSTEM_RESTORE_VALIDATION_FAILEDfor malformed JSON bodies.