feat: Admin Area General Settings and Categories 1.11

This commit is contained in:
OpenVelo Agent
2026-07-22 16:45:59 +00:00
parent b02369dd41
commit 9a6c805525
8 changed files with 431 additions and 91 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) {
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> {
+2
View File
@@ -15,6 +15,7 @@ import { InitSchema1700000000000 } from './migrations/1700000000000-InitSchema';
import { SeedSystemData1700000000100 } from './migrations/1700000000100-SeedSystemData';
import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { DatabaseInitService } from './database-init.service';
const ENTITIES = [
@@ -33,6 +34,7 @@ const MIGRATIONS = [
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
];
@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");`,
);
}
}
@@ -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 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 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 { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend/src/database/migrations/1700000000200-AddCategoryTimestampsAndUniqueAbbrev';
import { UpdateSystemCategoryKeys1700000000300 } from '../../backend/src/database/migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
@@ -27,6 +28,7 @@ describe('Migrations', () => {
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
],
migrationsRun: true,
synchronize: false,
@@ -48,12 +50,19 @@ describe('Migrations', () => {
]));
});
it('seeds 6 system categories with unique system_keys', async () => {
const cats = await dataSource.getRepository(CategoryEntity).find();
it('seeds exactly six canonical system categories with unique keys', async () => {
const cats: any[] = await dataSource.getRepository(CategoryEntity).find();
const systemCats = cats.filter((c) => c.systemKey);
expect(systemCats.length).toBeGreaterThanOrEqual(6);
expect(systemCats.length).toBe(6);
const keys = systemCats.map((c) => c.systemKey);
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 () => {