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:
@@ -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).
|
|
||||||
@@ -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).
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user