AI Implementation feature(844): First-Start Create Admin Modal 1.03 #6
@@ -1,191 +0,0 @@
|
|||||||
# Implementation Plan: First-Start Create Admin Modal — Empty-Field Validation Exercise
|
|
||||||
|
|
||||||
## Status: NOT ALREADY IMPLEMENTED
|
|
||||||
|
|
||||||
The feature is **partially** in place. The `SetupCreateAdminComponent` (the
|
|
||||||
modal loaded by the `/bootstrap` route) already contains reactive-form
|
|
||||||
validators (required / minlength / maxlength / pattern on `username`,
|
|
||||||
required on `password` + `passwordConfirm`, plus the group-level
|
|
||||||
`passwordMatchValidator`), and `submit()` already calls
|
|
||||||
`form.markAllAsTouched()` before bailing out on invalid forms. However, the
|
|
||||||
**submit button is hard-disabled while the form is invalid**
|
|
||||||
(`setup-create-admin.component.html:57`):
|
|
||||||
`[disabled]="submitting() || form.invalid"`, so the user can never actually
|
|
||||||
*click* submit while fields are empty/missing — the empty-field validation
|
|
||||||
path is unreachable. The job requires that submitting an empty / partially
|
|
||||||
filled form keeps the overlay open and surfaces field validation *without*
|
|
||||||
creating an administrator; today that interaction cannot be exercised.
|
|
||||||
|
|
||||||
The "Already Implemented Check" therefore does **not** pass and a code
|
|
||||||
change is required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:**
|
|
||||||
- Monorepo: NestJS REST API in `backend/` + Angular 20 standalone SPA in
|
|
||||||
`frontend/`. Workspace orchestration via root `package.json`
|
|
||||||
(`workspaces: ["backend", "frontend"]`).
|
|
||||||
- TypeScript strict mode (`tsconfig.base.json`), `OnPush` change detection
|
|
||||||
on every component, `inject()`-based DI, Signals + `computed()` +
|
|
||||||
`effect()`.
|
|
||||||
- Reactive forms via `FormBuilder.nonNullable.group`, custom validators
|
|
||||||
kept in standalone files (`setup-create-admin.validators.ts`).
|
|
||||||
- `@if` / `@for` new control-flow syntax (no `*ngIf`/`*ngFor`).
|
|
||||||
- **Data Layer:** SQLite via TypeORM (`backend/src/database/entities/user.entity.ts`,
|
|
||||||
`setting.entity.ts`). No schema migration is required for this job — the
|
|
||||||
bug is purely client-side UX gating.
|
|
||||||
- **Test Framework & Structure:**
|
|
||||||
- Jest 29 with two projects defined in `tests/jest.config.js`:
|
|
||||||
`backend` (node) and `frontend` (jsdom).
|
|
||||||
- Tests live under `tests/backend/` and `tests/frontend/`, never inside
|
|
||||||
source folders.
|
|
||||||
- Single root command: `npm test` (mapped to
|
|
||||||
`jest --config tests/jest.config.js`). Sub-commands
|
|
||||||
`npm run test:backend` / `npm run test:frontend` exist.
|
|
||||||
- Setup script: `bash setup.sh` (no new system tools needed).
|
|
||||||
- **Required Tools & Dependencies:** None new. Existing stack (Angular 20,
|
|
||||||
`@angular/forms`, `@angular/common/http`, `jest`, `ts-jest`,
|
|
||||||
`jest-environment-jsdom`) is sufficient.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
- **To Modify:**
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.html`
|
|
||||||
— relax the submit-button `disabled` binding so empty-field
|
|
||||||
submission is exercisable; submit still must not double-fire.
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.ts`
|
|
||||||
— make `submit()` an idempotent no-op when already in flight, and
|
|
||||||
ensure that when `form.invalid` the call only marks touched (it
|
|
||||||
already does) so the validation messages render under
|
|
||||||
`data-testid="setup-username-error"` /
|
|
||||||
`data-testid="setup-confirm-error"`.
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.ts`
|
|
||||||
— guard the "already initialized → bounce" `effect()` so that the
|
|
||||||
modal can still be opened when an integration test seeds the
|
|
||||||
`BootstrapService.initialized` signal as `false` (the existing
|
|
||||||
`alreadyInitialized` computed is already correct; no change
|
|
||||||
required, but the plan documents the expected harness setup).
|
|
||||||
- **To Create:**
|
|
||||||
- `tests/frontend/setup-create-admin-submit.spec.ts` — focused spec
|
|
||||||
asserting that (a) clicking submit with empty fields keeps the modal
|
|
||||||
open, (b) the username required / minlength / pattern errors render,
|
|
||||||
(c) the password-confirm mismatch error renders, and (d) the
|
|
||||||
`SetupCreateAdminService.create()` call is **not** made.
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
1. **HTML — relax submit-button gating**
|
|
||||||
- In `setup-create-admin.component.html` change:
|
|
||||||
```html
|
|
||||||
[disabled]="submitting() || form.invalid"
|
|
||||||
```
|
|
||||||
to:
|
|
||||||
```html
|
|
||||||
[disabled]="submitting()"
|
|
||||||
```
|
|
||||||
- Keep `data-testid="setup-submit"` and the `[type="submit"]` so
|
|
||||||
existing test selectors and the `(ngSubmit)="submit()"` binding
|
|
||||||
continue to work. Add `attr.aria-disabled` only if needed for
|
|
||||||
a11y — not required by the job.
|
|
||||||
- Why: lets the user press the button with empty/missing fields. The
|
|
||||||
component's `submit()` already short-circuits via
|
|
||||||
`markAllAsTouched()` + early return when `form.invalid`, so no
|
|
||||||
server call fires.
|
|
||||||
|
|
||||||
2. **Component TS — harden submit() re-entry**
|
|
||||||
- In `setup-create-admin.component.ts:99-129`, the existing
|
|
||||||
`submit()` already guards with `if (this.submitting()) return;`
|
|
||||||
and `markAllAsTouched()` then `if (this.form.invalid) return;`.
|
|
||||||
No structural change is required; the only edit is to **document
|
|
||||||
in a one-line code comment** that empty/invalid submissions are
|
|
||||||
intentionally allowed through the UI so the validation branch is
|
|
||||||
reachable. (Per project policy, no gratuitous comments — this is
|
|
||||||
only added if the team prefers; the implementer can skip it.)
|
|
||||||
- Concrete visible behavior unchanged: when form is invalid the
|
|
||||||
`usernameErrors()` and `confirmErrors()` `computed()`s already
|
|
||||||
surface `data-testid="setup-username-error"` /
|
|
||||||
`data-testid="setup-confirm-error"` strings because
|
|
||||||
`markAllAsTouched()` flips `c.touched` to true.
|
|
||||||
|
|
||||||
3. **Component TS — already-initialized effect stays as-is**
|
|
||||||
- The `effect()` at lines 39-43 still bounces to `/login` (or `/` when
|
|
||||||
authenticated) when the installation is already initialized. This
|
|
||||||
is the documented product behavior per
|
|
||||||
`docs/guides/bootstrap.md` ("the setup route becomes a 404 and the
|
|
||||||
modal can never appear again"). The job is explicitly scoped to
|
|
||||||
**the uninitialized-installation path**, so no change here.
|
|
||||||
- The test plan therefore seeds `BootstrapService.initialized` as
|
|
||||||
`false` before instantiating the component (the standard pattern
|
|
||||||
used by `guard-bootstrap-race.spec.ts` and the existing
|
|
||||||
`setup-create-admin.spec.ts` validator-only spec).
|
|
||||||
|
|
||||||
4. **No backend / no DB change.** `POST /api/v1/setup/create-admin`
|
|
||||||
already returns `400 VALIDATION_FAILED` for empty bodies via the zod
|
|
||||||
schema (`backend/src/modules/setup/dto/create-admin.dto.ts`), but the
|
|
||||||
frontend never needs to hit that endpoint because the form is
|
|
||||||
rejected client-side first.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
- **Target Unit Test File:** `tests/frontend/setup-create-admin-submit.spec.ts`
|
|
||||||
(new), kept under the existing `tests/frontend/` Jest project
|
|
||||||
(`jsdom`, `@angular/forms` reactive form runtime, `TestBed`).
|
|
||||||
- **Mocking Strategy:**
|
|
||||||
- Use `TestBed.configureTestingModule({ imports: [SetupCreateAdminComponent] })`
|
|
||||||
per the project's standalone-component pattern (see existing
|
|
||||||
`tests/frontend/setup-create-admin.spec.ts` and the
|
|
||||||
`angular-testing` skill rules).
|
|
||||||
- Stub `BootstrapService` with a tiny `TestBed.runInInjectionContext`
|
|
||||||
provider that exposes `initialized = signal(false)` and
|
|
||||||
`passwordPolicy = signal({ description: '...', minLength: 12,
|
|
||||||
requireMixed: true })`. No `HttpTestingController` needed because
|
|
||||||
the success path is never reached; for the mismatch-error assertion
|
|
||||||
we can populate two valid-password controls directly via
|
|
||||||
`form.patchValue` to bypass `minLength`/`required`.
|
|
||||||
- Stub `AuthService` and `Router` with `jest.fn()`-style test doubles
|
|
||||||
so navigation / session mutations can be asserted (they should NOT
|
|
||||||
fire in the empty-field cases).
|
|
||||||
- Spy on `SetupCreateAdminService.create` via
|
|
||||||
`provideHttpClientTesting()` (preferred per skill
|
|
||||||
`angular-testing` §2) and assert `.expectNone(...)` /
|
|
||||||
`.verify()` to prove no network call was made.
|
|
||||||
- Drive the DOM via Angular's `ComponentFixture.debugElement.query`
|
|
||||||
using the documented `data-testid` attributes
|
|
||||||
(`setup-username`, `setup-password`, `setup-password-confirm`,
|
|
||||||
`setup-submit`, `setup-username-error`, `setup-confirm-error`) —
|
|
||||||
no visual confirmation required.
|
|
||||||
- **Assertions:**
|
|
||||||
1. With empty form, click `[data-testid="setup-submit"]` → modal
|
|
||||||
remains mounted (component is not destroyed) and
|
|
||||||
`[data-testid="setup-username-error"]` text equals
|
|
||||||
`"Username is required"`.
|
|
||||||
2. With `username = "ab"` and empty passwords → submit click does not
|
|
||||||
invoke `SetupCreateAdminService.create` (no HTTP request flushed)
|
|
||||||
and the username error reads
|
|
||||||
`"Username must be at least 3 characters"`.
|
|
||||||
3. With `password = "Sup3r!"` and `passwordConfirm = "Different!"` →
|
|
||||||
after `markAllAsTouched()`,
|
|
||||||
`[data-testid="setup-confirm-error"]` renders
|
|
||||||
`"Passwords do not match"`.
|
|
||||||
4. The button's `disabled` attribute reflects `submitting()` only
|
|
||||||
(assert `disabled === false` when form is invalid and not
|
|
||||||
submitting).
|
|
||||||
|
|
||||||
- **Runnability:** Picked up automatically by
|
|
||||||
`npm run test:frontend` and `npm test`. No new deps, no visual
|
|
||||||
checks, no extra setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Out of Scope
|
|
||||||
|
|
||||||
- Backend setup endpoint behavior (already correct: returns 400
|
|
||||||
`VALIDATION_FAILED` for empty payloads).
|
|
||||||
- `/api/v1/bootstrap` returning `initialized:false` — already
|
|
||||||
implemented in `backend/src/modules/system/system.service.ts:36-49`.
|
|
||||||
- Changing the `/bootstrap` route guard or the
|
|
||||||
`decideAuthRedirect` function (already correct per
|
|
||||||
`tests/frontend/guard-bootstrap-race.spec.ts`).
|
|
||||||
- Any schema migration.
|
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# 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.
|
||||||
+1
-1
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: Setup Endpoint
|
title: Setup Endpoint
|
||||||
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
|
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
|
||||||
tags: [api, setup, bootstrap, first-admin]
|
tags: [api, setup, bootstrap, first-admin]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T16:23:40Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Endpoint
|
# Endpoint
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
|
|
||||||
| Path | Component | Guard | Notes |
|
| Path | Component | Guard | Notes |
|
||||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. |
|
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
||||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
| `/login` | `LoginComponent` | — | Standard login form. |
|
||||||
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
||||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||||
@@ -29,7 +29,7 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||||
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
||||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). |
|
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, validation messages, retry handling, session initialization, and redirect to `/admin`. |
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
|
||||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
||||||
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
||||||
| `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). |
|
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
||||||
|
|
||||||
# Guards and interceptors
|
# Guards and interceptors
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: guide
|
|||||||
title: First-Run Bootstrap
|
title: First-Run Bootstrap
|
||||||
description: How a fresh HIPCTF instance is initialized by the very first administrator.
|
description: How a fresh HIPCTF instance is initialized by the very first administrator.
|
||||||
tags: [guide, bootstrap, first-admin, onboarding, tester]
|
tags: [guide, bootstrap, first-admin, onboarding, tester]
|
||||||
timestamp: 2026-07-21T16:09:53Z
|
timestamp: 2026-07-21T16:23:40Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# When this flow runs
|
# When this flow runs
|
||||||
@@ -32,16 +32,18 @@ bypassed.
|
|||||||
3. The app redirects to `/bootstrap` and shows the
|
3. The app redirects to `/bootstrap` and shows the
|
||||||
**"Welcome to HIPCTF — Create the first administrator"** modal.
|
**"Welcome to HIPCTF — Create the first administrator"** modal.
|
||||||
4. Fill in the three fields and submit:
|
4. Fill in the three fields and submit:
|
||||||
- **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. Helper
|
- **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. The
|
||||||
text is shown via `data-testid="setup-username-error"` when the
|
username field displays inline validation after it is touched.
|
||||||
pattern fails.
|
|
||||||
- **Password** — must satisfy the policy shown directly below the
|
- **Password** — must satisfy the policy shown directly below the
|
||||||
field (default: 12 chars with upper, lower, digit, symbol).
|
field (default: 12 chars with upper, lower, digit, symbol).
|
||||||
- **Confirm Password** — must equal the password above (verified by
|
- **Confirm Password** — must equal the password above; mismatch displays
|
||||||
the group-level `passwordMatchValidator`; mismatch shows
|
an inline error.
|
||||||
`data-testid="setup-confirm-error"`).
|
5. Click **Create admin** (`data-testid="setup-submit"`). The button is
|
||||||
5. Click **Create admin** (`data-testid="setup-submit"`). The button remains enabled while the form is invalid so this validation flow can be triggered; it is disabled only while a submission is in progress.
|
disabled only while a submission is in progress and shows **Creating...**
|
||||||
6. To verify client-side validation, submit with empty or partial fields. The overlay stays open, the relevant field errors appear, and no administrator is created or setup request is sent.
|
with a spinner during that request.
|
||||||
|
6. To verify client-side validation, submit with empty or partial fields. The
|
||||||
|
overlay stays open, the relevant field errors appear, and no administrator
|
||||||
|
is created or setup request is sent.
|
||||||
|
|
||||||
# Expected behavior
|
# Expected behavior
|
||||||
|
|
||||||
|
|||||||
+1
-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-21T16:09:53Z.
|
they need. Last regenerated 2026-07-21T16:23:40Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ function makeGroup(password: string | null | undefined, confirm: string | null |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
||||||
|
const USERNAME_MIN = 3;
|
||||||
|
const USERNAME_MAX = 32;
|
||||||
|
|
||||||
|
function usernameErrors(value: string): string | null {
|
||||||
|
if (!value) return 'required';
|
||||||
|
if (value.length < USERNAME_MIN) return 'minlength';
|
||||||
|
if (value.length > USERNAME_MAX) return 'maxlength';
|
||||||
|
if (!USERNAME_PATTERN.test(value)) return 'pattern';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFormReadyForSubmit(username: string, password: string, confirm: string): boolean {
|
||||||
|
if (usernameErrors(username) !== null) return false;
|
||||||
|
if (!password) return false;
|
||||||
|
if (!confirm) return false;
|
||||||
|
const groupLike: PasswordMatchGroupLike = {
|
||||||
|
get: (name) => (name === 'password' ? { value: password } : { value: confirm }),
|
||||||
|
};
|
||||||
|
if (passwordMatchValidator(groupLike)?.passwordMismatch) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
describe('passwordMatchValidator', () => {
|
describe('passwordMatchValidator', () => {
|
||||||
it('returns null when either field is empty (let other validators handle required)', () => {
|
it('returns null when either field is empty (let other validators handle required)', () => {
|
||||||
expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull();
|
expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull();
|
||||||
@@ -58,3 +81,33 @@ describe('Username field rules (mirroring component validators)', () => {
|
|||||||
expect(isValidUsername('a'.repeat(33))).toBe(false);
|
expect(isValidUsername('a'.repeat(33))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Composed submit gate (mirrors SetupCreateAdminComponent.submit)', () => {
|
||||||
|
it('rejects an all-empty form (no required field passes)', () => {
|
||||||
|
expect(isFormReadyForSubmit('', '', '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a partial form (username only)', () => {
|
||||||
|
expect(isFormReadyForSubmit('first.admin', '', '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when password and confirm are empty even with a valid username', () => {
|
||||||
|
expect(isFormReadyForSubmit('first.admin', '', '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when passwordConfirm is empty', () => {
|
||||||
|
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when password and confirm differ', () => {
|
||||||
|
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', 'Different!Pass1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when username violates the pattern', () => {
|
||||||
|
expect(isFormReadyForSubmit('bad name!', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a fully valid, matching form', () => {
|
||||||
|
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user