AI Implementation feature(893): Admin Area General Settings and Categories 1.11 #33

Merged
m0rph3us1987 merged 2 commits from feature-893-1784737977887 into dev 2026-07-22 16:46:02 +00:00
13 changed files with 566 additions and 104 deletions
-87
View File
@@ -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).
+37
View File
@@ -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> {
+2
View File
@@ -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()
@@ -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");`,
);
}
}
+4 -3
View File
@@ -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. |
+107
View File
@@ -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.
+9 -4
View File
@@ -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
View File
@@ -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
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-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();
}
});
});
+3 -1
View File
@@ -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);
+12 -3
View File
@@ -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 () => {