12 KiB
12 KiB
Implementation Plan: Job 903 — Admin Challenges Import must Replace, Modal must Auto-Close
1. Architectural Reconnaissance
-
Codebase style & conventions:
- Backend: NestJS (TypeScript,
strict, class-basedInjectableservices, TypeORM withbetter-sqlite3, async/await, controllers delegating to services). - Frontend: Angular 20 standalone components with
ChangeDetectionStrategy.OnPush, signalinput()/output(),@if/@forcontrol flow, RxJS only for the search debounce, smart/dumb split. Pure helpers live in*.pure.tsand are tested directly without a TestBed. - Tests: Jest + ts-jest configured in
tests/jest.config.jsas two projects (backend,frontend). Backend specs useTest.createTestingModulewith in-memory SQLite and a uniquemkdtempSyncupload dir per file (no shared FS races). Frontend specs intests/frontend/import pure helpers directly (no DOM) — the only TestBed specs are for components that must exercise@Inputbindings. npm testat the repo root runs all tests for all components via the Jest project selector.
- Backend: NestJS (TypeScript,
-
Data Layer:
- SQLite via TypeORM; relevant tables
challenge,challenge_file,category,solve. - File storage:
data/uploads/challenges/<uuid>for committed files,.staging/<uuid>for in-flight uploads. - Repository injection via
@InjectRepository(Entity); transactions viathis.dataSource.transaction(async (manager) => { ... }).
- SQLite via TypeORM; relevant tables
-
Test Framework & Structure:
- Backend tests in
/repo/tests/backend/*.spec.ts(Jest,testEnvironment: 'node'). - Frontend tests in
/repo/tests/frontend/*.spec.ts(Jest +jsdom); pure-helper specs do not need TestBed. - Single command:
npm test(already wired in rootpackage.json). - New specs to add: one backend spec for the replace-all-on-confirmed-import behavior, one frontend spec for the auto-close + list-refresh behavior of the import modal. Both must be runnable with
npm test.
- Backend tests in
-
Required Tools & Dependencies: No new tools or packages. Existing
better-sqlite3, TypeORM, NestJS, Angular, and Jest setup is sufficient.
2. Impacted Files
-
To Modify:
backend/src/modules/admin/challenges.service.ts— changeimportConfirmed()so it wipes every pre-existingchallenge(and cascade-deletes theirchallenge_file+solverows) before re-inserting from the document. Re-link the now-unreferenced disk files for unlink and unlink any orphaned file rows after the swap.frontend/src/app/features/admin/challenges/challenges.component.ts— after a successful confirmed import (onImportConfirm), callcloseImport()so the dialog disappears (it currently stays open showing the result message and a manual Close button).docs/guides/admin-challenges.md— clarify that Import with?confirm=overwritereplaces the entire challenge set (not just upserts by name). Update the "Importing the same document twice is idempotent" line to state the full-replace semantics.docs/architecture/key-files.md— update the one-line responsibility forchallenges.service.tsto mention the replace-on-confirmed-import contract.
-
To Create:
tests/backend/admin-challenges-import-replace.spec.ts— focused contract test asserting that an overwrite-confirmed import removes pre-existing challenges (including their files and solves) and leaves only the imported set; checks both DB and the on-diskchallenges/directory.tests/frontend/admin-challenges-import-autoclose.spec.ts— pure/smart test asserting that after a successfulonImportConfirm, the import modal closes (i.e.importOpen()flips tofalse) and the result is still surfaced via the page-level status banner.
3. Proposed Changes
3.1 Backend — AdminChallengesService.importConfirmed becomes a full replace
In backend/src/modules/admin/challenges.service.ts, refactor importConfirmed (currently lines 412–522) so the confirmed path:
- Stage every file up front (unchanged logic, lines 421–446). Keep the early
rollbackStagedon validation failure. - Inside one transaction (unchanged transaction wrapper, line 450):
a. Load every existing challenge row with
manager.getRepository(ChallengeEntity).find()(no filter). b. Capture each existing challenge'sChallengeFileEntityrows (find({ where: { challengeId: In(ids) } })) so we know whichstoredFilenames are still referenced after the transaction. c. Delete every existing challenge row with a singlemanager.delete(ChallengeEntity, {})— TypeORM cascades tochallenge_fileandsolverows via the existing FK cascade. This is the replace semantics. d. Then iteratedoc.challengesand (as today) skip entries whose category is unknown, otherwise create a new challenge row + new file rows pointing at the already-staged tokens. Result counts stay{ imported, skipped, warnings }. - After commit, clean up disk — for the captured pre-existing stored filenames, call
fileService.removeFinal(storedFilename)best-effort (same helper used byremove()). Newly-inserted challenges' files are then committed via the existingcommit(item.staged.token)loop (lines 506–517). - Do not break the preview path.
previewImportkeeps the existing conflict/unknown-category logic; the destructive replace only happens when?confirm=overwrite(controller pathadmin-challenges.controller.ts:87-92). - No changes to DTOs or to the export shape.
exportAll()already produces the versioned envelope that the new behavior requires for round-tripping.
Edge cases to handle explicitly:
- Empty
doc.challenges⇒ result{ imported: 0, skipped: [], warnings: [] }; existing rows are wiped, files unlinked, no new rows created. - Unknown-category rows ⇒ counted in
skipped; do not preserve them, do not abort the wipe. The wipe happens before the per-challenge inserts. - Disk-unlink failure for a removed file ⇒ swallow + log via
ChallengeFilesService.removeFinal(matches existing delete behavior, never throws).
3.2 Frontend — AdminChallengesComponent.onImportConfirm closes the modal
In frontend/src/app/features/admin/challenges/challenges.component.ts:
- In
onImportConfirm()(lines 455–470), afterawait this.load()succeeds andimportResultis set, additionally callcloseImport().closeImport()already clearsimportDoc,importPreview,importResult, andimportError(lines 447–453) and resetsimportOpentofalse. - The page-level status banner is already populated by
this.statusMessage.set(summarizeImportResult(result))immediately beforecloseImport(), so the user still sees the "Imported N challenges" message after the dialog closes. - The
<app-challenge-import-modal>template already renders a "Close" button whileresult()is set; that branch becomes unreachable after the fix (the modal will close before the user can click it), so no template change is required. The result branch is kept as a defensive fallback for any future flow that intentionally keeps the modal open (e.g. partial-failure display).
3.3 Docs — clarify the replace semantics
docs/guides/admin-challenges.md— under the "Import" section (line 103) replace the third bullet ("Click Overwrite & Import … the server upserts challenges inside one transaction by case-insensitive name") with: "Click Overwrite & Import → the client posts the same document with?confirm=overwrite. The server stages every file up front (rolling back any FS failure), then wipes every existing challenge (cascade-deleting theirchallenge_fileandsolverows and best-effort unlinking their disk files) and inserts the archive's challenges in one transaction."- Update the last "Notes" line on idempotency (line 138) to: "Importing the same document twice is idempotent: every existing challenge (matching or not) is replaced by the archive contents."
docs/architecture/key-files.md— update thechallenges.service.tsresponsibility to "... CRUD, list aggregates, transactional deletion, export, full-replace import orchestration."
4. Test Strategy
4.1 Backend contract test — tests/backend/admin-challenges-import-replace.spec.ts
- Mirror the wiring of
tests/backend/admin-challenges-import-export.spec.ts::memory:SQLite, uniquemkdtempSync(os.tmpdir() + '/hipctf-challenges-import-replace-…')UPLOAD_DIR, all migrations includingUpgradeChallengeAdminSchema1700000000500, TypeOrmModule.forFeature forCategoryEntity,ChallengeEntity,ChallengeFileEntity,SolveEntity. - AAA structure. Mocking strategy: no external boundaries — use real in-memory SQLite + real
ChallengeFilesServicewriting to the temp dir. Isolation: per-file temp dir (no parallel FS races). - Tests (minimal, one logical assertion each):
- Replace wipes unrelated challenges — Seed
Alpha(CRY, NC, port=1337, flag='flag{a}', with one staged file committed) andBravo(CRY, WEB). CallimportConfirmedwith a single-challenge doc{ name: 'Imported Charlie', … categorySystemKey: 'CRY', … }. Assertlist({}).map(r => r.name)is exactly['Imported Charlie']and thatchRepo.count()equals 1. - Disk files of wiped challenges are unlinked — Capture the stored filename of Alpha's committed file via
fileRepo.findOne({ where: { challengeId: alpha.id } }). After the import, assertfs.existsSync(path.join(finalDir, alphaStoredName))isfalse, while the new challenge's stored file exists. - Empty archive wipes everything — Re-seed Alpha, then
importConfirmedwith{ challenges: [] }. AssertchRepo.count() === 0and the previously-committed disk file is unlinked. - Unknown-category rows are skipped, not preserved — Seed
Keep(WEB). Import a doc containing one unknown-category challenge plus nothing else. Assertresult.skippedhas length 1 andlist({})is empty (the wipe still happened).
- Replace wipes unrelated challenges — Seed
4.2 Frontend modal test — tests/frontend/admin-challenges-import-autoclose.spec.ts
- Smart container test using
TestBed.createComponent(AdminChallengesComponent)to exercise the live handler. - Mock
AdminService(jest.fn()forpreviewChallengeImport,confirmChallengeImport,listChallenges,listCategories,exportChallenges) and stub the documentcreateElement('input')file-picker flow by settingimportDoc/importPreviewdirectly via a small helper (or by calling the publicopenImport()+ injecting the file via a fake<input>ref). Keep it minimal: directly drive the component signals is preferable to mocking the file picker. - AAA structure. Mocking strategy: only
AdminService(network boundary). No DOM snapshotting; we assert on signal values (OnPush friendly) and DOM text viafixture.nativeElement.textContentif needed. - Tests:
onImportConfirmcloses the modal after success — arrange:component.importOpen.set(true),component.importDoc.set({ … }),component.importPreview.set({ total: 1, conflicts: [], unknownCategories: [], warnings: [] }). Stubadmin.confirmChallengeImportto resolve{ imported: 1, skipped: [], warnings: [] }andadmin.listChallengesto resolve[]. Act:await component.onImportConfirm(). Assertcomponent.importOpen()isfalse,component.importResult()isnull(cleared bycloseImport), andcomponent.statusMessage()contains "Imported 1".onImportConfirmkeeps the modal open when the network call rejects — arrange same, stubconfirmChallengeImportto reject with a generic error. Act + assertcomponent.importOpen()staystrueandcomponent.importError()is non-null.
4.3 Run command
- From the repo root:
npm test(ornpm run test:backend/npm run test:frontendfor individual projects). Both new specs run inside the existingts-jestconfiguration with no additional setup.