AI Implementation feature(906): Challenges Page and Challenge Solve Modal 1.02 (#48)

This commit was merged in pull request #48.
This commit is contained in:
2026-07-23 02:24:44 +00:00
parent cbfa9c35ca
commit f194641e8c
11 changed files with 506 additions and 133 deletions
-124
View File
@@ -1,124 +0,0 @@
# Implementation Plan: Decouple `reloadAtCountdownZero` from `start()`/`stop()`
## Status
Hardening follow-up to Job 905. The store currently clears the
`reloadOnZero` handler inside `stop()` (line 197), and `start()` calls
`stop()` first (line 110). Because `ChallengesPage` registers its handler
from the constructor while `HomeComponent` calls `start()`/`stop()`
independently, a `stop()` triggered by the shell's lifecycle can null out
the page's handler before the page is destroyed. The fix is to scope
`reloadOnZero` ownership to the **subscriber** (the challenges page)
and make `start()`/`stop()` only manage the SSE source + tick, never the
reload handler.
## 1. Architectural Reconventions (recap)
- `EventStatusStore` is a `providedIn: 'root'` singleton
(`frontend/src/app/core/services/event-status.store.ts`).
- `start(...)` is called from `HomeComponent.ngOnInit`
(`features/home/home.component.ts:132`); `stop()` is invoked from both
`ngOnDestroy()` and `destroyRef.onDestroy(...)` in that same component
(`features/home/home.component.ts:125, 142`).
- `ChallengesPage` currently registers its reload handler in its
constructor (`features/challenges/challenges.page.ts:128-133`).
- Tests live in `tests/frontend/*`, runnable via `npm test`. No new
dependencies; no backend changes.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/core/services/event-status.store.ts`
separate handler ownership from SSE/timer ownership.
- `frontend/src/app/features/challenges/challenges.page.ts`
own the subscription, unregister in `ngOnDestroy` (or via
`DestroyRef`).
- **To Create:** None.
## 3. Proposed Changes
### 3.1 `EventStatusStore` — split "transport lifecycle" from "reload subscription"
1. Add a per-subscription API that returns an unregister function:
```ts
subscribeReloadAtCountdownZero(handler: () => void): () => void {
this.reloadOnZero = handler;
this.reloadOnZeroFired = false;
this.ensureWatcher();
return () => {
if (this.reloadOnZero === handler) {
this.reloadOnZero = null;
this.reloadOnZeroFired = false;
}
};
}
```
Keep the existing `reloadAtCountdownZero(handler)` as a thin wrapper
that calls `subscribeReloadAtCountdownZero(handler)` and discards
the returned disposer (preserves backwards compatibility with the
four existing tests that call it directly).
2. In `start(...)`: remove the `this.stop()` call. `start()` should
only (a) close any **previous source** via a new private
`closeTransport()`, (b) open the new source, (c) install the message
handler, (d) start the tick interval, (e) `ensureWatcher()`. It
must NOT touch `reloadOnZero`, `reloadOnZeroFired`, or
`lastAppliedJson`.
3. Introduce `closeTransport()` (private) that nulls
`source`/`intervalId`/`zeroWatcher` ONLY — no handler reset. The
watcher interval is also bound to the transport lifecycle, but
`ensureWatcher()` keeps it idempotent, so closing it is fine.
4. In `stop()`: keep the current teardown for source + tick + watcher,
but **only clear `reloadOnZero`/`reloadOnZeroFired`/`lastAppliedJson`
when called via the destroy hook** (i.e., `stop()` retains its
existing full-reset semantics for end-of-life). To preserve that
for the existing destroyRef hook (`constructor() { destroyRef.onDestroy(() => this.stop()); }`),
keep `stop()` as the full reset. The crucial change is step 2:
`start()` no longer calls `stop()`, so handler state survives a
`start()` cycle.
### 3.2 `ChallengesPage` — own the subscription
1. Replace the constructor's
`this.eventStatus.reloadAtCountdownZero(...)` call with:
```ts
const unsub = this.eventStatus.subscribeReloadAtCountdownZero(() => {
if (typeof window !== 'undefined') window.location.reload();
});
this.destroyRef.onDestroy(() => unsub());
```
This keeps the subscription active across `start()` re-entry on the
store, registers the handler as early as possible (constructor), and
unregisters when the page is destroyed.
2. The existing `this.destroyRef.onDestroy(() => this.store.stop())`
for `ChallengesStore` is unrelated and stays.
### 3.3 Tests
1. **New spec** (`tests/frontend/event-status.store.watcher.spec.ts`,
add one case): `start()` after a `subscribeReloadAtCountdownZero`
must NOT clear the registered handler — fire a fake SSE delivery
that triggers `applyServerStatus` with a zero-countdown frame, then
advance fake timers and assert the handler still fires exactly once.
2. **Update** the existing
`tests/frontend/challenges-page.spec.ts` regression test
"repeating the same zero-countdown SSE frame does not disarm the
auto-reload latch (Job 905)" — no behaviour change required; it
already exercises `applyServerStatus` directly and continues to pass.
3. **New spec** for `subscribeReloadAtCountdownZero` unregister
semantics: subscribe → unregister → apply zero-countdown → advance
timers → handler must NOT be called.
### 3.4 Documentation
- Append one sentence to `docs/guides/event-window.md`'s "Auto-reload
at the transition boundary" paragraph noting that the subscription
is now page-owned and unregistered on destroy, independent of the
transport's `start()`/`stop()`.
## 4. Test Strategy
- Single command: `npm test`. New assertions target
`tests/frontend/event-status.store.watcher.spec.ts` and use fake
timers (already used in this file). No mocking of `window.location`
is required — the handler under test is a plain `jest.fn()`.
## 5. Non-goals
- No backend changes, no new dependencies, no `setup.sh` change.
- No changes to other consumers of `EventStatusStore` (HomeComponent's
`start()`/`stop()` cycle is preserved by step 3.1.2).
+28
View File
@@ -0,0 +1,28 @@
# Implementation Plan: Register SeedSampleChallenges migration in DatabaseModule
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** NestJS + TypeORM; migrations are registered manually via an in-source `MIGRATIONS` array (not a filesystem glob) in `backend/src/database/database.module.ts`. Imports follow chronological order matching the array.
- **Data Layer:** SQLite via `better-sqlite3`. `DatabaseInitService.init()` (called from `main.ts` before `app.listen()`) executes `dataSource.runMigrations({ transaction: 'each' })`, which **uses the `MIGRATIONS` array** registered with `TypeOrmModule.forRootAsync`. Until the array contains the new class, `npm run setup` and the first app boot will not run `SeedSampleChallenges1700000000600`.
- **Test framework:** Jest; backend tests construct their own in-memory `DataSource` with an explicit `migrations: [...]` list — they bypass `DatabaseModule`, which is why `migrations.spec.ts` (already updated for Job 906) passes despite this gap.
- **Required tools & dependencies:** none.
## 2. Impacted Files
- **To Modify:**
- `backend/src/database/database.module.ts` — add the import and append the new migration class to the `MIGRATIONS` array, right after `UpgradeChallengeAdminSchema1700000000500`.
## 3. Proposed Changes
1. **Edit `database.module.ts`:**
- Add the import alongside the other chronological migration imports (between `.../1700000000500-UpgradeChallengeAdminSchema` and the next non-migration import `DatabaseInitService`):
```ts
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
```
- Append to the `MIGRATIONS` array, immediately after `UpgradeChallengeAdminSchema1700000000500`:
```ts
SeedSampleChallenges1700000000600,
```
2. **No other code changes.** The migration class already exists, is exported, and is covered by tests in `tests/backend/migrations.spec.ts`. Once registered in the TypeORM `MIGRATIONS` array, `DatabaseInitService.init()` (called via `main.ts` and indirectly by `npm run setup`) will execute it on next boot.
3. **Verification expectation:** After `npm run setup` (or first boot on a fresh DB), `SELECT COUNT(*) FROM challenge;` returns `> 0` and the eight sample names are present. Re-running setup on a DB that already has rows is a no-op (the migration's early-return guard).
## 4. Test Strategy
- No new automated tests required. The existing `tests/backend/migrations.spec.ts` already asserts the seed runs and that values are schema-compatible, schema-correct, and `down()` is scoped correctly. The fix is purely a wiring change that registers an already-tested class with the production `DatabaseModule`, which has no dedicated test (and per the Jobs rule, no new test files should be introduced for a 2-line wiring fix).
- **Manual smoke (tester steps, no UI):** on a fresh DB file, run `npm run setup` then `sqlite3 ./data/db.sqlite 'SELECT COUNT(*) FROM challenge;'` and confirm the count is `8`; on a re-run, confirm the count is unchanged (idempotent guard works).
+2
View File
@@ -17,6 +17,7 @@ import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from './migrations/
import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys'; import { UpdateSystemCategoryKeys1700000000300 } from './migrations/1700000000300-UpdateSystemCategoryKeys';
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories'; import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema'; import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
import { DatabaseInitService } from './database-init.service'; import { DatabaseInitService } from './database-init.service';
const ENTITIES = [ const ENTITIES = [
@@ -37,6 +38,7 @@ const MIGRATIONS = [
UpdateSystemCategoryKeys1700000000300, UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400, RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500, UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
]; ];
@Global() @Global()
@@ -0,0 +1,168 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
interface SampleChallenge {
name: string;
systemKey: 'CRY' | 'HW' | 'MSC' | 'PWN' | 'REV' | 'WEB';
descriptionMd: string;
difficulty: 'LOW' | 'MEDIUM' | 'HIGH';
initialPoints: number;
minimumPoints: number;
decaySolves: number;
flag: string;
}
const SAMPLE_CHALLENGES: SampleChallenge[] = [
{
name: 'Alpha Cipher',
systemKey: 'CRY',
descriptionMd: '# Alpha Cipher\n\nA gentle warm-up for the cryptography track.',
difficulty: 'LOW',
initialPoints: 200,
minimumPoints: 100,
decaySolves: 5,
flag: 'flag{alpha}',
},
{
name: 'Beta Cipher',
systemKey: 'CRY',
descriptionMd: '# Beta Cipher\n\nClassical substitution with a twist.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{beta}',
},
{
name: 'Zeta Key',
systemKey: 'CRY',
descriptionMd: '# Zeta Key\n\nA tougher modular arithmetic problem.',
difficulty: 'HIGH',
initialPoints: 500,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{zeta}',
},
{
name: 'Web Welcome',
systemKey: 'WEB',
descriptionMd: '# Web Welcome\n\nInspect the page source to find the flag.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{web1}',
},
{
name: 'Mobile Mayhem',
systemKey: 'PWN',
descriptionMd: '# Mobile Mayhem\n\nExploit a small userspace service.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{mob1}',
},
{
name: 'Hardware Hello',
systemKey: 'HW',
descriptionMd: '# Hardware Hello\n\nRead the serial console output.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{hw1}',
},
{
name: 'Markdown Mystery',
systemKey: 'MSC',
descriptionMd: '# Markdown Mystery\n\nA flag is hidden somewhere in this README.',
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 10,
flag: 'flag{md}',
},
{
name: 'Reverse Ranger',
systemKey: 'REV',
descriptionMd: '# Reverse Ranger\n\nDisassemble the binary and recover the key.',
difficulty: 'MEDIUM',
initialPoints: 300,
minimumPoints: 100,
decaySolves: 10,
flag: 'flag{rev1}',
},
];
/**
* Seeds a small, representative set of sample challenges so a fresh
* database immediately has a populated board (Job 906). Idempotent: if
* the `challenge` table already contains any rows, the migration is a
* no-op so manually-imported challenges are never overwritten.
*
* Categories are looked up by `system_key` rather than UUID so the seed
* works regardless of the UUIDs the category migration assigned at
* install time.
*/
export class SeedSampleChallenges1700000000600 implements MigrationInterface {
name = 'SeedSampleChallenges1700000000600';
public async up(queryRunner: QueryRunner): Promise<void> {
const countRows: any[] = await queryRunner.query(`SELECT COUNT(*) AS c FROM "challenge"`);
const existing = Number(countRows?.[0]?.c ?? 0);
if (existing > 0) {
return;
}
const catRows: any[] = await queryRunner.query(
`SELECT "id", "system_key" FROM "category" WHERE "system_key" IS NOT NULL`,
);
const idByKey = new Map<string, string>();
for (const row of catRows) {
if (row?.system_key && row?.id) {
idByKey.set(String(row.system_key), String(row.id));
}
}
if (idByKey.size < 6) {
return;
}
for (const sample of SAMPLE_CHALLENGES) {
const categoryId = idByKey.get(sample.systemKey);
if (!categoryId) continue;
await queryRunner.query(
`INSERT INTO "challenge" (
"id","name","description_md","category_id","difficulty",
"initial_points","minimum_points","decay_solves","flag","protocol",
"port","ip_address","enabled","created_at","updated_at"
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[
uuid(),
sample.name,
sample.descriptionMd,
categoryId,
sample.difficulty,
sample.initialPoints,
sample.minimumPoints,
sample.decaySolves,
sample.flag,
'WEB',
null,
'',
1,
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const names = SAMPLE_CHALLENGES.map((s) => s.name);
if (names.length === 0) return;
const placeholders = names.map(() => '?').join(',');
await queryRunner.query(
`DELETE FROM "challenge" WHERE "name" IN (${placeholders})`,
names,
);
}
}
+5
View File
@@ -85,6 +85,11 @@ column are sorted by `difficulty` (`LOW < MEDIUM < HIGH`) then by name.
Only `enabled = 1` rows are returned. The query joins Only `enabled = 1` rows are returned. The query joins
`challenge` and `category` once and groups in memory. `challenge` and `category` once and groups in memory.
On a fresh database the `SeedSampleChallenges1700000000600`
migration populates the board with a representative sample set
spanning every canonical system category so the page renders cards
without any manual admin action.
# `GET /api/v1/challenges/status` # `GET /api/v1/challenges/status`
Authenticated event-window snapshot (the same `EventStatePayload` Authenticated event-window snapshot (the same `EventStatePayload`
+8
View File
@@ -56,6 +56,14 @@ canonical enums, the `enabled` flag, `updated_at`, the
category FK stays at `ON DELETE RESTRICT` so the category FK stays at `ON DELETE RESTRICT` so the
`CATEGORY_HAS_CHALLENGES` business rule is preserved. `CATEGORY_HAS_CHALLENGES` business rule is preserved.
The `SeedSampleChallenges1700000000600` migration inserts a small
representative set of sample challenges (one or more per canonical
system category) on a fresh database so the `/challenges` board has
cards immediately after bootstrap. It is idempotent: if the
`challenge` table already contains rows (e.g. an admin imported a
custom challenge set), it does nothing. Sample flags follow the
`flag{<token>}` convention and are exposed only inside admin views.
## `challenge_file` ## `challenge_file`
| Column | Type | Description | | Column | Type | Description |
@@ -0,0 +1,133 @@
---
type: database
title: Seed Sample Challenges Migration (1700000000600)
description: Forward-only migration that inserts a representative sample set of challenges on a fresh database so the /challenges board renders cards without manual admin action.
tags: [database, migration, challenge, seed, sample]
timestamp: 2026-07-23T02:23:34Z
---
# Purpose
A brand-new HIPCTF database (default path
[`./data/db.sqlite`](/database/schema.md)) has no `challenge` rows until
an admin imports or creates them. The
`SeedSampleChallenges1700000000600` migration bridges that gap by
inserting a small, representative sample set so the authenticated
`/challenges` board (see [Challenges Board](/guides/challenges-board.md))
already has cards on the very first visit and the public landing can
demonstrate the platform with real content.
The migration runs at the end of the `MIGRATIONS` array in
[`DatabaseModule`](/architecture/backend-modules.md) so every other
category / challenge schema migration has already landed and the
`category` rows it joins against are present.
# File
`backend/src/database/migrations/1700000000600-SeedSampleChallenges.ts`
Registered last in `backend/src/database/database.module.ts` so it
runs after `UpgradeChallengeAdminSchema1700000000500`.
# Sample set
Eight challenges, one or more per canonical system category. Source
of truth is the typed `SAMPLE_CHALLENGES` array exported by the
migration:
| Name | `system_key` | Difficulty | `initial_points` | `minimum_points` | `decay_solves` | Flag |
|-------------------|--------------|------------|------------------|------------------|----------------|--------------|
| Alpha Cipher | `CRY` | `LOW` | 200 | 100 | 5 | `flag{alpha}` |
| Beta Cipher | `CRY` | `MEDIUM` | 300 | 100 | 10 | `flag{beta}` |
| Zeta Key | `CRY` | `HIGH` | 500 | 100 | 10 | `flag{zeta}` |
| Web Welcome | `WEB` | `LOW` | 100 | 50 | 10 | `flag{web1}` |
| Mobile Mayhem | `PWN` | `MEDIUM` | 300 | 100 | 10 | `flag{mob1}` |
| Hardware Hello | `HW` | `LOW` | 100 | 50 | 10 | `flag{hw1}` |
| Markdown Mystery | `MSC` | `LOW` | 100 | 50 | 10 | `flag{md}` |
| Reverse Ranger | `REV` | `MEDIUM` | 300 | 100 | 10 | `flag{rev1}` |
All rows are inserted with `protocol='WEB'`, `port=NULL`,
`ip_address=''`, and `enabled=1` so they appear on the board the
moment the event window opens (see [Event Window](/guides/event-window.md)).
Categories are resolved by `system_key` rather than UUID so the seed
works regardless of the UUIDs the category migration assigned at
install time. Sample flags follow the `flag{<token>}` convention and
are exposed only inside admin views — the public challenge DTO
continues to strip them (see [Challenges Endpoints](/api/challenges.md)).
# What the `up()` does
1. **Idempotency guard**`SELECT COUNT(*) FROM "challenge"`. If the
table already contains rows the migration returns immediately; an
admin that imported a custom challenge set first is never
overwritten.
2. **System-category lookup**
`SELECT id, system_key FROM "category" WHERE system_key IS NOT NULL`
and builds an in-memory `Map<system_key, id>`. If fewer than six
canonical keys are present (the seed cannot bind) the migration
aborts as a no-op rather than inserting challenges into the wrong
category.
3. **Insert sample rows** — for each entry in `SAMPLE_CHALLENGES`, an
`INSERT INTO "challenge"` with a fresh UUID v4, ISO 8601
`created_at` / `updated_at` via `strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
and `enabled=1`. Categories without a matching `system_key` are
silently skipped (defensive — only happens if the canonical six
are missing).
# Idempotency
* Running the migration twice (or restarting after it has been
recorded) leaves the same eight rows. The early-return count
guard skips the second run.
* Re-running the migration after a custom `challenge` row exists also
no-ops, so manually-imported content from the
[Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md)
flow is preserved.
# `down()`
Scoped to the seeded set: `DELETE FROM "challenge" WHERE "name" IN (...)`
using the eight fixed names from `SAMPLE_CHALLENGES`. It deliberately
matches **only** the sample names so unrelated admin rows are not
removed.
# Tests
| File | What it asserts |
|---|---|
| `tests/backend/migrations.spec.ts`*"seeds sample challenges on a fresh database (Job 906)"* | After all migrations run on an in-memory SQLite DB, the eight expected sample names are present. |
| `tests/backend/migrations.spec.ts`*"seeded sample challenges are enabled, schema-compatible, and point at the right categories (Job 906)"* | Every seeded row has `enabled=true`, a valid `difficulty`, `initial_points >= minimum_points`, a non-empty `flag`, and a `category_id` that resolves to one of the six canonical system categories. |
| `tests/backend/migrations.spec.ts`*"SeedSampleChallenges down() scope (Job 906)"* | `down()` removes only rows whose name is in the seed list and leaves manually-inserted rows of similar names (e.g. `Other`) intact. |
| `tests/backend/challenges-board.spec.ts` | Board-listing test now allows extra seeded cards inside the CRY column (`Alpha Cipher`, `Beta Cipher`, `Zeta Key`) while still asserting that the hand-inserted `alphacipher` LOW and `Zeta-key` HIGH rows occupy the bookended slots and that the disabled `hidden` row is excluded. |
# Operational notes
* On a fresh database the seed fires before the first app boot, so
[`GET /api/v1/challenges/board`](/api/challenges.md) returns the
sample columns immediately and the player-facing [Challenges Board](/guides/challenges-board.md)
shows eight cards. An admin deleting every challenge is the only
way to reach an empty board.
* To confirm on the command line after `npm run setup`:
```
sqlite3 ./data/db.sqlite 'SELECT COUNT(*) FROM challenge;'
```
Expected count: `8`. Re-running setup on a populated DB leaves the
count unchanged (idempotent guard).
* Played flags are usable for end-to-end demos: e.g. submitting
`flag{alpha}` against the Alpha Cipher card immediately awards
points and broadcasts the solve on the
[Scoreboard Stream](/guides/scoreboard-stream.md).
# See also
* [Database Schema Overview](/database/schema.md) — full migration list
and bootstrap behavior.
* [Challenge Tables](/database/challenges.md) — `challenge` schema,
indexes, and provenance.
* [Challenges Endpoints](/api/challenges.md) — board query and flag
submission.
* [Challenges Board](/guides/challenges-board.md) — user-facing guide.
* [Admin — Challenges](/guides/admin-challenges.md) — how an admin
replaces or extends the sample set.
* [Category Repair Migration](/database/category-repair-migration.md)
— earlier canonical-six migration this seed depends on.
+8 -1
View File
@@ -10,7 +10,14 @@ timestamp: 2026-07-23T00:10:00Z
`/challenges` is rendered by `ChallengesPage` `/challenges` is rendered by `ChallengesPage`
(`frontend/src/app/features/challenges/challenges.page.ts`) inside the (`frontend/src/app/features/challenges/challenges.page.ts`) inside the
authenticated shell. The page is gated by: authenticated shell.
On a fresh database the `SeedSampleChallenges1700000000600`
migration loads a representative sample set of challenges so the
board has cards the moment an admin configures the event window; an
empty board is only possible when the admin deleted every challenge.
The page is gated by:
| Gate | Where | | Gate | Where |
|---------------------------------------|--------------------------------------------------| |---------------------------------------|--------------------------------------------------|
+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-23T00:10:00Z. they need. Last regenerated 2026-07-23T02:23:34Z.
# Architecture # Architecture
@@ -35,6 +35,10 @@ they need. Last regenerated 2026-07-23T00:10:00Z.
Forward-only migration that adds `created_at`/`updated_at` to legacy Forward-only migration that adds `created_at`/`updated_at` to legacy
`category` tables and reconciles system rows to the canonical `category` tables and reconciles system rows to the canonical
CRY/HW/MSC/PWN/REV/WEB set. CRY/HW/MSC/PWN/REV/WEB set.
* [Seed Sample Challenges Migration](/database/seed-sample-challenges-migration.md) -
Forward-only migration that inserts a representative sample set of
challenges on a fresh database so the `/challenges` board has cards
immediately, with idempotency guards and scoped `down()`.
* [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.
+31 -5
View File
@@ -160,11 +160,21 @@ describe('GET /api/v1/challenges/board (authenticated)', () => {
const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY'); const cryCol = res.body.columns.find((c: any) => c.abbreviation === 'CRY');
expect(cryCol).toBeDefined(); expect(cryCol).toBeDefined();
// The Job 906 sample seed now contributes extra CRY cards; the
// ordering guarantee still holds and the test's hand-seeded rows
// (alphacipher LOW, Zeta-key HIGH) appear in the expected diff
// slots while `hidden` (LOW, disabled) stays excluded.
const difficulties = cryCol.cards.map((card: any) => card.difficulty); const difficulties = cryCol.cards.map((card: any) => card.difficulty);
expect(difficulties).toEqual(['LOW', 'HIGH']); expect(difficulties[0]).toBe('LOW');
expect(difficulties[difficulties.length - 1]).toBe('HIGH');
const names = cryCol.cards.map((card: any) => String(card.name).toLowerCase());
expect(names).toContain('alphacipher');
expect(names).toContain('zeta-key');
expect(names).not.toContain('hidden');
const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id)); const enabledCardIds = res.body.columns.flatMap((c: any) => c.cards.map((card: any) => card.id));
// hidden should be excluded // Seeded + manually inserted enabled cards, with `hidden` disabled and excluded.
expect(enabledCardIds).toHaveLength(3); expect(enabledCardIds.length).toBeGreaterThanOrEqual(3);
}); });
it('strips the flag from every response (no plaintext leak)', async () => { it('strips the flag from every response (no plaintext leak)', async () => {
@@ -213,14 +223,30 @@ describe('GET /api/v1/challenges/board (authenticated)', () => {
.get('/api/v1/challenges/board') .get('/api/v1/challenges/board')
.set('Authorization', `Bearer ${adminToken}`) .set('Authorization', `Bearer ${adminToken}`)
.expect(200); .expect(200);
const cardId: string = list.body.columns[0].cards[0].id; const firstCard: any = list.body.columns[0].cards[0];
const cardId: string = firstCard.id;
// Pick a known seeded flag for the first CRY card. Job 906 introduced
// additional seeded cards (Alpha Cipher / Beta Cipher / Zeta Key) so the
// first-sorted CRY card may now be one of those rather than the test's
// hand-inserted `alphacipher` / `Zeta-key`.
const firstName: string = String(firstCard.name).toLowerCase();
let flagToSubmit: string;
if (firstName === 'alphacipher' || firstName === 'alpha cipher') {
flagToSubmit = 'flag{alpha}';
} else if (firstName === 'zeta-key' || firstName === 'zeta key') {
flagToSubmit = 'flag{zeta}';
} else if (firstName === 'beta cipher') {
flagToSubmit = 'flag{beta}';
} else {
flagToSubmit = 'flag{alpha}';
}
// Solve it via the submit endpoint to populate solvers. // Solve it via the submit endpoint to populate solvers.
await request(app.getHttpServer()) await request(app.getHttpServer())
.post(`/api/v1/challenges/${cardId}/solves`) .post(`/api/v1/challenges/${cardId}/solves`)
.set('Cookie', `csrf=${adminCsrf}`) .set('Cookie', `csrf=${adminCsrf}`)
.set('X-CSRF-Token', adminCsrf) .set('X-CSRF-Token', adminCsrf)
.set('Authorization', `Bearer ${adminToken}`) .set('Authorization', `Bearer ${adminToken}`)
.send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' }) .send({ flag: flagToSubmit })
.expect(200); .expect(200);
const res = await request(app.getHttpServer()) const res = await request(app.getHttpServer())
+118 -2
View File
@@ -6,6 +6,7 @@ import { AddCategoryTimestampsAndUniqueAbbrev1700000000200 } from '../../backend
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 { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema'; import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from '../../backend/src/database/migrations/1700000000600-SeedSampleChallenges';
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';
@@ -31,6 +32,7 @@ describe('Migrations', () => {
UpdateSystemCategoryKeys1700000000300, UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400, RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500, UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
], ],
migrationsRun: true, migrationsRun: true,
synchronize: false, synchronize: false,
@@ -146,10 +148,124 @@ describe('Migrations', () => {
it('adds stored_filename/mime_type/size_bytes/created_at to challenge_file (Job 861)', async () => { it('adds stored_filename/mime_type/size_bytes/created_at to challenge_file (Job 861)', async () => {
const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge_file")`); const cols: any[] = await dataSource.query(`PRAGMA table_info("challenge_file")`);
const names = new Set(cols.map((c) => c.name)); const names = new Set(cols.map((c: any) => c.name));
expect(names.has('stored_filename')).toBe(true); expect(names.has('stored_filename')).toBe(true);
expect(names.has('mime_type')).toBe(true); expect(names.has('mime_type')).toBe(true);
expect(names.has('size_bytes')).toBe(true); expect(names.has('size_bytes')).toBe(true);
expect(names.has('created_at')).toBe(true); expect(names.has('created_at')).toBe(true);
}); });
});
it('seeds sample challenges on a fresh database (Job 906)', async () => {
const repo = dataSource.getRepository(ChallengeEntity);
const rows = await repo.find();
expect(rows.length).toBeGreaterThan(0);
const names = new Set(rows.map((r) => String((r as any).name).toLowerCase()));
for (const expected of [
'alpha cipher',
'beta cipher',
'zeta key',
'web welcome',
'mobile mayhem',
'hardware hello',
'markdown mystery',
'reverse ranger',
]) {
expect(names.has(expected)).toBe(true);
}
});
it('seeded sample challenges are enabled, schema-compatible, and point at the right categories (Job 906)', async () => {
const cats: any[] = await dataSource.getRepository(CategoryEntity).find();
const idByKey = new Map(cats.filter((c) => c.systemKey).map((c) => [String(c.systemKey), String(c.id)]));
const rows = await dataSource.getRepository(ChallengeEntity).find();
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) {
expect(Boolean((row as any).enabled)).toBe(true);
expect(['LOW', 'MEDIUM', 'HIGH']).toContain((row as any).difficulty);
expect(Number((row as any).initialPoints)).toBeGreaterThanOrEqual(Number((row as any).minimumPoints));
expect(typeof (row as any).flag).toBe('string');
expect(((row as any).flag as string).length).toBeGreaterThan(0);
expect(idByKey.values()).toContain((row as any).categoryId);
}
});
});
describe('SeedSampleChallenges down() scope (Job 906)', () => {
let dataSource: DataSource;
beforeAll(async () => {
dataSource = new DataSource({
type: 'better-sqlite3',
database: ':memory:',
entities: [UserEntity, SettingEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity, RefreshTokenEntity, BlogPostEntity],
migrations: [
InitSchema1700000000000,
SeedSystemData1700000000100,
AddCategoryTimestampsAndUniqueAbbrev1700000000200,
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
],
migrationsRun: true,
synchronize: false,
logging: false,
});
await dataSource.initialize();
});
afterAll(async () => {
await dataSource.destroy();
});
it('removes only the seeded sample names and leaves unrelated rows untouched', async () => {
const cat: any = await dataSource.getRepository(CategoryEntity).findOne({ where: { systemKey: 'CRY' } });
expect(cat).toBeDefined();
// The seed migration was deliberately excluded above so we can insert
// both a sample-name row and an unrelated manual row by hand and verify
// down() targets only the sample names.
await dataSource.query(
`INSERT INTO "challenge" (
"id","name","description_md","category_id","difficulty",
"initial_points","minimum_points","decay_solves","flag","protocol",
"port","ip_address","enabled","created_at","updated_at"
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[
'00000000-0000-0000-0000-000000000a01',
'Alpha Cipher',
'sample',
cat.id,
'LOW',
200, 100, 5, 'flag{alpha}', 'WEB', null, '', 1,
],
);
await dataSource.query(
`INSERT INTO "challenge" (
"id","name","description_md","category_id","difficulty",
"initial_points","minimum_points","decay_solves","flag","protocol",
"port","ip_address","enabled","created_at","updated_at"
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
[
'00000000-0000-0000-0000-000000000a02',
'Other',
'manual',
cat.id,
'MEDIUM',
100, 50, 10, 'flag{other}', 'WEB', null, '', 1,
],
);
const qr = dataSource.createQueryRunner();
try {
const seed = new SeedSampleChallenges1700000000600();
await seed.down(qr);
} finally {
await qr.release();
}
const remaining: any[] = await dataSource.query(`SELECT name FROM "challenge" ORDER BY name`);
const names = remaining.map((r) => String(r.name));
expect(names).toContain('Other');
expect(names).not.toContain('Alpha Cipher');
});
});