AI Implementation feature(845): First-Start Create Admin Modal 1.04 #7
@@ -1,217 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Implementation Plan: Job 845 — First-Start Create Admin Modal 1.04 (surface field validation)
|
||||
|
||||
## 1. Architectural Reconnaissance
|
||||
|
||||
- **Codebase style & conventions:**
|
||||
- NestJS API + Angular 17 SPA monorepo (workspaces in `package.json`).
|
||||
- Frontend: **Standalone components**, `ChangeDetectionStrategy.OnPush`, Angular **signals** (`signal`/`computed`), no NgModules. Style guide mandates `OnPush` and signals for state.
|
||||
- Reactive Forms via `ReactiveFormsModule` with `fb.nonNullable.group(...)` (non-nullable typed controls).
|
||||
- All services are `providedIn: 'root'`. Two HTTP interceptors wired in `frontend/src/main.ts` (`csrfInterceptor`, then `authInterceptor`).
|
||||
- Frontend tests are **pure-logic / no TestBed**: `tests/frontend/**/*.spec.ts` import pure functions (validators, session-storage helpers, shell decision helpers) and rely on `jest` (`testEnvironment: 'jsdom'`) runnable with the root command `npm test` (which uses `jest --config tests/jest.config.js --selectProjects frontend`).
|
||||
- Strictly-typed reactive forms per `angular-forms` skill (`FormGroup<T>`, `fb.nonNullable`).
|
||||
- Data attributes / `data-testid` selectors are the stable DOM hooks (see `docs/guides/bootstrap.md`).
|
||||
- **Data Layer:** N/A for this Job — purely a frontend UI/validation correctness fix. The backend already returns a structured `400 VALIDATION_FAILED` envelope; the bug is solely that the SPA short-circuits on `form.invalid` without ever surfacing per-field error messages.
|
||||
- **Test Framework & Structure:**
|
||||
- Jest with `ts-jest`, jsdom environment for frontend.
|
||||
- Tests live **only** in `tests/frontend/` (never co-located with source).
|
||||
- Root command: `npm test` (runs all projects via `jest --config tests/jest.config.js`).
|
||||
- Existing precedent at `tests/frontend/setup-create-admin.spec.ts` is a pure-logic suite (mirrors component logic in standalone helper functions). **We will follow that pattern** — no TestBed, no DOM rendering required, no visual confirmation.
|
||||
- **Required Tools & Dependencies:** No new dependencies, system tools, or `setup.sh` changes needed. The fix is a localized source-code change in the standalone component + its template + a pure-helper module that mirrors the per-field error messages (for parity testing). The npm workspace already has `@angular/forms ^17.3` and `jest`.
|
||||
|
||||
## 2. Impacted Files
|
||||
|
||||
### To Modify
|
||||
- **`frontend/src/app/features/setup/setup-create-admin.component.ts`** — fix the reactivity of the per-field error calculations and expose `passwordErrors()`. Convert the `computed(...)`s whose bodies read `FormControl` properties (which are NOT signals in Angular 17) into plain **methods** so the template re-evaluates them on every change-detection cycle (cheap, OnPush-friendly, no extra subscriptions). Alternatively keep them as computed but reflect `touched` into a local `signal()` driven by `statusChanges`/`valueChanges` + an effect; **plain methods are the minimal, idiomatic fix** and are used elsewhere in the same component style. Keep all existing public surface (`form`, `submitting`, `serverError`, `serverErrorCode`, `canRetry`, `policyDescription`, `submit`, `swallowBackdrop`, `swallowEscape`) intact.
|
||||
- **`frontend/src/app/features/setup/setup-create-admin.component.html`** — render the new Password field-error block, mirror `aria-invalid` on the Password and Confirm Password inputs (Username already has it), and keep the `data-testid` hooks the bootstrap guide documents (`setup-username-error`, add `setup-password-error`, keep `setup-confirm-error`).
|
||||
- **`frontend/src/app/features/setup/setup-create-admin.component.css`** — no functional change required; `.field-error` styling already exists.
|
||||
- **`tests/frontend/setup-create-admin.spec.ts`** — extend the existing pure-logic suite to cover the new password-error helper and to assert that, given an all-empty (or partially-filled) post-submit state, the per-field error strings the template renders are correct.
|
||||
|
||||
### To Create
|
||||
- **`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`** — pure helper functions that return the per-field error string (or `null`) for a given `(value, touched)` snapshot. Keeping them as pure functions (no Angular DI, no signals) is deliberate: it lets the component call them from methods **and** lets the pure-logic test suite assert them without spinning up Angular. Mirrors the pattern already used by `setup-create-admin.validators.ts` and tested in `setup-create-admin.spec.ts`.
|
||||
|
||||
## 3. Proposed Changes
|
||||
|
||||
### Root cause
|
||||
Angular 17 `FormControl.touched`, `.valid`, and `.errors` are plain class properties, **not** signals. The existing `computed(() => … usernameErrors …)` (component file lines 70–78 and 80–87) reads those plain properties and so registers **zero signal dependencies**. Even after `submit()` calls `form.markAllAsTouched()`, the computed is not re-evaluated — its cached `null` is returned and the `@if (usernameErrors()) { … }` block stays hidden. The same applies to `confirmErrors()` for the `passwordConfirm` control. There is also no `passwordErrors()` at all and no `aria-invalid` binding on the Password/Confirm inputs, which matches the user's observation.
|
||||
|
||||
### 3.1 New pure helper module
|
||||
|
||||
`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`:
|
||||
|
||||
```ts
|
||||
export type FieldErrorKey =
|
||||
| 'required'
|
||||
| 'minlength'
|
||||
| 'maxlength'
|
||||
| 'pattern'
|
||||
| 'passwordMismatch';
|
||||
|
||||
export interface FieldErrorContext {
|
||||
value: string;
|
||||
touched: boolean;
|
||||
errors: Partial<Record<FieldErrorKey, true>> | null;
|
||||
groupMismatch?: boolean;
|
||||
}
|
||||
|
||||
export function usernameError(ctx: FieldErrorContext): string | null { … }
|
||||
export function passwordError(ctx: FieldErrorContext): string | null { … } // currently required-only; password POLICY is server-enforced
|
||||
export function confirmError(ctx: FieldErrorContext): string | null { … }
|
||||
```
|
||||
|
||||
Rules encoded (single source of truth, identical to current component intent):
|
||||
|
||||
- **Username:**
|
||||
- not touched or no errors → `null`
|
||||
- `required` → `'Username is required'`
|
||||
- `minlength` → `'Username must be at least 3 characters'`
|
||||
- `maxlength` → `'Username must be at most 32 characters'`
|
||||
- `pattern` → `'Username may only contain letters, digits, dot, dash and underscore'`
|
||||
- else → `'Invalid username'`
|
||||
- **Password:**
|
||||
- not touched or no errors → `null`
|
||||
- `required` → `'Password is required'`
|
||||
- else → `null` (policy is intentionally server-side only; this component never surfaces a "weak password" field error pre-submit)
|
||||
- **Confirm:**
|
||||
- not touched and no `passwordMismatch` group error → `null`
|
||||
- `required` → `'Please confirm your password'`
|
||||
- `passwordMismatch` (group-level) → `'Passwords do not match'`
|
||||
- else → `'Invalid value'`
|
||||
|
||||
These signatures accept the small slice of state they need so the component can pass `c.value`, `c.touched`, and `c.errors` (cast through `Partial<Record<…, true>>`) and so the test suite can call them directly without touching Angular.
|
||||
|
||||
### 3.2 Component changes
|
||||
|
||||
`setup-create-admin.component.ts`:
|
||||
|
||||
1. **Replace the two `computed(...)`s with plain methods** so the template re-renders them on every CD pass:
|
||||
- `usernameError(): string | null { return usernameError({ value: this.form.controls.username.value, touched: this.form.controls.username.touched, errors: this.form.controls.username.errors }); }`
|
||||
- `passwordError(): string | null { return passwordError({ value: this.form.controls.password.value, touched: this.form.controls.password.touched, errors: this.form.controls.password.errors }); }` ← **new**
|
||||
- `confirmError(): string | null { return confirmError({ value: this.form.controls.passwordConfirm.value, touched: this.form.controls.passwordConfirm.touched, errors: this.form.controls.passwordConfirm.errors, groupMismatch: !!this.form.errors?.['passwordMismatch'] }); }`
|
||||
2. **Delete the orphaned `usernameErrors` and `confirmErrors` signal properties** (template rewires to methods).
|
||||
3. Leave `submit()` exactly as-is: `markAllAsTouched()` then early-return on invalid. The early return is now correct and observable (because `…Error()` is re-evaluated by the next CD cycle that runs after `markAllAsTouched` synchronously mutates `touched` on every control). No new `effect()` needed; this is the minimal fix.
|
||||
4. Keep OnPush (`ChangeDetectionStrategy.OnPush`), keep all existing imports/decorators/HostListener for `escape` and `swallowBackdrop`. The host binding keyboard handler stays so the modal remains non-dismissible.
|
||||
|
||||
> Optional, NOT recommended for this Job (kept out of scope to honor "minimal changes"): a reactive alternative would be to subscribe to `statusChanges` / `valueChanges` via `toSignal()` in the constructor and gate rendering on those signals. The plain-method approach above is the minimum-surface fix that fully resolves the bug and matches the component's current style.
|
||||
|
||||
### 3.3 Template changes
|
||||
|
||||
`setup-create-admin.component.html`:
|
||||
|
||||
1. Username `<input>` — keep existing `[attr.aria-invalid]="!!usernameError()"` and existing `<small class="field-error">` block, but rebind to the new method name.
|
||||
2. Password `<label class="field">` — add `aria-invalid` and add the missing error block below the policy hint:
|
||||
```html
|
||||
<input … formControlName="password"
|
||||
[attr.aria-invalid]="!!passwordError()"
|
||||
data-testid="setup-password" />
|
||||
<small class="hint">{{ policyDescription() }}</small>
|
||||
@if (passwordError()) {
|
||||
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
|
||||
}
|
||||
```
|
||||
3. Confirm `<input>` — add `aria-invalid`:
|
||||
```html
|
||||
<input … formControlName="passwordConfirm"
|
||||
[attr.aria-invalid]="!!confirmError()"
|
||||
data-testid="setup-password-confirm" />
|
||||
@if (confirmError()) {
|
||||
<small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
|
||||
}
|
||||
```
|
||||
4. Submit button — **unchanged**. The Job accepts either "visible per-field error messages" **or** "disabled submit button until valid" — we keep the click-to-trigger-feedback behavior (already used by `markAllAsTouched`) and now surface real errors, which is the original design intent. We deliberately do **not** disable the button on invalid (preserves parity with the existing guide which says the button is "disabled only while submitting").
|
||||
|
||||
### 3.4 CSS
|
||||
|
||||
No changes. `.field-error` color/size is already defined.
|
||||
|
||||
### 3.5 No backend changes
|
||||
|
||||
The backend's `SetupController.createAdmin` already responds with `400 VALIDATION_FAILED` and a `details` array; that contract is unchanged. The client never reached it before, and after the fix the client still won't for an empty form (which is correct — the per-field client-side errors are now visible; the server is reachable when the client actually submits valid input).
|
||||
|
||||
## 4. Test Strategy
|
||||
|
||||
- **Target Unit Test File:** `tests/frontend/setup-create-admin.spec.ts` (extend the existing pure-logic file; do **not** create a co-located spec under `frontend/`).
|
||||
- The existing file already exports nothing new — it lives entirely in `tests/frontend/` per project convention and runs with the root command `npm test` (Jest, jsdom).
|
||||
- **Mocking Strategy:**
|
||||
- **No TestBed, no HTTP, no DOM rendering.** The new helpers (`usernameError`, `passwordError`, `confirmError`) are pure functions in `setup-create-admin.field-errors.ts` — they are imported and called directly with hand-built `FieldErrorContext` literals, mirroring the existing `usernameErrors`/`isFormReadyForSubmit` helpers in the same file.
|
||||
- The existing `passwordMatchValidator` tests and username rules act as the de-facto model; we extend the "Composed submit gate" suite to also assert the rendered error strings.
|
||||
- **New test cases (≤ 8, focused on the bug paths):**
|
||||
1. `usernameError({ value: '', touched: true, errors: { required: true } })` returns `'Username is required'`.
|
||||
2. `usernameError({ value: 'ab', touched: true, errors: { minlength: true } })` returns the minlength message.
|
||||
3. `passwordError({ value: '', touched: true, errors: { required: true } })` returns `'Password is required'`.
|
||||
4. `passwordError({ value: 'x', touched: true, errors: null })` returns `null` (policy is server-side only; no pre-submit hint).
|
||||
5. `confirmError({ value: '', touched: true, errors: { required: true } })` returns `'Please confirm your password'`.
|
||||
6. `confirmError({ value: 'a', touched: true, errors: null, groupMismatch: true })` returns `'Passwords do not match'`.
|
||||
7. `confirmError({ value: 'a', touched: false, errors: null })` returns `null` (no spurious visibility pre-touch).
|
||||
8. `usernameError({ value: '', touched: false, errors: { required: true } })` returns `null` (no spurious visibility pre-touch).
|
||||
- **How to run:** `npm test` (root). Selectors: `npm run test:frontend` for fast feedback. No browser, no screenshot, no Visual regression.
|
||||
- **Out of scope (explicitly):** TestBed rendering, HTTP mock for `SetupCreateAdminService.create`, fixture-based aria assertion, and end-to-end visual confirmation — all forbidden by the test rules.
|
||||
@@ -10,11 +10,11 @@
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
formControlName="username"
|
||||
[attr.aria-invalid]="!!usernameErrors()"
|
||||
[attr.aria-invalid]="!!usernameError()"
|
||||
data-testid="setup-username"
|
||||
/>
|
||||
@if (usernameErrors()) {
|
||||
<small class="field-error" data-testid="setup-username-error">{{ usernameErrors() }}</small>
|
||||
@if (usernameError()) {
|
||||
<small class="field-error" data-testid="setup-username-error">{{ usernameError() }}</small>
|
||||
}
|
||||
</label>
|
||||
|
||||
@@ -24,9 +24,13 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="password"
|
||||
[attr.aria-invalid]="!!passwordError()"
|
||||
data-testid="setup-password"
|
||||
/>
|
||||
<small class="hint">{{ policyDescription() }}</small>
|
||||
@if (passwordError()) {
|
||||
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
|
||||
}
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
@@ -35,10 +39,11 @@
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
formControlName="passwordConfirm"
|
||||
[attr.aria-invalid]="!!confirmError()"
|
||||
data-testid="setup-password-confirm"
|
||||
/>
|
||||
@if (confirmErrors()) {
|
||||
<small class="field-error" data-testid="setup-confirm-error">{{ confirmErrors() }}</small>
|
||||
@if (confirmError()) {
|
||||
<small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
|
||||
}
|
||||
</label>
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import { AuthService } from '../../core/services/auth.service';
|
||||
import { BootstrapService } from '../../core/services/bootstrap.service';
|
||||
import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service';
|
||||
import { passwordMatchValidator } from './setup-create-admin.validators';
|
||||
import {
|
||||
usernameError as computeUsernameError,
|
||||
passwordError as computePasswordError,
|
||||
confirmError as computeConfirmError,
|
||||
} from './setup-create-admin.field-errors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-create-admin',
|
||||
@@ -67,24 +72,33 @@ export class SetupCreateAdminComponent {
|
||||
() => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN',
|
||||
);
|
||||
|
||||
readonly usernameErrors = computed(() => {
|
||||
usernameError(): string | null {
|
||||
const c = this.form.controls.username;
|
||||
if (!c.touched || c.valid) return null;
|
||||
if (c.errors?.['required']) return 'Username is required';
|
||||
if (c.errors?.['minlength']) return 'Username must be at least 3 characters';
|
||||
if (c.errors?.['maxlength']) return 'Username must be at most 32 characters';
|
||||
if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore';
|
||||
return 'Invalid username';
|
||||
});
|
||||
return computeUsernameError({
|
||||
value: c.value,
|
||||
touched: c.touched,
|
||||
errors: (c.errors as { required?: true; minlength?: true; maxlength?: true; pattern?: true } | null) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
readonly confirmErrors = computed(() => {
|
||||
passwordError(): string | null {
|
||||
const c = this.form.controls.password;
|
||||
return computePasswordError({
|
||||
value: c.value,
|
||||
touched: c.touched,
|
||||
errors: (c.errors as { required?: true } | null) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
confirmError(): string | null {
|
||||
const c = this.form.controls.passwordConfirm;
|
||||
const groupMismatch = this.form.errors?.['passwordMismatch'];
|
||||
if ((!c.touched || c.valid) && !groupMismatch) return null;
|
||||
if (c.errors?.['required']) return 'Please confirm your password';
|
||||
if (groupMismatch) return 'Passwords do not match';
|
||||
return 'Invalid value';
|
||||
});
|
||||
return computeConfirmError({
|
||||
value: c.value,
|
||||
touched: c.touched,
|
||||
errors: (c.errors as { required?: true } | null) ?? null,
|
||||
groupMismatch: !!this.form.errors?.['passwordMismatch'],
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape', ['$event'])
|
||||
swallowEscape(event: KeyboardEvent): void {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export type FieldErrorKey =
|
||||
| 'required'
|
||||
| 'minlength'
|
||||
| 'maxlength'
|
||||
| 'pattern'
|
||||
| 'passwordMismatch';
|
||||
|
||||
export interface FieldErrorContext {
|
||||
value: string;
|
||||
touched: boolean;
|
||||
errors: Partial<Record<FieldErrorKey, true>> | null;
|
||||
groupMismatch?: boolean;
|
||||
}
|
||||
|
||||
export function usernameError(ctx: FieldErrorContext): string | null {
|
||||
const { touched, errors } = ctx;
|
||||
if (!touched || !errors) return null;
|
||||
if (errors.required) return 'Username is required';
|
||||
if (errors.minlength) return 'Username must be at least 3 characters';
|
||||
if (errors.maxlength) return 'Username must be at most 32 characters';
|
||||
if (errors.pattern) return 'Username may only contain letters, digits, dot, dash and underscore';
|
||||
return 'Invalid username';
|
||||
}
|
||||
|
||||
export function passwordError(ctx: FieldErrorContext): string | null {
|
||||
const { touched, errors } = ctx;
|
||||
if (!touched || !errors) return null;
|
||||
if (errors.required) return 'Password is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function confirmError(ctx: FieldErrorContext): string | null {
|
||||
const { touched, errors, groupMismatch } = ctx;
|
||||
if (!touched && !groupMismatch) return null;
|
||||
if (errors?.required) return 'Please confirm your password';
|
||||
if (groupMismatch) return 'Passwords do not match';
|
||||
return 'Invalid value';
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
passwordMatchValidator,
|
||||
PasswordMatchGroupLike,
|
||||
} from '../../frontend/src/app/features/setup/setup-create-admin.validators';
|
||||
import {
|
||||
usernameError,
|
||||
passwordError,
|
||||
confirmError,
|
||||
} from '../../frontend/src/app/features/setup/setup-create-admin.field-errors';
|
||||
|
||||
function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike {
|
||||
return {
|
||||
@@ -111,3 +116,58 @@ describe('Composed submit gate (mirrors SetupCreateAdminComponent.submit)', () =
|
||||
expect(isFormReadyForSubmit('first.admin', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Field-error helpers (mirror the template render)', () => {
|
||||
it('usernameError returns "Username is required" when touched + required', () => {
|
||||
expect(
|
||||
usernameError({ value: '', touched: true, errors: { required: true } }),
|
||||
).toBe('Username is required');
|
||||
});
|
||||
|
||||
it('usernameError returns the minlength message when too short', () => {
|
||||
expect(
|
||||
usernameError({ value: 'ab', touched: true, errors: { minlength: true } }),
|
||||
).toBe('Username must be at least 3 characters');
|
||||
});
|
||||
|
||||
it('usernameError returns null when not touched even if errors are present', () => {
|
||||
expect(
|
||||
usernameError({ value: '', touched: false, errors: { required: true } }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('passwordError returns "Password is required" when touched + required', () => {
|
||||
expect(
|
||||
passwordError({ value: '', touched: true, errors: { required: true } }),
|
||||
).toBe('Password is required');
|
||||
});
|
||||
|
||||
it('passwordError returns null when touched with no errors (policy is server-side)', () => {
|
||||
expect(
|
||||
passwordError({ value: 'x', touched: true, errors: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('confirmError returns "Please confirm your password" when touched + required', () => {
|
||||
expect(
|
||||
confirmError({ value: '', touched: true, errors: { required: true } }),
|
||||
).toBe('Please confirm your password');
|
||||
});
|
||||
|
||||
it('confirmError returns "Passwords do not match" when group mismatch is set', () => {
|
||||
expect(
|
||||
confirmError({
|
||||
value: 'a',
|
||||
touched: true,
|
||||
errors: null,
|
||||
groupMismatch: true,
|
||||
}),
|
||||
).toBe('Passwords do not match');
|
||||
});
|
||||
|
||||
it('confirmError returns null when not touched and no group mismatch', () => {
|
||||
expect(
|
||||
confirmError({ value: 'a', touched: false, errors: null }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user