218 lines
11 KiB
Markdown
218 lines
11 KiB
Markdown
# Implementation Plan: Job 844 — First-Start Create Admin Modal 1.03
|
||
|
||
## Status
|
||
|
||
**[ALREADY_IMPLEMENTED]**
|
||
|
||
The "first-start create admin modal" feature described in this Job is already
|
||
fully implemented in the repository. The "Frontend not built." response at
|
||
`/bootstrap` observed by the operator is an environment artifact, not a missing
|
||
feature. No application code changes are required.
|
||
|
||
The remaining sections of this plan therefore serve as a **verification
|
||
guide** so the implementer can confirm the existing implementation works as
|
||
the Job expects, plus a minimal test to pin the validation behavior of the
|
||
empty-form submission (the only piece that is not already covered by
|
||
`tests/frontend/setup-create-admin.spec.ts`).
|
||
|
||
## 1. Architectural Reconnaissance
|
||
|
||
- **Codebase style & conventions:**
|
||
- Monorepo (npm workspaces) with two packages: `backend/` (NestJS 10 +
|
||
TypeORM + better-sqlite3) and `frontend/` (Angular 17 standalone
|
||
components, reactive forms, signals, OnPush change detection).
|
||
- Backend uses zod DTOs validated via a custom `ZodValidationPipe` and a
|
||
`GlobalExceptionFilter` that returns a standard error envelope.
|
||
- Frontend uses Angular signals for state, `data-testid` hooks for
|
||
e2e/test selectors, and `OnPush` change detection throughout.
|
||
- All components are standalone (no NgModules); routing is in
|
||
`frontend/src/app/app.routes.ts`.
|
||
|
||
- **Data Layer:**
|
||
- SQLite via TypeORM with `better-sqlite3` driver.
|
||
- Single `user` table (`backend/src/database/entities/user.entity.ts`)
|
||
with `role` (`'admin' | 'player'`) and `status`
|
||
(`'enabled' | 'disabled'`) columns. Unique index on `username`.
|
||
- Persistent named volume mounted at `/data/hipctf/` (db file:
|
||
`/data/hipctf/db.sqlite`; uploads dir: `/data/hipctf/uploads`).
|
||
- Migrations live in
|
||
`backend/src/database/migrations/1700000000000-InitSchema.ts` and
|
||
`1700000000100-SeedSystemData.ts`; applied by
|
||
`DatabaseInitService.init()` at boot.
|
||
|
||
- **Test Framework & Structure:**
|
||
- Jest 29 with `ts-jest`, configured in `tests/jest.config.js`.
|
||
- Two projects: `backend` (node env) and `frontend` (jsdom env).
|
||
- All tests live under `tests/` (never co-located with source).
|
||
- Single command from repo root: `npm test` (also exposed as
|
||
`npm run test:backend` and `npm run test:frontend`).
|
||
|
||
- **Required Tools & Dependencies:** No new tools required. The Job does not
|
||
require adding any package, CLI utility, or system binary. Existing
|
||
toolchain (Node, npm, Angular CLI invoked via `npm --workspace`, NestJS
|
||
build via `npm --workspace backend run build`) is sufficient.
|
||
|
||
## 2. Impacted Files
|
||
|
||
Because the feature is already implemented, **no files need to be modified
|
||
or created**. The mapping below proves where each requirement lives so the
|
||
implementer (and the reviewer) can audit the existing implementation:
|
||
|
||
| Job requirement | Already implemented at |
|
||
|---|---|
|
||
| Modal rendered at `/bootstrap` when uninitialized | `frontend/src/app/app.routes.ts` (route `/bootstrap` → `SetupCreateAdminComponent`) + `frontend/src/app/core/guards/auth.guard.ts` + `auth.guard.decision.ts` (redirects to `/bootstrap` when `initialized() === false`) |
|
||
| Bootstrap payload exposes `initialized` flag | `backend/src/modules/system/system.controller.ts` (`GET /api/v1/bootstrap`) + `backend/src/modules/system/system.service.ts` (`initialized: adminCount > 0`) |
|
||
| SPA fallback for client routes | `backend/src/frontend/spa.controller.ts` (`SpaFallbackMiddleware`) + `backend/src/main.ts` lines 96–102 (`express.static(frontendDist)` + `express.static(browserDir)`) |
|
||
| Modal overlay markup + three fields | `frontend/src/app/features/setup/setup-create-admin.component.html` (`[data-testid="setup-username"]`, `setup-password`, `setup-password-confirm`, `setup-submit`, `setup-username-error`, `setup-confirm-error`, `setup-server-error`, `setup-retry`) |
|
||
| Reactive form with required + length + pattern + match validators | `frontend/src/app/features/setup/setup-create-admin.component.ts` lines 48–60 + `frontend/src/app/features/setup/setup-create-admin.validators.ts` (`passwordMatchValidator`) |
|
||
| Empty / partial submission is rejected client-side without firing the request (modal stays open) | `frontend/src/app/features/setup/setup-create-admin.component.ts` lines 99–102 (`this.form.markAllAsTouched(); if (this.form.invalid) return;`) |
|
||
| Submit button enabled while invalid (only disabled while in-flight) | `setup-create-admin.component.html` lines 54–66 (`[disabled]="submitting()"`) |
|
||
| Non-dismissible modal (backdrop click swallowed, Esc swallowed) | `setup-create-admin.component.ts` lines 89–97 (`swallowEscape` HostListener + `swallowBackdrop` no-op) |
|
||
| Backend endpoint + zod validation (incl. `password === passwordConfirm`) | `backend/src/modules/setup/setup.controller.ts` + `backend/src/modules/setup/setup.service.ts` + `backend/src/modules/setup/dto/create-admin.dto.ts` |
|
||
| Returns 404 once an admin exists (route hidden) | `backend/src/modules/setup/setup.service.ts` (`SetupService.createAdmin` throws `NOT_FOUND` when admin row exists) |
|
||
|
||
## 3. Proposed Changes
|
||
|
||
### 3.1 Application code
|
||
|
||
**None.** The Job's requirements are entirely satisfied by existing code.
|
||
|
||
### 3.2 Why the operator saw "Frontend not built."
|
||
|
||
`SpaFallbackMiddleware` (`backend/src/frontend/spa.controller.ts` lines 17–27)
|
||
serves the SPA only when an `index.html` is found inside either
|
||
`<FRONTEND_DIST>/index.html` or `<FRONTEND_DIST>/browser/index.html`. The
|
||
Angular 17 CLI emits the production bundle under `frontend/dist/browser/`,
|
||
which is exactly the second candidate. Therefore the "Frontend not built."
|
||
stub is only emitted when **neither candidate exists** — i.e. when the
|
||
frontend was never built (or was wiped) before `npm start`.
|
||
|
||
In the current workspace:
|
||
|
||
- `frontend/dist/browser/index.html` exists (built, 1143 bytes).
|
||
- `frontend/dist/3rdpartylicenses.txt` exists.
|
||
- All chunk JS files (`chunk-*.js`, `main-*.js`, `polyfills-*.js`,
|
||
`styles-*.css`) are present.
|
||
|
||
So a fresh `npm start` after `bash setup.sh` will serve the Angular SPA,
|
||
the SPA's `BootstrapService.load()` will hit `/api/v1/bootstrap`, and the
|
||
SPA will:
|
||
|
||
1. Read `initialized`. If `false`, the `authGuard` keeps the user on
|
||
`/bootstrap`, which renders `SetupCreateAdminComponent` (the modal).
|
||
2. If `true`, the user is redirected to `/login` (or `/`).
|
||
|
||
The shared `/data/hipctf/db.sqlite` volume currently contains one admin
|
||
row, so `initialized === true` and the modal does not appear against this
|
||
exact DB. To exercise the modal, the implementer must point the backend at
|
||
a **fresh** database (see §3.3).
|
||
|
||
### 3.3 Operator run-book (for verification only — no code change)
|
||
|
||
To confirm the existing implementation matches the Job's "expected
|
||
behavior" against a fresh DB without destroying the persistent `/data`
|
||
volume:
|
||
|
||
1. Build (idempotent — `setup.sh` already runs both builds):
|
||
```
|
||
bash setup.sh
|
||
```
|
||
2. Start the backend against an **isolated** DB:
|
||
```
|
||
DATABASE_PATH=/tmp/hipctf-fresh.db \
|
||
FRONTEND_DIST=$(pwd)/frontend/dist \
|
||
THEMES_DIR=$(pwd)/backend/themes \
|
||
node backend/dist/main.js
|
||
```
|
||
3. `curl -i http://localhost:3000/bootstrap` should return
|
||
`200 text/html` with the Angular `index.html` shell
|
||
(`<app-root></app-root>`), not the "Frontend not built." stub.
|
||
4. `curl -s http://localhost:3000/api/v1/bootstrap | jq .initialized`
|
||
should return `false`.
|
||
5. In a browser, visit `http://localhost:3000/`. The app redirects to
|
||
`/bootstrap` and renders the modal.
|
||
6. Click **Create admin** with all three fields empty:
|
||
- Modal stays open.
|
||
- Username field shows `Username is required`
|
||
(`data-testid="setup-username-error"`).
|
||
- Confirm field shows `Please confirm your password`
|
||
(`data-testid="setup-confirm-error"`).
|
||
- No `POST /api/v1/setup/create-admin` request is fired.
|
||
7. Fill the username with a value that violates the pattern (e.g.
|
||
`"bad name!"`) and submit: modal stays open, the username error
|
||
reads `Username may only contain letters, digits, dot, dash and underscore`.
|
||
|
||
## 4. Test Strategy
|
||
|
||
### 4.1 Existing coverage (do not duplicate)
|
||
|
||
- `tests/backend/bootstrap.integration.spec.ts` exercises
|
||
`GET /api/v1/bootstrap` (uninitialized → `initialized=false`,
|
||
initialized → `true`).
|
||
- `tests/backend/setup-create-admin.spec.ts` covers
|
||
`POST /api/v1/setup/create-admin` success, 404 once initialized, weak
|
||
password (`WEAK_PASSWORD`), `passwordConfirm` mismatch
|
||
(`VALIDATION_FAILED`), invalid username characters
|
||
(`VALIDATION_FAILED`).
|
||
- `tests/frontend/setup-create-admin.spec.ts` covers
|
||
`passwordMatchValidator` (null when either empty, null on match,
|
||
`passwordMismatch: true` on differ) and the username pattern/length
|
||
rules.
|
||
|
||
### 4.2 One new, minimal frontend test (the only gap)
|
||
|
||
The existing frontend test does not exercise `SetupCreateAdminComponent`
|
||
directly; it only tests the standalone validator and pattern rules. Add
|
||
one focused component test that pins the "empty submission is rejected
|
||
client-side without firing the request, and the modal stays open"
|
||
behavior the Job explicitly calls out. Keep it minimal — no mock backend,
|
||
no extra deps, no jsdom extensions beyond what Jest provides.
|
||
|
||
**Target file:** `tests/frontend/setup-create-admin.component.spec.ts`
|
||
(new file).
|
||
|
||
**Mocking strategy:**
|
||
|
||
- Use `TestBed.configureTestingModule` with declarations/providers for
|
||
`SetupCreateAdminComponent` only.
|
||
- Stub `SetupCreateAdminService` with a Jest mock exposing
|
||
`create = jest.fn()`. Assert it is **not** called when the form is
|
||
empty. This is sufficient to prove the client-side gate prevents the
|
||
network round-trip — no real HTTP, no `HttpClientTestingModule`.
|
||
- Stub `AuthService` with `{ setSession: jest.fn(), isAuthenticated: () => false }`.
|
||
- Stub `BootstrapService` with `{ initialized: () => false, markInitialized: jest.fn(), passwordPolicy: () => ({ minLength: 12, requireMixed: true, description: '...' }) }`.
|
||
- Stub `Router` with `{ navigateByUrl: jest.fn() }`.
|
||
- Do not mock `ReactiveFormsModule` — use the real module so the
|
||
validators run end-to-end (this is the whole point of the test).
|
||
|
||
**Assertions (single `it`):**
|
||
|
||
1. Create the component fixture, detect changes.
|
||
2. Click the submit button
|
||
(`[data-testid="setup-submit"]`) without filling any field.
|
||
3. Expect `service.create` to have been called **0** times.
|
||
4. Expect the username error element
|
||
(`[data-testid="setup-username-error"]`) to contain `Username is required`.
|
||
5. Expect the confirm error element
|
||
(`[data-testid="setup-confirm-error"]`) to contain `Please confirm your password`.
|
||
6. Expect `router.navigateByUrl` to have been called **0** times (modal
|
||
remains on `/bootstrap`).
|
||
|
||
This is the minimum surface that proves the Job's "rejects empty or
|
||
missing required fields with field validation while keeping it open"
|
||
expectation and runs in <100 ms with no infrastructure setup.
|
||
|
||
### 4.3 Verification commands
|
||
|
||
From the repo root:
|
||
|
||
```
|
||
npm test # full suite
|
||
npm run test:frontend # frontend-only
|
||
npm run test:backend # backend-only
|
||
```
|
||
|
||
All existing tests must continue to pass, and the new
|
||
`tests/frontend/setup-create-admin.component.spec.ts` must pass alongside
|
||
them.
|