feat: Challenges Page and Challenge Solve Modal 1.02

This commit is contained in:
OpenVelo Agent
2026-07-23 02:24:41 +00:00
parent ceebaa06aa
commit 7b7858317c
6 changed files with 347 additions and 131 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 { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
import { DatabaseInitService } from './database-init.service';
const ENTITIES = [
@@ -37,6 +38,7 @@ const MIGRATIONS = [
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
];
@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,
);
}
}
+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');
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);
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));
// hidden should be excluded
expect(enabledCardIds).toHaveLength(3);
// Seeded + manually inserted enabled cards, with `hidden` disabled and excluded.
expect(enabledCardIds.length).toBeGreaterThanOrEqual(3);
});
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')
.set('Authorization', `Bearer ${adminToken}`)
.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.
await request(app.getHttpServer())
.post(`/api/v1/challenges/${cardId}/solves`)
.set('Cookie', `csrf=${adminCsrf}`)
.set('X-CSRF-Token', adminCsrf)
.set('Authorization', `Bearer ${adminToken}`)
.send({ flag: list.body.columns[0].cards[0].name === 'alphacipher' ? 'flag{alpha}' : 'flag{zeta}' })
.send({ flag: flagToSubmit })
.expect(200);
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 { RepairCategorySchemaAndSystemCategories1700000000400 } from '../../backend/src/database/migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from '../../backend/src/database/migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from '../../backend/src/database/migrations/1700000000600-SeedSampleChallenges';
import { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SettingEntity } from '../../backend/src/database/entities/setting.entity';
@@ -31,6 +32,7 @@ describe('Migrations', () => {
UpdateSystemCategoryKeys1700000000300,
RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600,
],
migrationsRun: true,
synchronize: false,
@@ -146,10 +148,124 @@ describe('Migrations', () => {
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 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('mime_type')).toBe(true);
expect(names.has('size_bytes')).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');
});
});