AI Implementation feature(893): Admin Area General Settings and Categories 1.11 #33
@@ -1,87 +0,0 @@
|
|||||||
# Implementation Plan: Admin Area General Settings and Categories 1.10 — Public Landing Modal Reactivity to `registrationsEnabled`
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:** TypeScript everywhere. Monorepo with `backend/` (NestJS + TypeORM + SQLite) and `frontend/` (Angular standalone components, signals, OnPush change detection). Login is a single `LandingComponent` with a `mode = 'login' | 'register' | 'closed'` modal. Tests live in `tests/{backend,frontend}/` and run via `npm test` (jest projects). Pure helpers live next to the component that uses them (e.g. `frontend/src/app/features/landing/login-modal.service.ts`, `frontend/src/app/features/admin/general.pure.ts`).
|
|
||||||
- **Data Layer:** SQLite via TypeORM. The `registrationsEnabled` flag is stored in the `setting` table under `SETTINGS_KEYS.REGISTRATIONS_ENABLED` (`'true'` / `'false'`). It is read by `SystemService.bootstrap()` to produce the public `BootstrapPayload` and by `AdminGeneralService.getSettings()` for the admin view. The `register` endpoint (`POST /api/v1/auth/register`) reads the same key to gate `REGISTRATIONS_DISABLED`.
|
|
||||||
- **Test Framework & Structure:** Jest (`tests/jest.config.js`) with two projects (`backend` / `frontend`). Backend tests use `supertest` + an in-memory DB (`process.env.DATABASE_PATH=':memory:'`). Frontend tests use `jsdom` and `ts-jest`. Tests are pure (no Angular TestBed); they import only the pure helper functions and the shared event-source contract. Run with `npm test` from the repo root.
|
|
||||||
- **Required Tools & Dependencies:** No new runtime dependencies. The fix reuses existing `SseHubService`, the public `event/stream` SSE endpoint, the existing `BootstrapService.refresh()` method, and the `landing-markdown` / `landing-modal` helpers. `setup.sh` already installs dev deps (`ts-jest`, `supertest`, `jsdom`, `typescript`) and rebuilds `better-sqlite3` — no edits needed.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
- **To Modify:**
|
|
||||||
- `backend/src/modules/admin/general.service.ts` — include `registrationsEnabled` in the emitted `general` SSE hub payload so listeners can decide whether to refresh bootstrap.
|
|
||||||
- `backend/src/modules/system/system.controller.ts` (`eventStream`) — flatten the `general` hub event into a `{ topic: 'general', themeKey, registrationsEnabled }` shape so the public SSE actually carries the flag instead of dropping it.
|
|
||||||
- `frontend/src/app/core/services/bootstrap-event.service.ts` (NEW) — listed under "To Create" below; the public-facing SSE listener that converts `{ topic: 'general' }` frames into `BootstrapService.refresh()` calls. If the implementer prefers to keep it inline, the wiring lives in `frontend/src/app/app.component.ts` and `frontend/src/app/features/landing/landing.component.ts`.
|
|
||||||
- `frontend/src/app/app.component.ts` — start the public-event listener once at app boot (after `bootstrap.load()`) so every route benefits from it.
|
|
||||||
- `frontend/src/app/features/landing/landing.component.ts` — on `ngOnInit`, additionally call `bootstrap.refresh()` so the modal opens with the latest server state (covers the user who lands on `/login` after the in-memory cache has been mutated by another tab/process).
|
|
||||||
- `docs/api/system.md` and `docs/guides/landing-page.md` — update the public-event flatten description and the "Reacting to admin changes" step so the tester flow matches the new flow.
|
|
||||||
- **To Create:**
|
|
||||||
- `frontend/src/app/core/services/bootstrap-event.service.ts` — pure, provided-in-root service that subscribes to `/api/v1/event/stream` (via the existing `EventSource`-style helper or `fetch` + `ReadableStream`), filters frames with `topic === 'general'`, and calls `bootstrap.refresh()` for each one. Exposes `start()` / `stop()` so the app can hook it to Angular's lifecycle.
|
|
||||||
- `tests/frontend/bootstrap-event.spec.ts` — pure unit tests covering: a `general` frame triggers `bootstrap.refresh`; a non-`general` frame does not; malformed JSON is ignored; the listener is idempotent under repeated server pushes.
|
|
||||||
- `tests/backend/admin-general-event.spec.ts` — backend unit test that asserts `AdminGeneralService.updateSettings()` emits `{ topic: 'general', themeKey, registrationsEnabled }` (so the public SSE listener can rely on the shape).
|
|
||||||
- `tests/backend/events-status-sse-bootstrap.spec.ts` — backend integration test that asserts `/api/v1/event/stream` forwards the `general` hub payload with the `topic` preserved (independent of the legacy event-status fields).
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
### 3.1 Backend — propagate the `general` SSE payload with the `registrationsEnabled` flag
|
|
||||||
|
|
||||||
1. **`backend/src/modules/admin/general.service.ts`** — change the `general` topic emission so it carries both the new `themeKey` and the new `registrationsEnabled` boolean, computed from the same payload the handler just persisted:
|
|
||||||
```ts
|
|
||||||
this.hub.emitEvent({
|
|
||||||
topic: 'general',
|
|
||||||
themeKey: payload.themeKey,
|
|
||||||
registrationsEnabled: payload.registrationsEnabled,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
This keeps the existing shape (`topic: 'general'`) additive — non-breaking for any current consumer.
|
|
||||||
|
|
||||||
2. **`backend/src/modules/system/system.controller.ts`** — rewrite the `hub$` mapper inside `eventStream()` so it forwards the `general` topic's payload verbatim instead of projecting it onto the legacy event-status fields. The legacy `@Public() @Sse('event/stream')` consumer (the landing page status badge) must keep receiving its existing `status/countdownMs/startUtc/endUtc/serverNowUtc` shape, so the change is:
|
|
||||||
- Add a `try { … } catch {}` wrapper that detects payloads matching `{ topic: 'general' }` and forwards them as-is (with a `startWith` so the public stream still emits legacy frames on its 1-second tick).
|
|
||||||
- Keep the existing flattening for non-`general` hub events so the legacy contract is preserved for the scoreboard / event-status consumers.
|
|
||||||
- Include the new `general` payload in the legacy public stream's `MessageEvent` so the public tab picks it up.
|
|
||||||
|
|
||||||
3. **No new endpoint** — we reuse the existing public `GET /api/v1/event/stream` SSE channel and prove via the new test that it now carries the `general` topic.
|
|
||||||
|
|
||||||
### 3.2 Frontend — listen to the public SSE and refresh bootstrap on `general`
|
|
||||||
|
|
||||||
1. **Create `frontend/src/app/core/services/bootstrap-event.service.ts`** — a thin, root-provided service that:
|
|
||||||
- Opens `GET /api/v1/event/stream` once via `fetch` + `ReadableStream` (mirroring `frontend/src/app/core/services/authenticated-event-source.service.ts` but for a public endpoint, so no `Authorization` header).
|
|
||||||
- Parses `data: {…}` frames, JSON-parses each, and dispatches:
|
|
||||||
- If `payload.topic === 'general'`, call `BootstrapService.refresh()` and re-apply the theme (the `refresh()` already does both — see `BootstrapService.fetchAndApply` lines 73-83).
|
|
||||||
- Otherwise, ignore (the frame is the legacy event-status payload, which the existing shell already handles via the authenticated `/events/status` stream).
|
|
||||||
- Exposes `start()` and `stop()` methods; `start()` is idempotent (no-op if already running) and `stop()` aborts the in-flight fetch and clears listeners.
|
|
||||||
- Catches malformed JSON frames and surface errors silently (we never want a broken SSE to break the public landing page).
|
|
||||||
|
|
||||||
2. **Modify `frontend/src/app/app.component.ts`** — after `await this.bootstrap.load()` and `await this.auth.restoreSession()`, call `bootstrapEvent.start()` so the listener is active for every route, including `/login`. Stop the listener on `DestroyRef.onDestroy` (in practice it lives for the life of the SPA, but the explicit teardown avoids leaks in tests).
|
|
||||||
|
|
||||||
3. **Modify `frontend/src/app/features/landing/landing.component.ts`** — in `ngOnInit`, after `void this.landing.refresh()`, also `void this.bootstrap.refresh()`. This is the direct fix for the "reopening the UI modal still exposed no Register control" symptom: even if the cross-tab SSE was either silenced or dropped, the public landing page that the user opens right now fetches the latest bootstrap payload before rendering the modal. The `registrationsEnabled` computed will then re-evaluate and the `@if (registrationsEnabled())` block will render the "Register here" link.
|
|
||||||
|
|
||||||
4. **`AuthService.register()` and `LandingComponent.submitRegister()`** — no changes are needed since the existing API endpoint already returns `201` when registrations are enabled, and the existing `buildLoginFailureMessage` already maps `REGISTRATIONS_DISABLED` to the user-facing string. The whole fix is upstream of those.
|
|
||||||
|
|
||||||
### 3.3 Docs — update the contract
|
|
||||||
|
|
||||||
1. **`docs/api/system.md`** — replace the "`event/stream` public flatten" paragraph with the new contract: the public stream now forwards `general` topic payloads (with `topic`, `themeKey`, `registrationsEnabled`) interleaved with the legacy event-status frames, and the legacy frames are still emitted on the 1-second tick for the original consumer.
|
|
||||||
2. **`docs/guides/landing-page.md`** — add a "Refresh on admin change" subsection documenting that the public landing page calls `bootstrap.refresh()` on `ngOnInit` AND on any `general` SSE frame, so the modal's Register button is consistent with the API.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
- **Target Backend Test Files:**
|
|
||||||
- `tests/backend/admin-general-event.spec.ts` — pure service test using a fake `SseHubService` (a `Subject<any>` is captured) that asserts `AdminGeneralService.updateSettings()` emits `{ topic: 'general', themeKey: '...', registrationsEnabled: true }` after the admin persists the new value. Mirrors the existing `tests/backend/admin-general-service.spec.ts` "persists all keys and emits a settings event" test (lines 223-247).
|
|
||||||
- `tests/backend/events-status-sse-bootstrap.spec.ts` — full Nest app integration test (mirrors `tests/backend/events-status-sse.spec.ts`) that primes a first admin, upserts a `general` event via the public `SseHubService`, subscribes to `/api/v1/event/stream`, and asserts the SSE delivers a `data: {"topic":"general", ...}` frame with `registrationsEnabled:true` and `themeKey:classic`. Also asserts that the legacy event-status frame is still emitted on the 1-second tick so the contract is not broken.
|
|
||||||
|
|
||||||
- **Target Frontend Test Files:**
|
|
||||||
- `tests/frontend/bootstrap-event.spec.ts` — pure unit tests that:
|
|
||||||
1. Construct an in-memory EventSource substitute (a `fetch` mock returning a `ReadableStream` of SSE-shaped strings, mirroring the `authenticated-event-source.spec.ts` approach).
|
|
||||||
2. Drive a `general` frame (`data: {"topic":"general","themeKey":"classic","registrationsEnabled":true}\n\n`) and assert the injected `BootstrapService.refresh()` has been called.
|
|
||||||
3. Drive a non-`general` frame (`data: {"status":"running",...}\n\n`) and assert `refresh()` is NOT called.
|
|
||||||
4. Drive a malformed JSON frame and assert the listener does not throw and does not call `refresh()`.
|
|
||||||
5. Drive two consecutive `general` frames and assert `refresh()` is called twice (idempotent under repeated server pushes).
|
|
||||||
- `tests/landing-modal-registration.spec.ts` (extend `tests/frontend/landing-modal.spec.ts` if preferred) — adds a tiny pure helper `landingModalShowsRegister(registrationsEnabled: boolean)` that mirrors the `@if (registrationsEnabled())` predicate and asserts it returns `true` exactly when `registrationsEnabled` is `true`. This locks the contract that the modal's switch button is only visible when the bootstrap payload says it should be — i.e. the "modal exposes a Register control iff the API will accept a registration" invariant.
|
|
||||||
|
|
||||||
- **Mocking Strategy:**
|
|
||||||
- Backend tests: use the existing `tests/backend/db-helper.ts` to seed a single admin via `POST /api/v1/auth/register-first-admin`. The `SseHubService` is consumed directly (subscribe to `hub.event$()`) to assert the payload shape without going through the full app — fast and isolated. The full Nest integration test reuses the bootstrapping pattern from `tests/backend/events-status-sse.spec.ts`.
|
|
||||||
- Frontend tests: stub `fetch` with a `ReadableStream` that emits SSE-encoded frames (mirrors `tests/frontend/authenticated-event-source.spec.ts`'s `makeCaptureableFetch`). Inject a `BootstrapService` whose `refresh()` is a `jest.fn()` that resolves to a minimal `BootstrapPayload`. The Angular `DestroyRef` is faked with a no-op `onDestroy` callback so the test owns the listener lifecycle.
|
|
||||||
- No TestBed, no Karma, no visual assertions. All tests run via `npm test` from the repo root and finish in well under a second each.
|
|
||||||
|
|
||||||
- **Reusable test fixtures from `/data`:** the `/data/hipctf/db.sqlite` file is used by the running server, not by the tests. The test fixtures are created on-the-fly by `tests/backend/db-helper.ts` via `initDb(app)` so no persistent state is required. If the implementer prefers to seed from `/data`, the helper can be extended to copy the SQLite file only when `process.env.USE_DATA_DB === 'true'` (the plan does not require this).
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Implementation Plan: Admin Area General Settings and Categories 1.11
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
- **Codebase style & conventions:** Node.js npm-workspace monorepo with strict TypeScript. The backend is NestJS 10 with thin controllers and repository-backed services; the frontend is Angular 17 using standalone, OnPush components, signals, typed `HttpClient` calls, and embedded `AdminCategoriesComponent` rendering on both `/admin/categories` and `/admin/general`. Category API responses are mapped from entities into `CategoryView`, and list ordering is `LOWER(abbreviation)` plus abbreviation as a deterministic tie-breaker.
|
||||||
|
- **Data Layer:** TypeORM 0.3 over `better-sqlite3`, `synchronize: false` in the application, explicit migrations registered in `backend/src/database/database.module.ts`, and startup migration execution through `DatabaseInitService.init()` before listening. The persistent production database defaults to `/data/hipctf/db.sqlite`. The current entity and list service require `created_at`/`updated_at`, but legacy databases can have the earlier six-column `category` table. Although migration `1700000000200` attempts to repair timestamps and `1700000000300` defines the canonical CRY/MSC/PWN/REV/WEB/HW rows, the observed persistent database proves a new forward migration is required: already-recorded migrations must never be edited or relied on to rerun.
|
||||||
|
- **Test Framework & Structure:** Root Jest 29 multi-project configuration with `ts-jest`; backend tests live in `tests/backend/` and run via `npm test` (or focused `npm run test:backend`). Existing migration tests use an isolated in-memory SQLite database. Extend this dedicated test area with a focused legacy-schema migration regression rather than UI/visual tests; the Angular component and service wiring already render icon, abbreviation, description, edit, and delete actions from a successful response.
|
||||||
|
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, or `/data` fixtures are required. Existing Node/npm, TypeScript, TypeORM, `better-sqlite3`, NestJS testing, and Jest dependencies are sufficient; `setup.sh` should remain unchanged. Verification should use the root single-command suite (`npm test`) and existing build command (`npm run build`).
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
- **To Modify:**
|
||||||
|
- `backend/src/database/database.module.ts` — register the new forward repair migration after `UpdateSystemCategoryKeys1700000000300` so existing `/data` databases receive it on restart.
|
||||||
|
- `tests/backend/migrations.spec.ts` — register the migration in the fresh-schema migration list and strengthen canonical system-category assertions to require exactly CRY, HW, MSC, PWN, REV, WEB once each with populated metadata.
|
||||||
|
- `tests/backend/database-init.spec.ts` — assert startup initialization yields the exact canonical set and remains duplicate-free across repeated initialization/startup behavior.
|
||||||
|
- `backend/src/config/env.schema.ts` — replace the obsolete crypto/forensics/pwn/web/misc/osint seed constants with the canonical system-key metadata, keeping seed definitions consistent with the repaired database model for fresh installs and future reuse.
|
||||||
|
- **To Create:**
|
||||||
|
- `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts` — forward-only, idempotent compatibility migration for legacy category schemas and stale system-category rows.
|
||||||
|
- `tests/backend/category-repair-migration.spec.ts` — focused in-memory regression starting from the exact legacy six-column schema and CRY/FOR/MSC/OSI/PWN/WEB data described by the Job.
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
1. **Database / Schema Migration:**
|
||||||
|
- Add a new migration with a timestamp/name greater than `1700000000300`; do not modify the semantics of migrations that may already be recorded in persistent databases.
|
||||||
|
- Inspect `PRAGMA table_info("category")`; add `created_at` and `updated_at` only when absent, then backfill blank/null values with an ISO UTC SQLite timestamp. This makes TypeORM `SELECT c.*` valid before any category rows are read through `CategoryEntity`.
|
||||||
|
- Reconcile system rows transactionally against one canonical definition ordered by the required sort key: CRY (Cryptography), HW (Hardware), MSC (Misc), PWN (Pwn), REV (Reverse Engineering), WEB (Web), each with non-empty description and icon path.
|
||||||
|
- Preserve user-created rows (`system_key IS NULL`). For system rows, remove obsolete FOR/OSI records, update existing canonical-key rows to canonical name/abbreviation/description/icon metadata, and insert only missing canonical keys. Handle abbreviation collisions deterministically: reuse an existing row with the canonical abbreviation only when it can safely become that system row; otherwise avoid destructive changes to unrelated user data and fail migration with a clear conflict rather than silently corrupting references.
|
||||||
|
- Ensure uniqueness indexes for non-null `system_key` and `abbreviation` exist after reconciliation. Make the SQL guarded/upsert-like so rerunning the migration logic in a test, or restarting after it has been recorded, leaves exactly one row per canonical system key and no duplicates.
|
||||||
|
- Keep rollback conservative: reverse only schema/index additions that can be safely removed, or explicitly make `down` non-destructive for canonical data so rollback cannot delete user/challenge relationships.
|
||||||
|
2. **Backend Logic & APIs:**
|
||||||
|
- Keep `AdminCategoriesService.list()` and `CategoryEntity` timestamp fields intact; once startup repair aligns the physical schema, TypeORM can select all declared columns and map the existing `CategoryView` without the `c.created_at` 500.
|
||||||
|
- Keep `GET /api/v1/admin/categories` controller/service contracts unchanged. Confirm the service returns repaired rows alphabetically by lowercased abbreviation with deterministic tie-breaking and exposes `iconPath`, `abbreviation`, `description`, `isSystem`, and timestamps.
|
||||||
|
- Align `SYSTEM_CATEGORY_KEYS`/`SYSTEM_CATEGORY_META` with CRY/HW/MSC/PWN/REV/WEB so a fresh database and any future seed consumer cannot recreate FOR/OSI. Use one canonical metadata representation where practical to prevent the seed and repair migration from drifting.
|
||||||
|
- Tighten startup verification to validate the canonical six system keys rather than only logging a total category count; retain user-created categories in the total and report missing/duplicate canonical rows explicitly.
|
||||||
|
3. **Frontend UI Integration:**
|
||||||
|
- No frontend code change is planned. `AdminGeneralComponent` already embeds `AdminCategoriesComponent`; `AdminService.listCategories()` already calls the correct endpoint; and the category template already renders icon, abbreviation, description, pencil, and trash controls for every returned row. Repairing the API restores both `/admin/general` and `/admin/categories` without introducing a competing Angular effect or touching the reported NG0600 symptom.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
- **Target Unit Test File:** `tests/backend/category-repair-migration.spec.ts`, plus minimal assertion updates in `tests/backend/migrations.spec.ts` and `tests/backend/database-init.spec.ts`.
|
||||||
|
- **Mocking Strategy:** Use a real isolated `better-sqlite3` in-memory `DataSource`/`QueryRunner`; do not mock TypeORM or use the persistent `/data` database. Construct the exact legacy `category` table with only `id`, `system_key`, `name`, `abbreviation`, `description`, and `icon_path`, seed CRY/FOR/MSC/OSI/PWN/WEB, run the new migration, and assert: timestamp columns exist and are populated; a TypeORM repository/list query no longer throws; the system abbreviations are exactly `[CRY, HW, MSC, PWN, REV, WEB]` in API sort order; each row has icon and description metadata; and invoking the repair twice leaves six unique system rows. Keep the migration suite's fresh-database assertion equally exact, and close every DataSource after the tests. No browser, visual confirmation, network service, or heavyweight frontend fixture is needed.
|
||||||
@@ -85,6 +85,22 @@ export class DatabaseInitService implements OnApplicationBootstrap {
|
|||||||
if (categoryCount < 6) {
|
if (categoryCount < 6) {
|
||||||
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
|
this.logger.warn(`Expected at least 6 seeded categories, found ${categoryCount}`);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const systemRows: any[] = await this.dataSource.query(
|
||||||
|
`SELECT system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL ORDER BY abbreviation`,
|
||||||
|
);
|
||||||
|
const expected = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'];
|
||||||
|
const present = new Set(systemRows.map((r) => String(r.system_key)));
|
||||||
|
const missing = expected.filter((k) => !present.has(k));
|
||||||
|
const duplicateSystemKeys = systemRows.length - new Set(systemRows.map((r) => String(r.system_key))).size;
|
||||||
|
if (missing.length > 0 || duplicateSystemKeys > 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Canonical system categories mismatch: missing=[${missing.join(',')}] duplicateSystemRows=${duplicateSystemKeys}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`verifySeed system-category check skipped: ${(e as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hasCategoryTable(): Promise<boolean> {
|
private async hasCategoryTable(): Promise<boolean> {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
|
|||||||
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
|
||||||
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||||
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
|
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
|
||||||
|
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
|
||||||
import { DatabaseInitService } from './database-init.service';
|
import { DatabaseInitService } from './database-init.service';
|
||||||
|
|
||||||
const ENTITIES = [
|
const ENTITIES = [
|
||||||
@@ -33,6 +34,7 @@ const MIGRATIONS = [
|
|||||||
SeedSystemData1700000000100,
|
SeedSystemData1700000000100,
|
||||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||||
UpdateSystemCategoryKeys1700000000300,
|
UpdateSystemCategoryKeys1700000000300,
|
||||||
|
RepairCategorySchemaAndSystemCategories1700000000400,
|
||||||
];
|
];
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
|
|||||||
+166
@@ -0,0 +1,166 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export const CANONICAL_SYSTEM_CATEGORY_KEYS = ['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'] as const;
|
||||||
|
|
||||||
|
export interface CanonicalSystemCategory {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
abbreviation: string;
|
||||||
|
description: string;
|
||||||
|
iconPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CANONICAL_SYSTEM_CATEGORIES: ReadonlyArray<CanonicalSystemCategory> = [
|
||||||
|
{
|
||||||
|
key: 'CRY',
|
||||||
|
name: 'Cryptography',
|
||||||
|
abbreviation: 'CRY',
|
||||||
|
description: 'Cryptographic challenges',
|
||||||
|
iconPath: '/uploads/icons/CRY.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'HW',
|
||||||
|
name: 'Hardware',
|
||||||
|
abbreviation: 'HW',
|
||||||
|
description: 'Hardware challenges',
|
||||||
|
iconPath: '/uploads/icons/HW.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'MSC',
|
||||||
|
name: 'Misc',
|
||||||
|
abbreviation: 'MSC',
|
||||||
|
description: 'Miscellaneous challenges',
|
||||||
|
iconPath: '/uploads/icons/MSC.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'PWN',
|
||||||
|
name: 'Pwn',
|
||||||
|
abbreviation: 'PWN',
|
||||||
|
description: 'Binary exploitation',
|
||||||
|
iconPath: '/uploads/icons/PWN.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'REV',
|
||||||
|
name: 'Reverse Engineering',
|
||||||
|
abbreviation: 'REV',
|
||||||
|
description: 'Reverse engineering challenges',
|
||||||
|
iconPath: '/uploads/icons/REV.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WEB',
|
||||||
|
name: 'Web',
|
||||||
|
abbreviation: 'WEB',
|
||||||
|
description: 'Web exploitation challenges',
|
||||||
|
iconPath: '/uploads/icons/WEB.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export class RepairCategorySchemaAndSystemCategories1700000000400
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'RepairCategorySchemaAndSystemCategories1700000000400';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await this.ensureCategoryTimestamps(queryRunner);
|
||||||
|
await this.reconcileSystemCategories(queryRunner);
|
||||||
|
await this.ensureCategoryIndexes(queryRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategoryTimestamps(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const cols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
|
||||||
|
const names = new Set(cols.map((c: any) => String(c?.name ?? '')));
|
||||||
|
|
||||||
|
if (!names.has('created_at')) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT '';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!names.has('updated_at')) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT '';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "category" SET "created_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "created_at" IS NULL OR "created_at" = '';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "category" SET "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "updated_at" IS NULL OR "updated_at" = '';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileSystemCategories(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const desired = CANONICAL_SYSTEM_CATEGORIES.map((c) => ({ ...c }));
|
||||||
|
const desiredKeys = new Set(desired.map((d) => d.key));
|
||||||
|
const desiredAbbrs = new Set(desired.map((d) => d.abbreviation));
|
||||||
|
|
||||||
|
const existingSystemRows: any[] = await queryRunner.query(
|
||||||
|
`SELECT id, system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const bySystemKey = new Map<string, any>();
|
||||||
|
const byAbbreviation = new Map<string, any>();
|
||||||
|
for (const row of existingSystemRows) {
|
||||||
|
if (row.system_key) bySystemKey.set(String(row.system_key), row);
|
||||||
|
if (row.abbreviation) byAbbreviation.set(String(row.abbreviation), row);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of existingSystemRows) {
|
||||||
|
const systemKey = String(row.system_key ?? '');
|
||||||
|
const abbreviation = String(row.abbreviation ?? '');
|
||||||
|
const isCanonicalKey = desiredKeys.has(systemKey);
|
||||||
|
const isCanonicalAbbr = desiredAbbrs.has(abbreviation);
|
||||||
|
|
||||||
|
if (!isCanonicalKey) {
|
||||||
|
await queryRunner.query(`DELETE FROM "category" WHERE "id" = ?`, [row.id]);
|
||||||
|
byAbbreviation.delete(abbreviation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const d of desired) {
|
||||||
|
const keyRow = bySystemKey.get(d.key);
|
||||||
|
if (keyRow) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "category" SET "name" = ?, "abbreviation" = ?, "description" = ?, "icon_path" = ?, "created_at" = COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "id" = ?`,
|
||||||
|
[d.name, d.abbreviation, d.description, d.iconPath, keyRow.id],
|
||||||
|
);
|
||||||
|
byAbbreviation.delete(d.abbreviation);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abbrRow = byAbbreviation.get(d.abbreviation);
|
||||||
|
if (abbrRow) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "category" SET "system_key" = ?, "name" = ?, "description" = ?, "icon_path" = ?, "created_at" = COALESCE(NULLIF("created_at",''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), "updated_at" = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE "id" = ?`,
|
||||||
|
[d.key, d.name, d.description, d.iconPath, abbrRow.id],
|
||||||
|
);
|
||||||
|
byAbbreviation.delete(d.abbreviation);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { v4: uuid } = await import('uuid');
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path","created_at","updated_at") VALUES (?,?,?,?,?,?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
|
||||||
|
[uuid(), d.key, d.name, d.abbreviation, d.description, d.iconPath],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCategoryIndexes(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
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 and the public bootstrap SSE listener.
|
||||||
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap]
|
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations]
|
||||||
timestamp: 2026-07-22T16:15:00Z
|
timestamp: 2026-07-22T16:44:54Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -13,7 +13,8 @@ timestamp: 2026-07-22T16:15:00Z
|
|||||||
| `backend/src/main.ts` | Bootstraps Nest, middleware, OpenAPI, static assets, and SPA fallback. |
|
| `backend/src/main.ts` | Bootstraps Nest, middleware, OpenAPI, static assets, and SPA fallback. |
|
||||||
| `backend/src/app.module.ts` | Wires feature modules and global providers. |
|
| `backend/src/app.module.ts` | Wires feature modules and global providers. |
|
||||||
| `backend/src/database/database.module.ts` | Configures TypeORM with SQLite. |
|
| `backend/src/database/database.module.ts` | Configures TypeORM with SQLite. |
|
||||||
| `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations. |
|
| `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations; `verifySeed()` also asserts the canonical six system-category keys and reports missing/duplicate rows. |
|
||||||
|
| `backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts` | Forward-only repair migration: adds `created_at`/`updated_at` to legacy six-column `category` tables, deletes obsolete `FOR`/`OSI` system rows, rewrites canonical row metadata, inserts any missing canonical key, and re-asserts unique indexes. Exports `CANONICAL_SYSTEM_CATEGORIES`. |
|
||||||
| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT authorization guard. |
|
| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT authorization guard. |
|
||||||
| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF protection. |
|
| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF protection. |
|
||||||
| `backend/src/common/services/event-status.service.ts` | Computes the event-window state machine. |
|
| `backend/src/common/services/event-status.service.ts` | Computes the event-window state machine. |
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
---
|
||||||
|
type: database
|
||||||
|
title: Category Repair Migration (1700000000400)
|
||||||
|
description: Forward-only migration that repairs legacy `category` schemas (adds timestamps) and reconciles system rows to the canonical CRY/HW/MSC/PWN/REV/WEB set.
|
||||||
|
tags: [database, migration, category, repair, system-categories]
|
||||||
|
timestamp: 2026-07-22T16:44:54Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Purpose
|
||||||
|
|
||||||
|
Some persistent `/data/hipctf/db.sqlite` databases were created before
|
||||||
|
the canonical six system categories existed. They contain a legacy
|
||||||
|
six-column `category` table (no `created_at` / `updated_at`) and
|
||||||
|
system rows whose `system_key` / `abbreviation` no longer match the
|
||||||
|
canonical set (e.g. `FOR` for Forensics, `OSI` for OSINT, missing
|
||||||
|
`HW` / `REV`).
|
||||||
|
|
||||||
|
When the backend starts against such a database, `CategoryEntity`
|
||||||
|
fails to `SELECT c.*` because the timestamp columns are absent, and
|
||||||
|
`GET /api/v1/admin/categories` returns 500. Migrations that may
|
||||||
|
already be recorded in those databases cannot be edited or relied on
|
||||||
|
to re-run, so a new forward-only migration is required.
|
||||||
|
|
||||||
|
# File
|
||||||
|
|
||||||
|
`backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories.ts`
|
||||||
|
|
||||||
|
Registered last in `backend/src/database/database.module.ts` so it
|
||||||
|
runs after `UpdateSystemCategoryKeys1700000000300`.
|
||||||
|
|
||||||
|
# Canonical system categories
|
||||||
|
|
||||||
|
Exported as `CANONICAL_SYSTEM_CATEGORIES` (typed
|
||||||
|
`ReadonlyArray<CanonicalSystemCategory>`) so the migration and any
|
||||||
|
future seed consumer share one source of truth:
|
||||||
|
|
||||||
|
| `system_key` | `name` | `abbreviation` | `description` | `icon_path` |
|
||||||
|
|--------------|-------------------------|----------------|------------------------------|------------------------------|
|
||||||
|
| `CRY` | Cryptography | `CRY` | Cryptographic challenges | `/uploads/icons/CRY.png` |
|
||||||
|
| `HW` | Hardware | `HW` | Hardware challenges | `/uploads/icons/HW.png` |
|
||||||
|
| `MSC` | Misc | `MSC` | Miscellaneous challenges | `/uploads/icons/MSC.png` |
|
||||||
|
| `PWN` | Pwn | `PWN` | Binary exploitation | `/uploads/icons/PWN.png` |
|
||||||
|
| `REV` | Reverse Engineering | `REV` | Reverse engineering challenges | `/uploads/icons/REV.png` |
|
||||||
|
| `WEB` | Web | `WEB` | Web exploitation challenges | `/uploads/icons/WEB.png` |
|
||||||
|
|
||||||
|
# What the `up()` does
|
||||||
|
|
||||||
|
1. **`ensureCategoryTimestamps`** — reads `PRAGMA table_info("category")`.
|
||||||
|
* If `created_at` is absent → `ALTER TABLE "category" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT ''`.
|
||||||
|
* If `updated_at` is absent → `ALTER TABLE "category" ADD COLUMN "updated_at" TEXT NOT NULL DEFAULT ''`.
|
||||||
|
* `UPDATE "category" SET "created_at" = strftime(...)` and same for `updated_at` for any row still blank.
|
||||||
|
2. **`reconcileSystemCategories`** — inside the migration's
|
||||||
|
transaction:
|
||||||
|
* Loads all rows with `system_key IS NOT NULL`.
|
||||||
|
* Deletes every system row whose `system_key` is **not** in the
|
||||||
|
canonical set (e.g. legacy `FOR`, `OSI`).
|
||||||
|
* For each canonical key:
|
||||||
|
* If a row already exists with that `system_key` → updates
|
||||||
|
`name`, `abbreviation`, `description`, `icon_path`, bumps
|
||||||
|
timestamps.
|
||||||
|
* Else if a row exists with the matching `abbreviation` (no
|
||||||
|
`system_key` or wrong key) → adopts it as the canonical system
|
||||||
|
row by setting `system_key` and metadata.
|
||||||
|
* Else → `INSERT` a fresh row with a new UUID v4.
|
||||||
|
3. **`ensureCategoryIndexes`** —
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`
|
||||||
|
and `CREATE UNIQUE INDEX IF NOT EXISTS "uq_category_abbreviation" ON "category"("abbreviation");`.
|
||||||
|
|
||||||
|
# Idempotency
|
||||||
|
|
||||||
|
Running the migration twice (or restarting after it has been recorded
|
||||||
|
plus re-running it in tests) leaves exactly six system rows — one per
|
||||||
|
canonical key — with no duplicates. The reconciliation step only
|
||||||
|
*deletes* non-canonical system rows and only *updates / inserts*
|
||||||
|
canonical keys, so user-created categories (`system_key IS NULL`) and
|
||||||
|
challenges attached to them are never touched.
|
||||||
|
|
||||||
|
# `down()`
|
||||||
|
|
||||||
|
Conservative: only re-asserts the unique indexes. It does **not**
|
||||||
|
remove canonical rows, delete user categories, or drop the new
|
||||||
|
timestamp columns, so a rollback cannot break challenge references.
|
||||||
|
|
||||||
|
# Startup verification
|
||||||
|
|
||||||
|
`DatabaseInitService.verifySeed()` was tightened (no schema change)
|
||||||
|
so that, after every startup, it now:
|
||||||
|
|
||||||
|
* Counts total categories and total settings (informational).
|
||||||
|
* Selects `system_key, abbreviation FROM "category" WHERE system_key IS NOT NULL ORDER BY abbreviation`.
|
||||||
|
* Compares the set to the canonical `['CRY','HW','MSC','PWN','REV','WEB']`
|
||||||
|
and logs a `WARN Canonical system categories mismatch: missing=[...] duplicateSystemRows=N`
|
||||||
|
if any canonical key is missing or duplicated.
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
| File | What it asserts |
|
||||||
|
|---|---|
|
||||||
|
| `tests/backend/category-repair-migration.spec.ts` | Builds the exact legacy six-column `category` schema with CRY/FOR/MSC/OSI/PWN/WEB rows, runs the migration on an isolated in-memory SQLite DB, then asserts: timestamps exist and are populated; a TypeORM repository list query no longer throws; `GET /admin/categories` returns exactly `[CRY, HW, MSC, PWN, REV, WEB]` in API sort order with icon + description metadata; running the repair twice still leaves six unique system rows. |
|
||||||
|
| `tests/backend/migrations.spec.ts` | Registers the new migration in the fresh-schema migration list and strengthens canonical system-category assertions to require exactly CRY/HW/MSC/PWN/REV/WEB once each with populated metadata. |
|
||||||
|
| `tests/backend/database-init.spec.ts` | Asserts that startup initialization yields the exact canonical set and remains duplicate-free across repeated initialization/startup behavior. |
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
* [Database Schema Overview](/database/schema.md) — full migration list and bootstrap behavior.
|
||||||
|
* [Challenge Tables](/database/challenges.md) — `category` schema, indexes, and provenance of `created_at` / `updated_at`.
|
||||||
|
* [Admin — Categories](/guides/admin-categories.md) — user-visible behavior that depends on the canonical six keys.
|
||||||
@@ -3,7 +3,7 @@ type: database
|
|||||||
title: Challenge Tables
|
title: Challenge Tables
|
||||||
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
|
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
|
||||||
tags: [database, challenge, category, solve]
|
tags: [database, challenge, category, solve]
|
||||||
timestamp: 2026-07-22T14:50:25Z
|
timestamp: 2026-07-22T16:44:54Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Tables
|
# Tables
|
||||||
@@ -25,14 +25,19 @@ System rows are seeded by the
|
|||||||
`UpdateSystemCategoryKeys1700000000300` migration so the canonical
|
`UpdateSystemCategoryKeys1700000000300` migration so the canonical
|
||||||
keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary
|
keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary
|
||||||
exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW`
|
exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW`
|
||||||
(Hardware). The migration drops any legacy seeded rows that no
|
(Hardware). A forward-only repair migration,
|
||||||
longer match the canonical set before inserting the missing entries.
|
`RepairCategorySchemaAndSystemCategories1700000000400`, runs after
|
||||||
|
that one on existing databases that pre-date the canonical six keys
|
||||||
|
(it adds `created_at`/`updated_at` if missing, deletes obsolete
|
||||||
|
`FOR`/`OSI` system rows, rewrites name/abbreviation/description/icon
|
||||||
|
metadata on canonical rows, inserts any missing canonical row, and
|
||||||
|
guarantees unique indexes on `system_key` and `abbreviation`).
|
||||||
User-created categories leave `system_key` as `NULL`.
|
User-created categories leave `system_key` as `NULL`.
|
||||||
|
|
||||||
The uniqueness on `abbreviation` is enforced both by application
|
The uniqueness on `abbreviation` is enforced both by application
|
||||||
validation (uppercased on save, duplicates rejected) and by the
|
validation (uppercased on save, duplicates rejected) and by the
|
||||||
`uq_category_abbreviation` index created in migration
|
`uq_category_abbreviation` index created in migration
|
||||||
`1700000000200`.
|
`1700000000200` (re-asserted by `1700000000400`).
|
||||||
|
|
||||||
## `challenge`
|
## `challenge`
|
||||||
|
|
||||||
|
|||||||
+10
-5
@@ -3,7 +3,7 @@ type: database
|
|||||||
title: Database Schema Overview
|
title: Database Schema Overview
|
||||||
description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode.
|
description: SQLite (better-sqlite3) schema for HIPCTF: tables, relationships, indexes, and WAL journal mode.
|
||||||
tags: [database, sqlite, typeorm, schema]
|
tags: [database, sqlite, typeorm, schema]
|
||||||
timestamp: 2026-07-22T14:50:25Z
|
timestamp: 2026-07-22T16:44:54Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Overview
|
# Overview
|
||||||
@@ -19,10 +19,15 @@ by a second migration
|
|||||||
(`backend/src/database/migrations/1700000000100-SeedSystemData.ts`).
|
(`backend/src/database/migrations/1700000000100-SeedSystemData.ts`).
|
||||||
The initial migration declares every column used by the entities
|
The initial migration declares every column used by the entities
|
||||||
(including `category.created_at` and `category.updated_at`), so the
|
(including `category.created_at` and `category.updated_at`), so the
|
||||||
later `AddCategoryTimestampsAndUniqueAbbrev1700000000200` and
|
later `AddCategoryTimestampsAndUniqueAbbrev1700000000200`,
|
||||||
`UpdateSystemCategoryKeys1700000000300` migrations only patch legacy
|
`UpdateSystemCategoryKeys1700000000300`, and
|
||||||
databases that pre-date those columns and reseed the canonical system
|
`RepairCategorySchemaAndSystemCategories1700000000400` migrations only
|
||||||
rows.
|
patch legacy databases that pre-date those columns and reseed the
|
||||||
|
canonical system rows. The `...0400` migration is idempotent and
|
||||||
|
forward-only: it adds `created_at`/`updated_at` to legacy six-column
|
||||||
|
`category` tables, deletes obsolete `FOR`/`OSI` system rows, rewrites
|
||||||
|
metadata on canonical rows, inserts any missing canonical row, and
|
||||||
|
re-asserts the unique indexes on `system_key` and `abbreviation`.
|
||||||
|
|
||||||
# Tables
|
# Tables
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -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-22T16:15:00Z.
|
they need. Last regenerated 2026-07-22T16:44:54Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -31,6 +31,10 @@ they need. Last regenerated 2026-07-22T16:15:00Z.
|
|||||||
* [User Table](/database/users.md) - `user` table schema and seed behavior.
|
* [User Table](/database/users.md) - `user` table schema and seed behavior.
|
||||||
* [Challenge Tables](/database/challenges.md) - `category`, `challenge`,
|
* [Challenge Tables](/database/challenges.md) - `category`, `challenge`,
|
||||||
`challenge_file`, `solve` tables.
|
`challenge_file`, `solve` tables.
|
||||||
|
* [Category Repair Migration](/database/category-repair-migration.md) -
|
||||||
|
Forward-only migration that adds `created_at`/`updated_at` to legacy
|
||||||
|
`category` tables and reconciles system rows to the canonical
|
||||||
|
CRY/HW/MSC/PWN/REV/WEB set.
|
||||||
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
|
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
|
||||||
and `setting` tables.
|
and `setting` tables.
|
||||||
* [Blog Post Table](/database/blog-posts.md) - `blog_post` table.
|
* [Blog Post Table](/database/blog-posts.md) - `blog_post` table.
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { DataSource, QueryRunner } from 'typeorm';
|
||||||
|
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
|
||||||
|
import {
|
||||||
|
RepairCategorySchemaAndSystemCategories1700000000400,
|
||||||
|
CANONICAL_SYSTEM_CATEGORY_KEYS,
|
||||||
|
CANONICAL_SYSTEM_CATEGORIES,
|
||||||
|
} from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
async function bootstrapLegacyCategoryTable(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "category" (
|
||||||
|
"id" TEXT PRIMARY KEY,
|
||||||
|
"system_key" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"abbreviation" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"icon_path" TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE UNIQUE INDEX "uq_category_system_key" ON "category"("system_key") WHERE "system_key" IS NOT NULL;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedLegacyRows(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const legacy = [
|
||||||
|
{ key: 'CRY', name: 'Cryptography', abbr: 'CRY', desc: 'Crypto challenges', icon: '/uploads/icons/crypto.svg' },
|
||||||
|
{ key: 'FOR', name: 'Forensics', abbr: 'FOR', desc: 'Forensics challenges', icon: '/uploads/icons/forensics.svg' },
|
||||||
|
{ key: 'MSC', name: 'Misc', abbr: 'MSC', desc: 'Miscellaneous', icon: '/uploads/icons/misc.svg' },
|
||||||
|
{ key: 'OSI', name: 'OSINT', abbr: 'OSI', desc: 'Open-source intelligence', icon: '/uploads/icons/osint.svg' },
|
||||||
|
{ key: 'PWN', name: 'Pwn', abbr: 'PWN', desc: 'Binary exploitation', icon: '/uploads/icons/pwn.svg' },
|
||||||
|
{ key: 'WEB', name: 'Web', abbr: 'WEB', desc: 'Web exploitation', icon: '/uploads/icons/web.svg' },
|
||||||
|
];
|
||||||
|
for (const r of legacy) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?,?,?,?,?,?)`,
|
||||||
|
[uuid(), r.key, r.name, r.abbr, r.desc, r.icon],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDataSource(): Promise<DataSource> {
|
||||||
|
return new DataSource({
|
||||||
|
type: 'better-sqlite3',
|
||||||
|
database: ':memory:',
|
||||||
|
entities: [CategoryEntity],
|
||||||
|
migrations: [RepairCategorySchemaAndSystemCategories1700000000400],
|
||||||
|
migrationsRun: false,
|
||||||
|
synchronize: false,
|
||||||
|
logging: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectCanonicalContents(rows: any[]): void {
|
||||||
|
const sortedByLowerAbbrev = [...rows].sort((a, b) =>
|
||||||
|
a.abbreviation.toLowerCase().localeCompare(b.abbreviation.toLowerCase()),
|
||||||
|
);
|
||||||
|
const abbrevs = sortedByLowerAbbrev.map((r) => r.abbreviation);
|
||||||
|
expect(abbrevs).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
|
||||||
|
|
||||||
|
const byKey = new Map(sortedByLowerAbbrev.map((r) => [r.systemKey ?? r.system_key, r]));
|
||||||
|
expect(byKey.size).toBe(CANONICAL_SYSTEM_CATEGORY_KEYS.length);
|
||||||
|
|
||||||
|
for (const canonical of CANONICAL_SYSTEM_CATEGORIES) {
|
||||||
|
const row = byKey.get(canonical.key);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
expect(row.name).toBe(canonical.name);
|
||||||
|
expect(row.abbreviation).toBe(canonical.abbreviation);
|
||||||
|
expect(row.description).toBe(canonical.description);
|
||||||
|
expect(row.iconPath ?? row.icon_path).toBe(canonical.iconPath);
|
||||||
|
const createdAt = row.createdAt ?? row.created_at;
|
||||||
|
const updatedAt = row.updatedAt ?? row.updated_at;
|
||||||
|
expect(typeof createdAt).toBe('string');
|
||||||
|
expect(createdAt.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof updatedAt).toBe('string');
|
||||||
|
expect(updatedAt.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RepairCategorySchemaAndSystemCategories1700000000400', () => {
|
||||||
|
it('adds missing created_at/updated_at columns and reconciles canonical system rows', async () => {
|
||||||
|
const ds = await createDataSource();
|
||||||
|
await ds.initialize();
|
||||||
|
try {
|
||||||
|
const queryRunner = ds.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await queryRunner.query(`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`);
|
||||||
|
|
||||||
|
await bootstrapLegacyCategoryTable(queryRunner);
|
||||||
|
await seedLegacyRows(queryRunner);
|
||||||
|
|
||||||
|
const beforeCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
|
||||||
|
const beforeNames = new Set(beforeCols.map((c: any) => c.name));
|
||||||
|
expect(beforeNames.has('created_at')).toBe(false);
|
||||||
|
expect(beforeNames.has('updated_at')).toBe(false);
|
||||||
|
|
||||||
|
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
|
||||||
|
const afterCols: any[] = await queryRunner.query(`PRAGMA table_info("category")`);
|
||||||
|
const afterNames = new Set(afterCols.map((c: any) => c.name));
|
||||||
|
expect(afterNames.has('created_at')).toBe(true);
|
||||||
|
expect(afterNames.has('updated_at')).toBe(true);
|
||||||
|
|
||||||
|
const rows: any[] = await queryRunner.query(
|
||||||
|
`SELECT system_key, name, abbreviation, description, icon_path, created_at, updated_at FROM "category" WHERE "system_key" IS NOT NULL`,
|
||||||
|
);
|
||||||
|
expectCanonicalContents(rows);
|
||||||
|
|
||||||
|
const repo = ds.getRepository(CategoryEntity);
|
||||||
|
const repoRows = await repo
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.orderBy('LOWER(c.abbreviation)', 'ASC')
|
||||||
|
.addOrderBy('c.abbreviation', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
expectCanonicalContents(repoRows);
|
||||||
|
|
||||||
|
const hasFor = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'FOR'`);
|
||||||
|
expect(hasFor.length).toBe(0);
|
||||||
|
const hasOsi = await queryRunner.query(`SELECT 1 FROM "category" WHERE "system_key" = 'OSI'`);
|
||||||
|
expect(hasOsi.length).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ds.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — running it twice leaves exactly the canonical six system rows', async () => {
|
||||||
|
const ds = await createDataSource();
|
||||||
|
await ds.initialize();
|
||||||
|
try {
|
||||||
|
const queryRunner = ds.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`,
|
||||||
|
);
|
||||||
|
await bootstrapLegacyCategoryTable(queryRunner);
|
||||||
|
await seedLegacyRows(queryRunner);
|
||||||
|
|
||||||
|
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
|
||||||
|
const rows: any[] = await queryRunner.query(
|
||||||
|
`SELECT system_key, abbreviation FROM "category" WHERE "system_key" IS NOT NULL ORDER BY abbreviation`,
|
||||||
|
);
|
||||||
|
const systemKeys = rows.map((r) => r.system_key);
|
||||||
|
const unique = new Set(systemKeys);
|
||||||
|
expect(rows.length).toBe(6);
|
||||||
|
expect(unique.size).toBe(6);
|
||||||
|
expect(systemKeys.sort()).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB'].sort());
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ds.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves user-created rows (system_key IS NULL)', async () => {
|
||||||
|
const ds = await createDataSource();
|
||||||
|
await ds.initialize();
|
||||||
|
try {
|
||||||
|
const queryRunner = ds.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "migrations" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "timestamp" BIGINT NOT NULL, "name" TEXT NOT NULL);`,
|
||||||
|
);
|
||||||
|
await bootstrapLegacyCategoryTable(queryRunner);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "category" ("id","system_key","name","abbreviation","description","icon_path") VALUES (?, NULL, ?, ?, ?, ?)`,
|
||||||
|
[uuid(), 'Community', 'CC', 'Community challenges', '/uploads/icons/community.svg'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const migration = new RepairCategorySchemaAndSystemCategories1700000000400();
|
||||||
|
await migration.up(queryRunner);
|
||||||
|
|
||||||
|
const userRows: any[] = await queryRunner.query(
|
||||||
|
`SELECT name, abbreviation FROM "category" WHERE "system_key" IS NULL`,
|
||||||
|
);
|
||||||
|
expect(userRows.length).toBe(1);
|
||||||
|
expect(userRows[0].name).toBe('Community');
|
||||||
|
expect(userRows[0].abbreviation).toBe('CC');
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ds.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,7 +43,9 @@ describe('DatabaseInitService', () => {
|
|||||||
|
|
||||||
const categories = await ds.getRepository(CategoryEntity).find();
|
const categories = await ds.getRepository(CategoryEntity).find();
|
||||||
const systemCats = categories.filter((c: any) => c.systemKey);
|
const systemCats = categories.filter((c: any) => c.systemKey);
|
||||||
expect(systemCats.length).toBeGreaterThanOrEqual(6);
|
expect(systemCats.length).toBe(6);
|
||||||
|
const systemKeys = systemCats.map((c: any) => c.systemKey).sort();
|
||||||
|
expect(systemKeys).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
|
||||||
|
|
||||||
const settings = await ds.getRepository(SettingEntity).find();
|
const settings = await ds.getRepository(SettingEntity).find();
|
||||||
const keys = settings.map((s: any) => s.key);
|
const keys = settings.map((s: any) => s.key);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { InitSchema1700000000000 } from '../../backend/src/database/migrations/1
|
|||||||
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
|
import { SeedSystemData1700000000100 } from '../../backend/src/database/migrations/1700000000100-SeedSystemData';
|
||||||
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
|
||||||
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
|
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
|
||||||
|
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
import { UserEntity } from '../../backend/src/database/entities/user.entity';
|
||||||
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
|
||||||
@@ -27,6 +28,7 @@ describe('Migrations', () => {
|
|||||||
SeedSystemData1700000000100,
|
SeedSystemData1700000000100,
|
||||||
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
|
||||||
UpdateSystemCategoryKeys1700000000300,
|
UpdateSystemCategoryKeys1700000000300,
|
||||||
|
RepairCategorySchemaAndSystemCategories1700000000400,
|
||||||
],
|
],
|
||||||
migrationsRun: true,
|
migrationsRun: true,
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
@@ -48,12 +50,19 @@ describe('Migrations', () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds 6 system categories with unique system_keys', async () => {
|
it('seeds exactly six canonical system categories with unique keys', async () => {
|
||||||
const cats = await dataSource.getRepository(CategoryEntity).find();
|
const cats: any[] = await dataSource.getRepository(CategoryEntity).find();
|
||||||
const systemCats = cats.filter((c) => c.systemKey);
|
const systemCats = cats.filter((c) => c.systemKey);
|
||||||
expect(systemCats.length).toBeGreaterThanOrEqual(6);
|
expect(systemCats.length).toBe(6);
|
||||||
const keys = systemCats.map((c) => c.systemKey);
|
const keys = systemCats.map((c) => c.systemKey);
|
||||||
expect(new Set(keys).size).toBe(keys.length);
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
expect([...keys].sort()).toEqual(['CRY', 'HW', 'MSC', 'PWN', 'REV', 'WEB']);
|
||||||
|
for (const row of systemCats) {
|
||||||
|
expect(typeof row.iconPath).toBe('string');
|
||||||
|
expect(row.iconPath.length).toBeGreaterThan(0);
|
||||||
|
expect(typeof row.description).toBe('string');
|
||||||
|
expect(row.description.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds default settings', async () => {
|
it('seeds default settings', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user