AI Implementation feature(903): Admin Area Challenges: List, Import/Export and Add/Edit Modal 1.03 (#44)

This commit was merged in pull request #44.
This commit is contained in:
2026-07-22 22:18:10 +00:00
parent fff0a600df
commit dcda3dfd4d
11 changed files with 583 additions and 112 deletions
-93
View File
@@ -1,93 +0,0 @@
# Implementation Plan: Admin Add/Edit Challenge — Category Validation Bug Fix (Job 902)
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Monorepo using npm workspaces (`backend`, `frontend`). TypeScript everywhere.
- Backend: **NestJS 10** with TypeORM, SQLite (better-sqlite3), class-validator-style validation through custom `ZodValidationPipe`.
- Frontend: **Angular 17** standalone components with `OnPush` change detection, Angular Signals + `effect()`, typed reactive forms (`FormBuilder.nonNullable`), RxJS for streams.
- Stylistic choices: pure helper functions live in `*.pure.ts` next to the components; component classes only orchestrate state and templates.
- Error envelopes use stable codes (`VALIDATION_FAILED`, `CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`) returned from `ApiError.badRequest(...)` with a `details[]` of `{ path, message }`.
- **Data Layer:** SQLite via TypeORM (`better-sqlite3`). `challenge.category_id` is `TEXT NOT NULL` with `ON DELETE RESTRICT` FK to `category.id`. The `category.id` column is TEXT UUID v4 (see `database/challenges.md`).
- **Test Framework & Structure:**
- **Jest 29** with `ts-jest`. Multi-project config in `tests/jest.config.js` with two projects: `backend` (node env) and `frontend` (jsdom env).
- Frontend tests live in `tests/frontend/*.spec.ts`, backend tests in `tests/backend/*.spec.ts`. Tests NEVER live next to source.
- Single command from the repo root: `npm test` (or `npm run test:backend` / `npm run test:frontend`).
- Frontend tests focus on logic / pure functions and DOM-free component behavior (no visual confirmation). `tests/frontend/admin-challenges-form.spec.ts` already exercises `validateChallengeForm`, `buildChallengeFormGroup`, `syncChallengeForm`, etc.
- **Required Tools & Dependencies:** No new tools or dependencies are required for this fix. Zod 3 is already a backend dep; UUID validation can use the standard `z.string().uuid()`. No new npm packages needed. `setup.sh` requires no changes.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` — add a dedicated early-return guard for empty `categoryId` and a UUID-format validator on the control; tighten `onOk()`.
- `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` — add a `categoryUuidPattern` constant and a `CATEGORY_ID_REQUIRED` helper constant; tighten `validateChallengeForm` to flag any non-UUID value as a category error (defense in depth).
- `backend/src/modules/admin/dto/challenges.dto.ts` — change `categoryId` in `baseShape` from `z.string().min(1).max(64)` to `z.string().uuid()` so an empty string, whitespace, or non-UUID payload is rejected at the validation pipe with a `400 VALIDATION_FAILED` and a `categoryId` detail.
- **To Create:**
- `tests/frontend/admin-challenges-form-job902.spec.ts` — targeted unit tests covering the new client-side guards.
- `tests/backend/admin-challenges-create-category-job902.spec.ts` — targeted integration test covering the new server-side Zod rejection of empty/non-UUID `categoryId` on `POST /api/v1/admin/challenges`.
## 3. Proposed Changes
### 1. Database / Schema Migration
No schema migration. The `category_id` column already requires a valid existing row (FK + service-level `assertCategoryExists`). The fix is purely validation tightening on the wire format. The `RepairCategorySchemaAndSystemCategories1700000000400` and `UpgradeChallengeAdminSchema1700000000500` migrations remain untouched.
### 2. Backend Logic & APIs (`backend/src/modules/admin/dto/challenges.dto.ts`)
- Update `baseShape.categoryId` from `z.string().min(1).max(64)` to `z.string().uuid({ message: 'Category is required' })`.
- This single change makes the backend reject:
- Empty string (`""`) → caught at the Zod pipe → `400 VALIDATION_FAILED` with `details: [{ path: 'categoryId', message: 'Category is required' }]`.
- Whitespace-only → still rejected by `z.string().uuid()` (UUIDs cannot contain whitespace).
- Any non-UUID string (including stray placeholders) → rejected.
- `CategoryIdParamSchema` for `GET/PUT/DELETE /:id` already uses `z.string().min(1).max(64)`; for consistency, also tighten it to `z.string().uuid()` so that a missing/invalid id route produces a uniform 400.
- The `ImportChallengeSchema` (used by import payloads) already passes a `categorySystemKey`, not `categoryId`, so it is unaffected.
- `mapServerFieldErrors` in `challenge-form.pure.ts` already maps `path === 'categoryId'` to the per-field error, so the inline `cf-error-category` will render automatically once the backend returns a `categoryId` detail.
### 3. Frontend UI Integration (`frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts`)
- In `buildChallengeFormGroup`, replace the bare `Validators.required` on `categoryId` with a stronger validator set that:
1. Rejects empty strings (`Validators.required`).
2. Enforces UUID v4 format using a small inline regex (e.g. `/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`) wrapped in a named validator error key (`uuid`) so `showError('categoryId')` can render a meaningful message.
- In `onOk()`:
- Read `categoryId = this.form.controls.categoryId.value` BEFORE calling `validateChallengeForm`.
- Add an explicit defensive guard that returns immediately when the trimmed value is empty or does not match the UUID pattern, sets `clientFieldErrors = { categoryId: 'Category is required' }`, marks the control touched/dirty, switches the active tab to `'general'` via `firstErrorTab`, and does NOT call `submit.emit(...)`. This is in addition to `validateChallengeForm` (which already catches the empty case) — it exists as a belt-and-suspenders measure so the bug cannot recur even if the pure helper is bypassed.
- Keep the existing template — `<option value="">Select a category…</option>` — and the existing `[data-testid="cf-category"]` selector untouched.
### 4. Frontend Pure Helper (`frontend/src/app/features/admin/challenges/challenge-form.pure.ts`)
- Export a new constant `CATEGORY_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i`.
- In `validateChallengeForm`, expand the category branch:
```ts
if (!values.categoryId) errs.categoryId = 'Category is required';
else if (!CATEGORY_ID_PATTERN.test(values.categoryId))
errs.categoryId = 'Category is required';
```
(Same message on purpose: do not leak internal format expectations to the user; both states mean "you have not selected a valid category".)
- Add `firstErrorTab` already returns `'general'` for `errors.categoryId` — no change needed there.
## 4. Test Strategy
- **Target Unit Test File (frontend):** `tests/frontend/admin-challenges-form-job902.spec.ts`
- Reuse the existing `makeDefaults()` helper pattern from `tests/frontend/admin-challenges-form.spec.ts`.
- Test 1 — `validateChallengeForm({ categoryId: '' })` produces `errs.categoryId === 'Category is required'`.
- Test 2 — `validateChallengeForm({ categoryId: ' ' })` produces the same error (whitespace string).
- Test 3 — `validateChallengeForm({ categoryId: 'not-a-uuid' })` produces the same error (defense-in-depth against non-UUID garbage).
- Test 4 — `validateChallengeForm({ categoryId: '<valid uuid>' })` does NOT produce a `categoryId` error.
- Test 5 — `buildChallengeFormGroup` applies a `uuid` validator that rejects `'abc'`, `'not-a-uuid'`, and `''` once the control is touched; accepts a canonical UUID v4.
- Test 6 — `firstErrorTab({ categoryId: '...' })` returns `'general'` (so the modal switches tabs to surface the error).
- **Target Integration Test File (backend):** `tests/backend/admin-challenges-create-category-job902.spec.ts`
- Follow the boot pattern from `tests/backend/admin-challenges-api.spec.ts` (register first admin, prime CSRF, login).
- Test 1 — `POST /api/v1/admin/challenges` with `categoryId: ''` and otherwise-valid body returns `400 VALIDATION_FAILED` with `details` containing a `categoryId` entry; no row is persisted (re-list and assert empty array).
- Test 2 — `POST /api/v1/admin/challenges` with `categoryId: 'not-a-uuid'` and otherwise-valid body returns `400 VALIDATION_FAILED` with a `categoryId` detail.
- Test 3 — `POST /api/v1/admin/challenges` with a valid UUID for `categoryId` (matching a seeded category) returns `201` and the row appears in the list.
- **Mocking Strategy:**
- **Frontend tests:** No HTTP/network mocking required — they exercise pure helpers and the in-memory `FormGroup` from `@angular/forms`. The existing `StubAdminService` pattern from `tests/frontend/admin-challenges-form.spec.ts` can be referenced if needed but is not required for the new tests above.
- **Backend tests:** Use the real `AppModule` with `process.env.DATABASE_PATH = ':memory:'`, real `GlobalExceptionFilter`, real `CsrfMiddleware`, and real admin login flow (same pattern as `tests/backend/admin-challenges-api.spec.ts`). Seed at least one category row through the existing `/api/v1/admin/categories` POST so the "valid UUID" path can succeed; rely on `category-repair-migration` to materialize the canonical system rows on fresh DBs.
- **Run command (single, root-level):**
```bash
npm test
```
This runs both projects (`backend` and `frontend`) via `tests/jest.config.js`. For narrower loops during development: `npm run test:backend` or `npm run test:frontend`.
+94
View File
@@ -0,0 +1,94 @@
# Implementation Plan: Job 903 — Admin Challenges Import must Replace, Modal must Auto-Close
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Backend: NestJS (TypeScript, `strict`, class-based `Injectable` services, TypeORM with `better-sqlite3`, async/await, controllers delegating to services).
- Frontend: Angular 20 standalone components with `ChangeDetectionStrategy.OnPush`, signal `input()` / `output()`, `@if`/`@for` control flow, RxJS only for the search debounce, smart/dumb split. Pure helpers live in `*.pure.ts` and are tested directly without a TestBed.
- Tests: Jest + ts-jest configured in `tests/jest.config.js` as two projects (`backend`, `frontend`). Backend specs use `Test.createTestingModule` with in-memory SQLite and a unique `mkdtempSync` upload dir per file (no shared FS races). Frontend specs in `tests/frontend/` import pure helpers directly (no DOM) — the only TestBed specs are for components that must exercise `@Input` bindings.
- `npm test` at the repo root runs **all** tests for **all** components via the Jest project selector.
- **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 via `this.dataSource.transaction(async (manager) => { ... })`.
- **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 root `package.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`.
- **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` — change `importConfirmed()` so it wipes every pre-existing `challenge` (and cascade-deletes their `challenge_file` + `solve` rows) 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`), call `closeImport()` 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=overwrite` replaces 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 for `challenges.service.ts` to 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-disk `challenges/` directory.
- `tests/frontend/admin-challenges-import-autoclose.spec.ts` — pure/smart test asserting that after a successful `onImportConfirm`, the import modal closes (i.e. `importOpen()` flips to `false`) 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 412522) so the confirmed path:
1. **Stage every file up front** (unchanged logic, lines 421446). Keep the early `rollbackStaged` on validation failure.
2. **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's `ChallengeFileEntity` rows (`find({ where: { challengeId: In(ids) } })`) so we know which `storedFilename`s are still referenced after the transaction.
c. Delete every existing challenge row with a single `manager.delete(ChallengeEntity, {})` — TypeORM cascades to `challenge_file` and `solve` rows via the existing FK cascade. This is the **replace** semantics.
d. Then iterate `doc.challenges` and (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 }`.
3. **After commit, clean up disk** — for the captured pre-existing stored filenames, call `fileService.removeFinal(storedFilename)` best-effort (same helper used by `remove()`). Newly-inserted challenges' files are then committed via the existing `commit(item.staged.token)` loop (lines 506517).
4. **Do not break the preview path.** `previewImport` keeps the existing conflict/unknown-category logic; the destructive replace only happens when `?confirm=overwrite` (controller path `admin-challenges.controller.ts:87-92`).
5. **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 455470), after `await this.load()` succeeds and `importResult` is set, additionally call `closeImport()`. `closeImport()` already clears `importDoc`, `importPreview`, `importResult`, and `importError` (lines 447453) and resets `importOpen` to `false`.
- The page-level status banner is already populated by `this.statusMessage.set(summarizeImportResult(result))` immediately before `closeImport()`, 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 while `result()` 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 their `challenge_file` and `solve` rows 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 the `challenges.service.ts` responsibility 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, unique `mkdtempSync(os.tmpdir() + '/hipctf-challenges-import-replace-…')` `UPLOAD_DIR`, all migrations including `UpgradeChallengeAdminSchema1700000000500`, TypeOrmModule.forFeature for `CategoryEntity`, `ChallengeEntity`, `ChallengeFileEntity`, `SolveEntity`.
- AAA structure. Mocking strategy: no external boundaries — use real in-memory SQLite + real `ChallengeFilesService` writing to the temp dir. Isolation: per-file temp dir (no parallel FS races).
- Tests (minimal, one logical assertion each):
1. **Replace wipes unrelated challenges** — Seed `Alpha` (CRY, NC, port=1337, flag='flag{a}', with one staged file committed) and `Bravo` (CRY, WEB). Call `importConfirmed` with a single-challenge doc `{ name: 'Imported Charlie', … categorySystemKey: 'CRY', … }`. Assert `list({}).map(r => r.name)` is exactly `['Imported Charlie']` and that `chRepo.count()` equals 1.
2. **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, assert `fs.existsSync(path.join(finalDir, alphaStoredName))` is `false`, while the new challenge's stored file exists.
3. **Empty archive wipes everything** — Re-seed Alpha, then `importConfirmed` with `{ challenges: [] }`. Assert `chRepo.count() === 0` and the previously-committed disk file is unlinked.
4. **Unknown-category rows are skipped, not preserved** — Seed `Keep` (WEB). Import a doc containing one unknown-category challenge plus nothing else. Assert `result.skipped` has length 1 and `list({})` is empty (the wipe still happened).
### 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()` for `previewChallengeImport`, `confirmChallengeImport`, `listChallenges`, `listCategories`, `exportChallenges`) and stub the document `createElement('input')` file-picker flow by setting `importDoc`/`importPreview` directly via a small helper (or by calling the public `openImport()` + 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 via `fixture.nativeElement.textContent` if needed.
- Tests:
1. **`onImportConfirm` closes the modal after success** — arrange: `component.importOpen.set(true)`, `component.importDoc.set({ … })`, `component.importPreview.set({ total: 1, conflicts: [], unknownCategories: [], warnings: [] })`. Stub `admin.confirmChallengeImport` to resolve `{ imported: 1, skipped: [], warnings: [] }` and `admin.listChallenges` to resolve `[]`. Act: `await component.onImportConfirm()`. Assert `component.importOpen()` is `false`, `component.importResult()` is `null` (cleared by `closeImport`), and `component.statusMessage()` contains "Imported 1".
2. **`onImportConfirm` keeps the modal open when the network call rejects** — arrange same, stub `confirmChallengeImport` to reject with a generic error. Act + assert `component.importOpen()` stays `true` and `component.importError()` is non-null.
### 4.3 Run command
- From the repo root: `npm test` (or `npm run test:backend` / `npm run test:frontend` for individual projects). Both new specs run inside the existing `ts-jest` configuration with no additional setup.
@@ -446,24 +446,29 @@ export class AdminChallengesService {
} }
const result: ImportResult = { imported: 0, skipped: [], warnings: [] }; const result: ImportResult = { imported: 0, skipped: [], warnings: [] };
let orphanedStoredFilenames: string[] = [];
try { try {
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const challengeRepo = manager.getRepository(ChallengeEntity); const challengeRepo = manager.getRepository(ChallengeEntity);
const fileRepo = manager.getRepository(ChallengeFileEntity); const fileRepo = manager.getRepository(ChallengeFileEntity);
// Full-replace: capture every existing challenge and its disk-bound
// files so we can unlink them after the wipe, then delete every row.
// ChallengeFileEntity and SolveEntity both cascade on challenge delete.
const existingChallenges = await challengeRepo.find();
const existingIds = existingChallenges.map((c) => c.id);
if (existingIds.length > 0) {
const existingFiles = await fileRepo.find({ where: { challengeId: In(existingIds) } });
orphanedStoredFilenames = existingFiles.map((f) => f.storedFilename);
await manager.delete(ChallengeEntity, { id: In(existingIds) });
}
for (const ch of doc.challenges) { for (const ch of doc.challenges) {
const cat = catMap.get(ch.categorySystemKey.toLowerCase()); const cat = catMap.get(ch.categorySystemKey.toLowerCase());
if (!cat) { if (!cat) {
result.skipped.push({ name: ch.name, reason: `Unknown category: ${ch.categorySystemKey}` }); result.skipped.push({ name: ch.name, reason: `Unknown category: ${ch.categorySystemKey}` });
continue; continue;
} }
// Upsert by case-insensitive name.
const existing = await challengeRepo
.createQueryBuilder('c')
.where('LOWER(c.name) = :n', { n: ch.name.toLowerCase() })
.getOne();
if (existing) {
await manager.delete(ChallengeEntity, { id: existing.id });
}
const row = challengeRepo.create({ const row = challengeRepo.create({
id: uuid(), id: uuid(),
name: ch.name, name: ch.name,
@@ -501,6 +506,11 @@ export class AdminChallengesService {
throw err; throw err;
} }
// Best-effort unlink of files owned by wiped challenges (post-commit).
for (const storedFilename of orphanedStoredFilenames) {
this.fileService.removeFinal(storedFilename);
}
// Commit each staged file by renaming to its final filename (the // Commit each staged file by renaming to its final filename (the
// ChallengeFile rows already reference those names). // ChallengeFile rows already reference those names).
for (const list of stagedByName.values()) { for (const list of stagedByName.values()) {
+5 -3
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source and contract-test files, including strict event-window validation and the public bootstrap SSE listener. description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, and the challenges full-replace import flow.
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations] tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import]
timestamp: 2026-07-22T19:18:00Z timestamp: 2026-07-22T22:17:11Z
--- ---
# Backend # Backend
@@ -30,6 +30,7 @@ timestamp: 2026-07-22T19:18:00Z
| `backend/src/modules/admin/dto/general.dto.ts` | Zod contract for `PUT /api/v1/admin/general/settings`; both event timestamps are required ISO-8601 values and end must be strictly after start. | | `backend/src/modules/admin/dto/general.dto.ts` | Zod contract for `PUT /api/v1/admin/general/settings`; both event timestamps are required ISO-8601 values and end must be strictly after start. |
| `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. | | `tests/backend/admin-general-event-window.spec.ts` | Focused contract tests for valid, empty, malformed, equal, and reversed event-window timestamps. |
| `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. | | `tests/backend/admin-general-service.spec.ts` | General-settings schema and service tests, including per-field empty datetime failures and settings-event emission. |
| `tests/backend/admin-challenges-import-replace.spec.ts` | Contract tests for the confirmed-import full-replace: wipes unrelated pre-existing challenges, unlinks wiped disk files, empty archive, unknown-category skip-then-wipe. |
# Frontend # Frontend
@@ -63,6 +64,7 @@ timestamp: 2026-07-22T19:18:00Z
| `tests/frontend/admin-categories-form-modal.spec.ts` | Tests the pure `syncCategoryForm` helper that drives the edit/create prefill in `CategoryFormModalComponent`: system-row abbreviation lock, user-row unlock, re-population on second invocation, clearing on create, and iconPreview passthrough. | | `tests/frontend/admin-categories-form-modal.spec.ts` | Tests the pure `syncCategoryForm` helper that drives the edit/create prefill in `CategoryFormModalComponent`: system-row abbreviation lock, user-row unlock, re-population on second invocation, clearing on create, and iconPreview passthrough. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
# See also # See also
+95
View File
@@ -0,0 +1,95 @@
---
type: guide
title: Admin — Challenges Full-Replace Import
description: How the confirmed challenges import wipes every existing challenge (with cascading files/solves and disk unlink) and inserts the archive contents in one transaction.
tags: [guide, admin, challenges, import, full-replace, tester]
timestamp: 2026-07-22T22:17:11Z
---
# What it is
The **confirmed challenges import** is the second step of the import flow
on `/admin/challenges` (after the preview). When the admin clicks
**Overwrite & Import** the client POSTs the already-validated archive to
`POST /api/v1/admin/challenges/import?confirm=overwrite`. The server
performs a **full replace** of the challenge set inside one DB
transaction, then best-effort unlinks the disk files owned by the
wiped challenges.
# When to use it
Use this when an admin wants to restore the platform challenge set to
the exact contents of a previously exported JSON archive. The user
guide for the surrounding flow lives at [Admin —
Challenges](/guides/admin-challenges.md); this concept focuses on the
import-specific behavior and its consequences.
# Behavior contract
| Phase | What happens |
|-------|--------------|
| Pre-flight | The client already validated the archive against the `IMPORT_VALIDATION_FAILED` Zod schema in [Admin Challenges DTOs](/api/admin.md#challenges). The server re-validates; preview counts (`total`, `conflicts`, `unknownCategories`, `warnings`) are surfaced by the import modal. |
| Staging | Every `dataBase64` file in the archive is decoded and staged to disk via `ChallengeFilesService.stage(...)`. If any stage fails the server rolls back every previously staged file and re-throws (mirrors the existing `rollbackStaged` safety net). |
| Transaction (DB) | The server begins a single TypeORM transaction. It loads every existing challenge, captures the disk-bound `stored_filename`s of every existing `ChallengeFile`, then `DELETE`s all existing `ChallengeEntity` rows by id. `ChallengeFileEntity` and `SolveEntity` cascade-delete via FK, so dependent rows disappear too. The archive's challenges are then inserted under their original names. |
| Post-commit (FS) | For every captured `storedFilename` the server calls `ChallengeFilesService.removeFinal(storedFilename)` (best-effort; failures are logged but do not abort). Each staged file for the imported set is committed to its final filename. |
| Response | Returns `{ imported, skipped, warnings }`. Known unknown `categorySystemKey`s are reported in `skipped[]` (still wipe everything else). |
# Side effects to expect as a tester
- **Every existing challenge disappears** from the list immediately
after the success banner — including rows whose names did not appear
in the archive. Use the Players page to confirm that pre-existing
`solve` rows for wiped challenges also vanish (they cascade).
- **Uploaded challenge files** owned by wiped rows are removed from
`./data/uploads/challenges/` post-commit. The imported archive's
files appear under the same directory using their original
filenames encoded onto the wipe.
- An empty archive (`{ challenges: [] }`) is valid and produces
`imported: 0`, an empty list, and zero on-disk challenge files.
- The **import modal auto-closes on success**; the page-level status
banner (`[data-testid="challenges-status"]`) shows the
imported/skipped/warning summary. Failures keep the modal open and
show the error inline.
# Idempotency
- Re-importing the same archive twice yields the same final DB state.
- The previous upsert-on-name semantics are gone: even challenges
whose names did not appear in the archive are removed.
# Error codes
See [Admin Endpoints](/api/admin.md) and the [Admin —
Challenges](/guides/admin-challenges.md) guide:
- `IMPORT_VALIDATION_FAILED` (400) — schema failure on preview or
confirm.
- `IMPORT_PARTIAL_FAILURE` is no longer emitted: the response is
always 200 with `{ imported, skipped, warnings }` (consistent with
partial success being the normal case when unknown categories are
present).
- Any DB transaction failure rolls back every staged file and
re-throws the original error, leaving the DB untouched.
# Wiring
| Concern | File |
|---------|------|
| Endpoint | `backend/src/modules/admin/admin-challenges.controller.ts` (`POST /api/v1/admin/challenges/import?confirm=overwrite`). |
| Orchestration | `backend/src/modules/admin/challenges.service.ts` (`importConfirmed`) — full-replace transaction + post-commit unlink. |
| File staging and disk ops | `backend/src/modules/admin/challenge-files.service.ts` (`stage`, `commit`, `removeFinal`). |
| Schema | `backend/src/modules/admin/dto/challenges.dto.ts` (`CHALLENGE_FORMAT`, `CHALLENGE_FORMAT_VERSION`, import document schema). |
| Client UI | `frontend/src/app/features/admin/challenges/challenges.component.ts` (`onImportConfirm`) — refreshes list, sets status, calls `closeImport()`. |
| Pure helpers | `frontend/src/app/features/admin/challenges/challenges.pure.ts` (`summarizeImportResult`, `importErrorMessage`). |
| Backend contract tests | `tests/backend/admin-challenges-import-replace.spec.ts` — full-replace, disk-file unlink, empty archive, unknown-category wipe. |
| Frontend contract tests | `tests/frontend/admin-challenges-import-autoclose.spec.ts` — pure helpers + modal auto-close branch. |
# Notes
- The flag is not in the archive's per-challenge `data`; only admins
can read the plaintext, and the export/import preserves the same
restriction.
- Wiping cascades remove `solve` rows for wiped challenges — the
scoreboard will recompute on the next render without those entries.
- The destroy-then-insert happens in one transaction, so a partial
failure cannot leave the platform with a mix of old and new rows.
+7 -5
View File
@@ -3,7 +3,7 @@ type: guide
title: Admin — Challenges title: Admin — Challenges
description: How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page. description: How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page.
tags: [guide, admin, challenges, import, export, files, tester] tags: [guide, admin, challenges, import, export, files, tester]
timestamp: 2026-07-22T21:25:29Z timestamp: 2026-07-22T22:17:11Z
--- ---
# When this view is available # When this view is available
@@ -104,8 +104,10 @@ The form modal has three sections (tabs): General, Connection, Files. Fields mar
1. Click **Import challenges** → a file picker accepts `.json`. The client reads it and posts it to `POST /api/v1/admin/challenges/import`. 1. Click **Import challenges** → a file picker accepts `.json`. The client reads it and posts it to `POST /api/v1/admin/challenges/import`.
2. On validation success the import modal opens with the preview (`total`, `conflicts`, `unknownCategories`, `warnings`) and the standard copy: *"Import N challenges. Existing challenges with matching names will be overwritten. New categories referenced but missing locally will be skipped with a warning. Continue?"* 2. On validation success the import modal opens with the preview (`total`, `conflicts`, `unknownCategories`, `warnings`) and the standard copy: *"Import N challenges. Existing challenges with matching names will be overwritten. New categories referenced but missing locally will be skipped with a warning. Continue?"*
3. 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 upserts challenges inside one transaction by case-insensitive name. 3. 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 their `challenge_file` and `solve` rows and best-effort unlinking their disk files) and inserts the archive's challenges** inside one transaction.
4. The response renders imported/skipped/warning counts; the list refreshes automatically. 4. The modal auto-closes on success and the list refreshes automatically; the page-level status banner shows imported/skipped/warning counts.
See [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) for the full transactional semantics, side effects, and the auto-close contract.
# Validation & error codes (server) # Validation & error codes (server)
@@ -127,7 +129,7 @@ The form modal has three sections (tabs): General, Connection, Files. Fields mar
| 3 | `frontend/src/app/core/services/admin.service.ts` | Typed `listChallenges`, `getChallengeDetail`, `createChallenge`, `updateChallenge`, `deleteChallenge`, `exportChallenges`, `previewChallengeImport`, `confirmChallengeImport`, `stageChallengeFile`, `discardChallengeFile`. | | 3 | `frontend/src/app/core/services/admin.service.ts` | Typed `listChallenges`, `getChallengeDetail`, `createChallenge`, `updateChallenge`, `deleteChallenge`, `exportChallenges`, `previewChallengeImport`, `confirmChallengeImport`, `stageChallengeFile`, `discardChallengeFile`. |
| 4 | `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` | Defaults/prefill, points/port validation, first-invalid-tab selection, file-size partitioning, payload mapping. | | 4 | `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` | Defaults/prefill, points/port validation, first-invalid-tab selection, file-size partitioning, payload mapping. |
| 5 | `backend/src/modules/admin/admin-challenges.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. | | 5 | `backend/src/modules/admin/admin-challenges.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 6 | `backend/src/modules/admin/challenges.service.ts` | CRUD, list aggregates, transactional deletion, export, import orchestration. | | 6 | `backend/src/modules/admin/challenges.service.ts` | CRUD, list aggregates, transactional deletion, export, full-replace import orchestration. |
| 7 | `backend/src/modules/admin/challenge-files.service.ts` | Staging, commit, discard, read for export, limits. | | 7 | `backend/src/modules/admin/challenge-files.service.ts` | Staging, commit, discard, read for export, limits. |
| 8 | `backend/src/modules/admin/dto/challenges.dto.ts` | Zod schemas for IDs, list query, create/update, staged-file manifest, import document with field paths. | | 8 | `backend/src/modules/admin/dto/challenges.dto.ts` | Zod schemas for IDs, list query, create/update, staged-file manifest, import document with field paths. |
@@ -136,4 +138,4 @@ The form modal has three sections (tabs): General, Connection, Files. Fields mar
* The flag is never present in any non-admin DTO. The player-facing challenges route and scoreboard remain unchanged; only admin endpoints can fetch the plaintext. * The flag is never present in any non-admin DTO. The player-facing challenges route and scoreboard remain unchanged; only admin endpoints can fetch the plaintext.
* All mutating endpoints require the standard CSRF cookie + header pair; the existing `csrfInterceptor` handles the headers automatically. * All mutating endpoints require the standard CSRF cookie + header pair; the existing `csrfInterceptor` handles the headers automatically.
* The list query enforces a deterministic `LOWER(name), name` order so the UI sort is reproducible. * The list query enforces a deterministic `LOWER(name), name` order so the UI sort is reproducible.
* Importing the same document twice is idempotent: every incoming challenge either replaces an existing row by lowercased name or inserts a new one. * Importing the same document twice is idempotent: every existing challenge (matching or not) is replaced by the archive contents.
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T21:25:29Z. they need. Last regenerated 2026-07-22T22:17:11Z.
# Architecture # Architecture
@@ -74,6 +74,9 @@ they need. Last regenerated 2026-07-22T21:25:29Z.
* [Admin — Challenges](/guides/admin-challenges.md) - How an admin lists, * [Admin — Challenges](/guides/admin-challenges.md) - How an admin lists,
searches, creates, edits, deletes, imports, and exports challenges at searches, creates, edits, deletes, imports, and exports challenges at
`/admin/challenges`, including file staging and overwrite confirm. `/admin/challenges`, including file staging and overwrite confirm.
* [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) -
Transactional full-replace semantics, side effects, and the auto-close
contract of the confirmed challenges import on `/admin/challenges`.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page. every signed-in page.
@@ -25,6 +25,7 @@ import {
categoryIconSrc, categoryIconSrc,
deriveEmptyState, deriveEmptyState,
difficultyClass, difficultyClass,
importErrorMessage,
summarizeImportPreview, summarizeImportPreview,
summarizeImportResult, summarizeImportResult,
} from './challenges.pure'; } from './challenges.pure';
@@ -459,11 +460,11 @@ export class AdminChallengesComponent implements OnInit, OnDestroy {
this.importError.set(null); this.importError.set(null);
try { try {
const result = await this.admin.confirmChallengeImport(doc); const result = await this.admin.confirmChallengeImport(doc);
this.importResult.set(result);
this.statusMessage.set(summarizeImportResult(result)); this.statusMessage.set(summarizeImportResult(result));
await this.load(); await this.load();
this.closeImport();
} catch (e: any) { } catch (e: any) {
this.importError.set(e?.error?.message ?? e?.message ?? 'Failed to import challenges'); this.importError.set(importErrorMessage(e, 'Failed to import challenges'));
} finally { } finally {
this.importing.set(false); this.importing.set(false);
} }
@@ -61,6 +61,16 @@ export function summarizeImportResult(r: ImportResultResponse): string {
return lines.join(' '); return lines.join(' ');
} }
export function importErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object') {
const e = err as { error?: { message?: string }; message?: string };
if (e.error?.message) return e.error.message;
if (typeof e.message === 'string' && e.message.length > 0) return e.message;
}
return fallback;
}
export function difficultyClass(difficulty: 'LOW' | 'MEDIUM' | 'HIGH'): string { export function difficultyClass(difficulty: 'LOW' | 'MEDIUM' | 'HIGH'): string {
return `difficulty-badge difficulty-${difficulty.toLowerCase()}`; return `difficulty-badge difficulty-${difficulty.toLowerCase()}`;
} }
@@ -0,0 +1,249 @@
process.env.DATABASE_PATH = ':memory:';
process.env.THEMES_DIR = './themes';
process.env.FRONTEND_DIST = './frontend/dist';
process.env.UPLOAD_SIZE_LIMIT = '5mb';
process.env.NODE_ENV = 'test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm';
import { validateEnv } from '../../backend/src/config/env.schema';
import { AdminChallengesService } from '../../backend/src/modules/admin/challenges.service';
import { ChallengeFilesService } from '../../backend/src/modules/admin/challenge-files.service';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { ChallengeFileEntity } from '../../backend/src/database/entities/challenge-file.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { CHALLENGE_FORMAT, CHALLENGE_FORMAT_VERSION } from '../../backend/src/modules/admin/dto/challenges.dto';
describe('Job 903: AdminChallengesService - confirmed import replaces the full challenge set', () => {
let service: AdminChallengesService;
let fileService: ChallengeFilesService;
let catRepo: Repository<CategoryEntity>;
let chRepo: Repository<ChallengeEntity>;
let fileRepo: Repository<ChallengeFileEntity>;
let cryCategoryId: string;
let uploadDir: string;
let finalDir: string;
beforeAll(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-challenges-import-replace-'));
process.env.UPLOAD_DIR = uploadDir;
finalDir = path.join(uploadDir, 'challenges');
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
TypeOrmModule.forRoot({
type: 'better-sqlite3',
database: ':memory:',
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity],
migrations: [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
}),
TypeOrmModule.forFeature([CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity]),
],
providers: [AdminChallengesService, ChallengeFilesService],
}).compile();
moduleRef.init();
service = moduleRef.get(AdminChallengesService);
fileService = moduleRef.get(ChallengeFilesService);
catRepo = moduleRef.get<Repository<CategoryEntity>>(getRepositoryToken(CategoryEntity));
chRepo = moduleRef.get<Repository<ChallengeEntity>>(getRepositoryToken(ChallengeEntity));
fileRepo = moduleRef.get<Repository<ChallengeFileEntity>>(getRepositoryToken(ChallengeFileEntity));
const cry = await catRepo.findOne({ where: { systemKey: 'CRY' } });
if (!cry) throw new Error('expected seeded CRY category');
cryCategoryId = cry.id;
});
afterAll(async () => {
if (uploadDir && fs.existsSync(uploadDir)) {
try {
fs.rmSync(uploadDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
beforeEach(async () => {
// Clear out any challenges left over from a previous test so the name
// uniqueness constraint does not fire.
const all = await service.list({});
for (const row of all) await service.remove(row.id);
});
async function seedChallenge(name: string): Promise<{ id: string; storedFilename: string | null }> {
const staged = fileService.stage(Buffer.from(name + '-bytes'), `${name}.txt`, 'text/plain');
const created = await service.create({
name,
descriptionMd: 'd',
categoryId: cryCategoryId,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: `flag{${name}}`,
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
stagedToken: staged.token,
originalFilename: staged.originalFilename,
mimeType: staged.mimeType,
sizeBytes: staged.sizeBytes,
},
],
} as any);
const fileRow = await fileRepo.findOne({ where: { challengeId: created.id } });
return { id: created.id, storedFilename: fileRow ? fileRow.storedFilename : null };
}
function buildDoc(challenges: any[]): any {
return {
format: CHALLENGE_FORMAT,
version: CHALLENGE_FORMAT_VERSION,
exportedAt: '2026-07-22T00:00:00Z',
challenges,
};
}
it('confirmed import wipes unrelated pre-existing challenges', async () => {
await seedChallenge('WipeAlpha');
await seedChallenge('WipeBravo');
const result = await service.importConfirmed(
buildDoc([
{
name: 'ImportedCharlie',
descriptionMd: 'fresh',
categorySystemKey: 'CRY',
difficulty: 'HIGH',
initialPoints: 250,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{charlie}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
]) as any,
);
expect(result.imported).toBe(1);
expect(result.skipped).toEqual([]);
const names = (await service.list({})).map((r) => r.name).sort();
expect(names).toEqual(['ImportedCharlie']);
expect(await chRepo.count()).toBe(1);
});
it('unlinks the disk files owned by wiped challenges and keeps the imported file', async () => {
const { storedFilename: alphaStored } = await seedChallenge('DiskAlpha');
await service.importConfirmed(
buildDoc([
{
name: 'ImportedDelta',
descriptionMd: 'd',
categorySystemKey: 'CRY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{delta}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [
{
originalFilename: 'delta.txt',
mimeType: 'text/plain',
sizeBytes: Buffer.byteLength('delta-bytes'),
dataBase64: Buffer.from('delta-bytes').toString('base64'),
},
],
},
]) as any,
);
expect(alphaStored).toBeDefined();
expect(fs.existsSync(path.join(finalDir, alphaStored!))).toBe(false);
const deltaRow = (await service.list({})).find((r) => r.name === 'ImportedDelta');
expect(deltaRow).toBeDefined();
const deltaFiles = await fileRepo.find({ where: { challengeId: deltaRow!.id } });
expect(deltaFiles).toHaveLength(1);
expect(fs.existsSync(path.join(finalDir, deltaFiles[0].storedFilename))).toBe(true);
});
it('empty archive wipes everything and leaves no challenges', async () => {
const { storedFilename: alphaStored } = await seedChallenge('EmptyAlpha');
const result = await service.importConfirmed(buildDoc([]) as any);
expect(result.imported).toBe(0);
expect(result.skipped).toEqual([]);
expect(await chRepo.count()).toBe(0);
expect(await fileRepo.count()).toBe(0);
expect(alphaStored).toBeDefined();
expect(fs.existsSync(path.join(finalDir, alphaStored!))).toBe(false);
});
it('unknown-category rows are reported as skipped but still wipe everything else', async () => {
await seedChallenge('SkipAlpha');
await seedChallenge('SkipBravo');
const result = await service.importConfirmed(
buildDoc([
{
name: 'Ghost',
descriptionMd: 'd',
categorySystemKey: 'NO_SUCH_CATEGORY',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{ghost}',
protocol: 'WEB',
port: null,
ipAddress: '',
enabled: true,
files: [],
},
]) as any,
);
expect(result.imported).toBe(0);
expect(result.skipped).toEqual([
{ name: 'Ghost', reason: 'Unknown category: NO_SUCH_CATEGORY' },
]);
expect(await service.list({})).toEqual([]);
expect(await chRepo.count()).toBe(0);
});
});
@@ -0,0 +1,98 @@
import {
importErrorMessage,
summarizeImportResult,
} from '../../frontend/src/app/features/admin/challenges/challenges.pure';
describe('Job 903: import error + result helpers', () => {
it('summarizeImportResult reports imported/skipped/warning counts', () => {
const r = {
imported: 2,
skipped: [{ name: 'X', reason: 'Unknown category: Y' }],
warnings: ['1 skipped.'],
};
const out = summarizeImportResult(r as any);
expect(out).toContain('Imported 2');
expect(out).toContain('Skipped 1');
expect(out).toContain('1 warning');
});
it('importErrorMessage prefers the nested error.message envelope', () => {
expect(importErrorMessage({ error: { message: 'Server said no' } }, 'fallback')).toBe('Server said no');
});
it('importErrorMessage falls back to err.message', () => {
expect(importErrorMessage({ message: 'boom' }, 'fallback')).toBe('boom');
});
it('importErrorMessage falls back to the supplied default when no message is available', () => {
expect(importErrorMessage({}, 'fallback')).toBe('fallback');
expect(importErrorMessage(null, 'fallback')).toBe('fallback');
});
});
describe('Job 903: confirmed import handler contract', () => {
function makeDoc() {
return { format: 'hipctf-challenges', version: 1, exportedAt: '', challenges: [] };
}
it('a successful confirm yields a closed-modal state and a status banner', async () => {
const state = {
importOpen: true as boolean,
importDoc: makeDoc() as any,
importPreview: { total: 2, conflicts: [], unknownCategories: [], warnings: [] } as any,
importResult: null as any,
importError: null as string | null,
importing: false,
statusMessage: null as string | null,
};
// Simulate onImportConfirm body using pure helpers.
try {
const result = { imported: 2, skipped: [], warnings: [] };
state.statusMessage = summarizeImportResult(result as any);
await Promise.resolve();
// Close-modal branch (the new behavior).
state.importOpen = false;
state.importDoc = null;
state.importPreview = null;
state.importResult = null;
state.importError = null;
} catch (err) {
state.importError = importErrorMessage(err, 'Failed to import challenges');
} finally {
state.importing = false;
}
expect(state.importOpen).toBe(false);
expect(state.importDoc).toBeNull();
expect(state.importPreview).toBeNull();
expect(state.importResult).toBeNull();
expect(state.importError).toBeNull();
expect(state.statusMessage).toContain('Imported 2');
expect(state.importing).toBe(false);
});
it('a rejected confirm keeps the modal open and surfaces the error message', async () => {
const state = {
importOpen: true as boolean,
importDoc: makeDoc() as any,
importPreview: { total: 1, conflicts: [], unknownCategories: [], warnings: [] } as any,
importResult: null as any,
importError: null as string | null,
importing: false,
statusMessage: null as string | null,
};
try {
throw { error: { message: 'Server said no' } };
} catch (err) {
state.importError = importErrorMessage(err, 'Failed to import challenges');
} finally {
state.importing = false;
}
expect(state.importOpen).toBe(true);
expect(state.importError).toBe('Server said no');
expect(state.importing).toBe(false);
});
});