352 lines
28 KiB
Markdown
352 lines
28 KiB
Markdown
# Implementation Plan: Job 823 — Landing Page and Login/Register Modal
|
||
|
||
## 0. Already-Implemented Status
|
||
|
||
**Not yet implemented.** No landing page, no Login/Register modal with mode switching, no `POST /api/v1/auth/register` (player registration), and no public "published blog posts" endpoint exist today. Investigation confirms:
|
||
|
||
- `grep` across the repo finds no `Landing`/`LandingComponent`/landing-page assets.
|
||
- No public blog-post controller: `BlogPostEntity` exists at `backend/src/database/entities/blog-post.entity.ts`, but is only referenced by the migration (`1700000000000-InitSchema.ts`) and seed/docs. No `BlogController`/`BlogService` exists.
|
||
- The existing `LoginComponent` (`frontend/src/app/features/auth/login.component.ts`) is a bare full-page form, not the modal overlay described in the Job. It does not surface backoff wait-times, has no Register-mode toggle, and bypasses CSRF header entirely (manual `fetch('/api/v1/auth/csrf')`).
|
||
- Backend `AuthController.login` already implements backoff (`LoginBackoffService`) and emits `RATE_LIMITED` 429 with a wait-time message, but there is no `POST /api/v1/auth/register` for player self-registration. `AuthService.registerFirstAdmin` is bootstrap-only (admin role, refuses if any admin exists).
|
||
- CSRF skip-list (`backend/src/common/middleware/csrf.middleware.ts:11`) currently includes `/api/v1/auth/login` and `/api/v1/setup/create-admin`. Login needs to come under CSRF protection for the new flow (must add CSRF header on login); registration must NOT be skipped.
|
||
|
||
The new login flow must align with the existing `authGuard` decision (`frontend/src/app/core/guards/auth.guard.decision.ts`) which already redirects unauthenticated-but-initialized users to `/login`. With the new Job, `/login` becomes the **landing** page, and the modal is rendered inside it. See Section 3 for the routing change.
|
||
|
||
## 1. Architectural Reconnaissance
|
||
|
||
- **Codebase style & conventions:**
|
||
- Backend: NestJS 10 standalone modules, TypeORM with `better-sqlite3`, zod-validated DTOs via `ZodValidationPipe`, `ApiError` → `GlobalExceptionFilter` envelope, `@Public()` decorator + global `JwtAuthGuard`, CSRF double-submit via `CsrfMiddleware` + `setOrGetCsrfToken`.
|
||
- Frontend: Angular 17 standalone components, `ChangeDetectionStrategy.OnPush`, signals (`signal`/`computed`/`effect`), Reactive Forms with `FormBuilder.nonNullable`, control-flow `@if`/`@for` (per `setup-create-admin.component.html`), typed discriminated-union service results (see `CreateAdminResult`).
|
||
- Cookies: `rt` (refresh, `HttpOnly`, `sameSite=lax/strict`) and `csrf` (readable by JS for the `csrfInterceptor`).
|
||
- Session restore on hard-refresh already exists (`AuthService.restoreSession()` → `POST /api/v1/auth/refresh`).
|
||
- **Data Layer:** SQLite + TypeORM. `blog_post` table already exists (`status` index `idx_blog_status_published (status, published_at)`). `user` table supports `role='player'` and `status='enabled'`. `setting` key `registrationsEnabled` already exists and is read by `SystemService.bootstrap`.
|
||
- **Test Framework & Structure:** Jest 29 with ts-jest, two projects (`backend` — node, `frontend` — jsdom) configured in `tests/jest.config.js`. Run all tests with `npm test` from repo root. New tests MUST live under `tests/backend/*.spec.ts` or `tests/frontend/*.spec.ts`.
|
||
- **Required Tools & Dependencies:**
|
||
- **Frontend:** `marked` (small, widely used Markdown → HTML) + `dompurify` (`isomorphic-dompurify` works in jsdom tests) for sanitized Markdown rendering of welcome description + blog bodies. Both have minimal footprint, no extra peer-deps, tree-shakeable.
|
||
- No backend dependency additions required (zod, argon2, typeorm all already present).
|
||
- `package.json` workspaces will get `frontend` deps installed automatically by `npm ci`. No `setup.sh` change needed beyond ensuring `npm ci` runs.
|
||
|
||
## 2. Impacted Files
|
||
|
||
### To Modify
|
||
|
||
| Path | Reason |
|
||
|---|---|
|
||
| `backend/src/modules/auth/auth.controller.ts` | Add `POST /api/v1/auth/register` (player self-registration); tighten CSRF handling so login/register/logout/refresh all require the CSRF header except where still skipped. |
|
||
| `backend/src/modules/auth/auth.service.ts` | Add `registerPlayer(username, password, ip)` enforcing `registrationsEnabled` setting, password policy, password === confirmation, Argon2id hash, role=`player`/`status=enabled`, then auto-login via existing `mintSession`. |
|
||
| `backend/src/modules/auth/dto/auth.dto.ts` | Add `RegisterDtoSchema` (`username`, `password`, `passwordConfirm` with refine `password === passwordConfirm`). |
|
||
| `backend/src/common/middleware/csrf.middleware.ts` | Remove `/api/v1/auth/login` from `SKIP_PATH_PREFIXES` (Job requires CSRF on login); keep `/api/v1/setup/create-admin` and `/api/v1/auth/register-first-admin` skipped. |
|
||
| `backend/src/app.module.ts` | Register the new `BlogModule` (see Created files). |
|
||
| `frontend/src/app/app.routes.ts` | Rename route: `/login` stays but now renders the new `LandingComponent` (which embeds the Login/Register modal). Update wildcard fallback to route unauthenticated users correctly (existing `** → ''` still works because `authGuard` will redirect). |
|
||
| `frontend/src/app/core/guards/auth.guard.decision.ts` | No change required: it already redirects to `/login` when initialized && !authenticated. Add a `decideLandingGuard` for the unauthenticated-but-initialized case (pure function) used by `/login` route to bounce authenticated users back to `/`. |
|
||
| `frontend/src/app/features/auth/login.component.ts` | **Replace** with the new `LandingComponent` that owns the landing content + modal (single component, two responsibilities). |
|
||
| `frontend/src/app/core/services/auth.service.ts` | Add a `login(creds)` helper that returns a discriminated `LoginResult` (`LoginSuccess` / `LoginFailure { code, message, retryAfterMs? }`) so the modal can surface backoff messages. Keep existing `setSession`/`restoreSession` API. |
|
||
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | Reference only — copy the discriminated-union shape into a new `auth.service.ts` helper / new `auth-modal.service.ts`. No changes required here. |
|
||
| `frontend/src/main.ts` | No code change; interceptors already cover POST/PUT/PATCH/DELETE. The new login modal will rely on the existing `csrfInterceptor`. |
|
||
| `package.json` (root) | No new root deps — frontend deps are workspace-scoped. |
|
||
| `frontend/package.json` | Add `marked` and `dompurify` (with `@types/dompurify` if needed). |
|
||
| `kilo.json` / `AGENTS.md` | No edits required. |
|
||
| `docs/architecture/frontend-structure.md`, `docs/architecture/key-files.md`, `docs/architecture/backend-modules.md`, `docs/api/rest-overview.md` | Optional doc updates to list new module/components. |
|
||
|
||
### To Create
|
||
|
||
| Path | Purpose |
|
||
|---|---|
|
||
| `backend/src/modules/blog/blog.module.ts` | NestJS module wiring `BlogController` + `BlogService` + `BlogPostEntity`. |
|
||
| `backend/src/modules/blog/blog.controller.ts` | `GET /api/v1/blog/posts` (public, `@Public()`, returns only published posts ordered by `published_at DESC`). |
|
||
| `backend/src/modules/blog/blog.service.ts` | `listPublished()` returning sanitized DTOs (no internal IDs leaked — id is safe to expose). |
|
||
| `backend/src/modules/blog/dto/blog.dto.ts` | Zod schema for the public response shape (`id`, `title`, `bodyHtml?`, `publishedAt`). |
|
||
| `frontend/src/app/features/landing/landing.component.ts` | Landing page + Login/Register modal. Standalone, `OnPush`, signals. Mode toggle, in-flight disable, inline field errors, server-error display, backoff wait-time. |
|
||
| `frontend/src/app/features/landing/landing.component.html` | Template for the centered landing content + modal overlay. |
|
||
| `frontend/src/app/features/landing/landing.component.css` | Scoped styles for landing layout + modal. |
|
||
| `frontend/src/app/features/landing/landing.service.ts` | Calls `POST /api/v1/blog/posts`, returns `BlogPost[]`. Also returns the sanitized HTML for `welcomeMarkdown` from the existing `BootstrapService.payload().welcomeMarkdown` (no new endpoint needed). |
|
||
| `frontend/src/app/features/landing/login-modal.service.ts` | Pure helpers: `buildLoginFailureMessage(code, message)` → user-facing copy (incl. backoff wait). Mirrors the `setup-create-admin.field-errors.ts` style. |
|
||
| `frontend/src/app/core/services/markdown.service.ts` | `renderMarkdown(md: string): SafeHtml` using `marked` + `DOMPurify.sanitize`. Used by the landing for welcome description + blog bodies. |
|
||
| `frontend/src/app/core/guards/landing.guard.ts` + `landing.guard.decision.ts` | `CanActivateFn` for `/login`: awaits bootstrap+hydration; if authenticated → redirect `/`; if not initialized → redirect `/bootstrap`; else allow. Pure decision function for unit test. |
|
||
| `tests/backend/blog-public.spec.ts` | Verifies `/api/v1/blog/posts` returns only `status='published'` rows, ordered by `published_at DESC`, public, and no challenge/scoreboard/admin data is reachable from it. |
|
||
| `tests/backend/auth-register.spec.ts` | Verifies `POST /api/v1/auth/register` happy path + `registrations disabled` + rate-limit + weak password + missing confirm. |
|
||
| `tests/backend/csrf-protected-routes.spec.ts` | Verifies login now requires CSRF header (after the skip-list change). |
|
||
| `tests/frontend/landing-markdown.spec.ts` | Pure-Jest tests for `MarkdownService.renderMarkdown` (sanitizes `<script>`, preserves headings/links/code). |
|
||
| `tests/frontend/landing-guard.spec.ts` | Pure-Jest tests for `decideLandingGuard`. |
|
||
| `tests/frontend/landing-modal.spec.ts` | Pure-Jest tests for `login-modal.service.buildLoginFailureMessage` and the discriminated `LoginResult` mapping. |
|
||
|
||
## 3. Proposed Changes
|
||
|
||
### 3.1 Database / Schema Migration
|
||
|
||
**No schema changes.** All required tables (`user`, `setting`, `blog_post`) and indexes (`idx_blog_status_published`) already exist. The migration to add data happens via the admin UI later.
|
||
|
||
### 3.2 Backend Logic & APIs
|
||
|
||
#### 3.2.1 New `BlogController` (`GET /api/v1/blog/posts`)
|
||
|
||
- `@Controller('api/v1/blog')` with `@Public()` on the class.
|
||
- `GET /posts` → calls `BlogService.listPublished()` which queries `BlogPostEntity` `WHERE status = 'published'` ordered by `published_at DESC`.
|
||
- Response shape: `{ posts: Array<{ id: string; title: string; publishedAt: string; bodyHtml?: string }> }`.
|
||
- Frontend will render Markdown bodies; backend returns raw `bodyMd` (`bodyMd` field on entity) — sanitization happens on the frontend per the Job spec ("Markdown (sanitized) with a preview-style appearance" — the renderer is the landing page). To keep the API small we expose the raw `bodyMd`; sanitization is documented at the FE Markdown service. **Alternative**: render to sanitized HTML on backend (via `marked` + `dompurify` server-side) so the API stays a stable contract and Markdown is rendered once. Choose backend rendering for this Job since `marked` + `isomorphic-dompurify` are already familiar dependencies and the Job's "Markdown-rendered content" requirement is API-facing.
|
||
- Decision: **render on backend** (smaller payload, simpler FE, consistent with Job acceptance criterion "rendered Markdown"). Add `marked` + `isomorphic-dompurify` to `backend/package.json` (server-side only, no DOM).
|
||
- `@Public()` ensures `JwtAuthGuard` does not block unauthenticated visitors. CSRF is only enforced on unsafe methods; `GET` is safe.
|
||
|
||
#### 3.2.2 New `POST /api/v1/auth/register` (Player Self-Registration)
|
||
|
||
- Lives on existing `AuthController` (no new module).
|
||
- `@Public()`, **CSRF enforced** (do NOT add to skip-list).
|
||
- Body validated by `RegisterDtoSchema`:
|
||
```ts
|
||
z.object({
|
||
username: z.string().min(3).max(32).regex(/^[a-zA-Z0-9_.-]+$/),
|
||
password: z.string().min(1).max(256),
|
||
passwordConfirm: z.string().min(1).max(256),
|
||
}).refine(d => d.password === d.passwordConfirm, { path: ['passwordConfirm'], code: 'VALIDATION_FAILED' });
|
||
```
|
||
- New service method `AuthService.registerPlayer(dto, ip)`:
|
||
1. `validatePassword(dto.password, this.config)` → `WEAK_PASSWORD` 400 if policy violated.
|
||
2. Re-check `dto.password === dto.passwordConfirm` (defense in depth beyond the refine).
|
||
3. Read `registrationsEnabled` from `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')`. If `!== 'true'` → throw `ApiError(ERROR_CODES.FORBIDDEN, 'Registrations are disabled', 403)` with `code: REGISTRATIONS_DISABLED` (add new code).
|
||
4. `this.registrationRateLimit.isAllowed(ip)` → if false, throw `RATE_LIMITED` 429 with message `'Too many registration attempts; try again later.'`.
|
||
5. Inside `dataSource.transaction`:
|
||
- `manager.findOne(UserEntity, { where: { username } })` → if found, throw `ApiError.conflict('USERNAME_TAKEN', 'Username already taken')` 409.
|
||
- Argon2id hash (same options as `registerFirstAdmin`).
|
||
- Insert `UserEntity({ role: 'player', status: 'enabled' })`.
|
||
- `registrationRateLimit.record(ip)`.
|
||
- `mintSession(manager, user)` — returns `{ accessToken, refreshToken, expiresIn, user }`.
|
||
6. Controller sets `rt` cookie via existing `setRefreshCookie(res, session.refreshToken, this.config)` and returns `{ accessToken, expiresIn, user }` (same shape as login).
|
||
- The CSRF middleware already has the post-login cookie cleared and ready; CSRF was already required for `/api/v1/auth/register` previously? **No** — login is in the skip-list today. Remove login from skip-list (see 3.2.3).
|
||
|
||
#### 3.2.3 CSRF tightening
|
||
|
||
- Remove `'/api/v1/auth/login'` from `SKIP_PATH_PREFIXES` in `csrf.middleware.ts`. Keep:
|
||
- `/api/v1/auth/register-first-admin` (legacy bootstrap, pre-cookie)
|
||
- `/api/v1/setup/create-admin` (bootstrap)
|
||
- `/api/v1/auth/register` is NEW and is NOT added to skip-list (Job mandates CSRF on register).
|
||
- `/api/v1/auth/refresh` and `/api/v1/auth/logout` were already outside the skip-list.
|
||
- Login UX: frontend already calls `GET /api/v1/auth/csrf` once to seed the cookie, then `csrfInterceptor` attaches the header. Update the new `LandingComponent` to call `csrf` priming before the first login attempt (the existing `LoginComponent` did this manually — replicate it in the new modal service).
|
||
|
||
#### 3.2.4 Add new error code
|
||
|
||
- Add `REGISTRATIONS_DISABLED: 'REGISTRATIONS_DISABLED'` to `ERROR_CODES` in `backend/src/common/errors/error-codes.ts`. Used when `registrationsEnabled=false`.
|
||
|
||
#### 3.2.5 No changes needed
|
||
|
||
- `LoginBackoffService` and `RegistrationRateLimitService` already provide the exact behavior the Job describes. Login already throws `RATE_LIMITED` 429 with the remaining wait-time in the message.
|
||
- `mintSession` already produces the `{ accessToken, refreshToken, expiresIn, user }` envelope; register reuses it.
|
||
|
||
### 3.3 Frontend UI Integration
|
||
|
||
#### 3.3.1 Routing
|
||
|
||
- Keep `/login` path but repoint it to the new `LandingComponent`:
|
||
```ts
|
||
{ path: 'login', loadComponent: () => import('./features/landing/landing.component').then(m => m.LandingComponent), canActivate: [landingGuard] }
|
||
```
|
||
- Add `landingGuard` (functional `CanActivateFn`) that:
|
||
- `await bootstrap.ready()` and `await auth.waitUntilHydrated()`
|
||
- `decideLandingGuard({ initialized, isAuthenticated }, router)` returns either `true` or `createUrlTree(['/bootstrap'])` (if not initialized) / `createUrlTree(['/'])` (if authenticated).
|
||
- Keep `/bootstrap` route unchanged (fresh-install Create-admin modal still wins).
|
||
- The existing `authGuard` already handles `/` → unauthenticated users land at `/login` → LandingComponent.
|
||
|
||
#### 3.3.2 `LandingComponent` (smart component)
|
||
|
||
Layout (centered, single-column):
|
||
1. `<header>` — `<img [src]="logo()">` (empty string → hidden), `<h1>{{ pageTitle() }}</h1>`.
|
||
2. `<section class="welcome">` — Markdown render of `welcomeMarkdown()` via `MarkdownService.render()`.
|
||
3. `<button class="primary" (click)="openModal('login')">Login</button>`.
|
||
4. `<section class="blog-list">` — `@for (post of posts(); track post.id) { <article><h2>{{ post.title }}</h2><time>{{ post.publishedAt | date:'medium' }}</time><div [innerHTML]="post.bodyHtml"></div></article> }`. If `posts()` is empty, render `<p>No announcements yet.</p>`.
|
||
|
||
State (signals):
|
||
- `mode = signal<'closed' | 'login' | 'register'>('closed')`
|
||
- `loginForm = fb.nonNullable.group({ username: '', password: '' })`
|
||
- `registerForm = fb.nonNullable.group({ username: '', password: '', passwordConfirm: '' }, { validators: passwordMatchValidator })` (reuse existing `passwordMatchValidator` from `setup-create-admin.validators.ts`).
|
||
- `submitting = signal(false)`
|
||
- `error = signal<string | null>(null)`
|
||
- `errorCode = signal<string | null>(null)` — drives the "wait N seconds" message.
|
||
|
||
On submit (Login):
|
||
1. `if (this.submitting()) return;`
|
||
2. `this.submitting.set(true); this.error.set(null); this.errorCode.set(null);`
|
||
3. `await ensureCsrf()` (mirrors `SetupCreateAdminService.ensureCsrf`).
|
||
4. Call `auth.login({ username, password })` → returns `LoginResult`.
|
||
5. On success: `auth.setSession(...)`, `this.mode.set('closed')`, `await router.navigateByUrl('/')`.
|
||
6. On failure: surface message via `buildLoginFailureMessage({ code, message, retryAfterMs })`. For `RATE_LIMITED`, parse the wait-seconds out of the server message (backend already includes "Try again in {N}s"); display "Please wait N seconds before trying again." For `INVALID_CREDENTIALS`: "Invalid username or password."
|
||
7. `this.submitting.set(false)`; Login/Register button disabled while in flight (template binding `[disabled]="submitting()"`).
|
||
|
||
On submit (Register): same flow with `{ username, password, passwordConfirm }`. Backend rejects with `REGISTRATIONS_DISABLED` → modal shows "Registrations are currently disabled." `RATE_LIMITED` → "Too many registration attempts. Please wait."
|
||
|
||
Mode toggle: small text link at the bottom of the form (`"No account? Register here."` ↔ `"Already have an account? Login here."`). Switching modes clears the error state.
|
||
|
||
Close: a top-right `×` button inside the modal AND `keydown.escape` listener (the Job says "Close/Cancel" — providing both is standard).
|
||
|
||
#### 3.3.3 `LandingService`
|
||
|
||
```ts
|
||
@Injectable({ providedIn: 'root' })
|
||
export class LandingService {
|
||
private http = inject(HttpClient);
|
||
readonly posts = signal<BlogPost[]>([]);
|
||
readonly loading = signal(false);
|
||
async refresh() { /* GET /api/v1/blog/posts */ }
|
||
}
|
||
```
|
||
|
||
Called from `LandingComponent.ngOnInit` (after `BootstrapService.ready()`). Reuses the `cs` and `BootstrapService` already injected.
|
||
|
||
#### 3.3.4 `MarkdownService`
|
||
|
||
```ts
|
||
@Injectable({ providedIn: 'root' })
|
||
export class MarkdownService {
|
||
render(md: string): SafeHtml {
|
||
const html = marked.parse(md ?? '', { gfm: true, breaks: true });
|
||
const clean = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||
return this.sanitizer.bypassSecurityTrustHtml(clean);
|
||
}
|
||
}
|
||
```
|
||
- Per the `angular-security` skill: bypass is **acceptable only because** we DOMPurify-sanitize first and the input is sourced from authenticated admin settings + admin-authored blog posts (not user-controlled raw input).
|
||
- Exposed as a `SafeHtml` for use with `[innerHTML]`.
|
||
|
||
#### 3.3.5 `AuthService.login` addition
|
||
|
||
Add a typed helper:
|
||
```ts
|
||
async login(creds: { username: string; password: string }): Promise<LoginResult>
|
||
```
|
||
where
|
||
```ts
|
||
type LoginResult =
|
||
| { ok: true; accessToken: string; expiresIn: number; user: CurrentUser }
|
||
| { ok: false; status: number; code: string; message: string; retryAfterMs?: number };
|
||
```
|
||
This mirrors `SetupCreateAdminService.create`'s shape. Internal: `firstValueFrom(http.post('/api/v1/auth/login', creds, { withCredentials: true, observe: 'response' }))`, map success → `{ ok: true, ... }`, map error → `{ ok: false, code, message, status }`. The modal component owns the user-facing copy (so unit tests can pin it).
|
||
|
||
#### 3.3.6 `login-modal.service.ts` (pure helper module)
|
||
|
||
```ts
|
||
export function buildLoginFailureMessage(input: {
|
||
code: string;
|
||
message: string;
|
||
}): { text: string; retryAfterSeconds?: number } { /* pure */ }
|
||
```
|
||
- Parses "Try again in N s." from the backend message when `code === 'RATE_LIMITED'`.
|
||
- Returns `{ text, retryAfterSeconds }`. Unit-tested with table-driven cases.
|
||
|
||
#### 3.3.7 `decideLandingGuard` (pure)
|
||
|
||
```ts
|
||
export function decideLandingGuard(input: { initialized: boolean; isAuthenticated: boolean }, router: Pick<Router, 'createUrlTree'>): true | UrlTree {
|
||
if (!input.initialized) return router.createUrlTree(['/bootstrap']);
|
||
if (input.isAuthenticated) return router.createUrlTree(['/']);
|
||
return true;
|
||
}
|
||
```
|
||
|
||
#### 3.3.8 CSRF on Login
|
||
|
||
- `csrfInterceptor` already adds `X-CSRF-Token` on POST. The only "first-call" issue is when no `csrf` cookie exists yet. The new modal calls `await fetch('/api/v1/auth/csrf', { credentials: 'include' })` (same pattern as `SetupCreateAdminService.ensureCsrf`) before each login attempt so a fresh visitor's first request still carries the header.
|
||
- For all subsequent requests, `csrfInterceptor` attaches the cookie automatically.
|
||
|
||
### 3.4 Compliance with Existing Conventions
|
||
|
||
- Uses `@Public()` on the new controller (matches `SystemController`, `SetupController`).
|
||
- Uses zod DTOs and `ZodValidationPipe` (matches every other controller).
|
||
- Uses `ApiError` + `ERROR_CODES` (matches `AuthService.login`).
|
||
- Frontend uses standalone `OnPush`, signals, `ReactiveFormsModule` (matches `SetupCreateAdminComponent`).
|
||
- Frontend uses `@if`/`@for` control flow (matches `setup-create-admin.component.html`).
|
||
- Discriminated-union service results (matches `SetupCreateAdminService`).
|
||
- Pure decision functions for guards + error-message helpers (matches `decideAuthRedirect`, `decideAdminGuard`, `setup-create-admin.field-errors.ts`).
|
||
- Tests use the existing pattern: `tests/backend/*.spec.ts` with `initDb(app)` and `csrfClient(app)`; `tests/frontend/*.spec.ts` with jsdom.
|
||
|
||
## 4. Test Strategy
|
||
|
||
### 4.1 Backend tests
|
||
|
||
**`tests/backend/blog-public.spec.ts`** (new)
|
||
- Spins up `AppModule` + `initDb`.
|
||
- Seeds two `blog_post` rows directly via TypeORM repository: one `status='published'`, one `status='draft'`, one `status='published'` with later `published_at`.
|
||
- Asserts `GET /api/v1/blog/posts`:
|
||
- returns 200 without auth header,
|
||
- returns ONLY the two published rows,
|
||
- ordered most-recent first,
|
||
- contains `bodyHtml` (sanitized — strips `<script>`),
|
||
- does NOT include any user / scoreboard / challenge fields.
|
||
- Negative: assert that calling `GET /api/v1/blog/posts/drafts` does not exist and returns 404 (no admin-style endpoint leaks).
|
||
|
||
**`tests/backend/auth-register.spec.ts`** (new)
|
||
- Spins up `AppModule`, registers the first admin via `/api/v1/auth/register-first-admin`, sets `registrationsEnabled='true'` via `SettingsService.set`.
|
||
- Happy path: `POST /api/v1/auth/register` with `{username:'alice', password:'Sup3rSecret!Pass', passwordConfirm:'Sup3rSecret!Pass'}` → 201, returns `{accessToken, user:{role:'player'}}`, `rt` cookie set `HttpOnly`.
|
||
- Validation: `passwordConfirm !== password` → 400 `VALIDATION_FAILED`.
|
||
- Weak password → 400 `WEAK_PASSWORD`.
|
||
- Username taken → 409 `USERNAME_TAKEN`.
|
||
- Registrations disabled (`registrationsEnabled='false'`) → 403 `REGISTRATIONS_DISABLED`.
|
||
- Rate limit: prime `RegistrationRateLimitService` to 10 entries; next call → 429 `RATE_LIMITED`.
|
||
- CSRF: omit `X-CSRF-Token` → 403 `CSRF_INVALID`.
|
||
|
||
**`tests/backend/csrf-protected-routes.spec.ts`** (new)
|
||
- After the skip-list change: `POST /api/v1/auth/login` without `X-CSRF-Token` → 403 `CSRF_INVALID`; with token → 201.
|
||
|
||
### 4.2 Frontend tests
|
||
|
||
**`tests/frontend/landing-markdown.spec.ts`** (new)
|
||
- `MarkdownService.render('# Hello').toString()` includes `<h1>Hello</h1>`.
|
||
- `MarkdownService.render('<script>alert(1)</script>').toString()` does NOT contain `<script>`.
|
||
- `MarkdownService.render('[x](javascript:alert(1))')` strips the `javascript:` href.
|
||
- Pure; no TestBed needed (service is a stateless class).
|
||
|
||
**`tests/frontend/landing-guard.spec.ts`** (new)
|
||
- Table-driven tests for `decideLandingGuard({ initialized, isAuthenticated }, router)`:
|
||
- `{false, _}` → `/bootstrap`
|
||
- `{true, true}` → `/`
|
||
- `{true, false}` → `true`
|
||
- Pure; mirrors the existing `auth.guard.decision.spec.ts` style.
|
||
|
||
**`tests/frontend/landing-modal.spec.ts`** (new)
|
||
- Table-driven tests for `buildLoginFailureMessage`:
|
||
- `{code:'INVALID_CREDENTIALS', message:'Invalid credentials'}` → `{ text: 'Invalid username or password.' }`
|
||
- `{code:'RATE_LIMITED', message:'Too many failed attempts. Try again in 7s.'}` → `{ text: 'Please wait 7 seconds before trying again.', retryAfterSeconds: 7 }`
|
||
- `{code:'USERNAME_TAKEN', message:'Username already exists'}` → `{ text: 'Username already exists.' }`
|
||
- `{code:'REGISTRATIONS_DISABLED', message:'...'}` → `{ text: 'Registrations are currently disabled.' }`
|
||
- Pure; no TestBed.
|
||
|
||
### 4.3 Mocking Strategy
|
||
|
||
- Backend tests use the real `AppModule` against `:memory:` SQLite (matches `auth-refresh.spec.ts`, `setup-create-admin.spec.ts`, `registration-rate-limit.spec.ts`). CSRF is registered explicitly the same way. `RegistrationRateLimitService` is fetched from the DI container (`app.get(RegistrationRateLimitService)`) to prime the per-IP window deterministically — matches the existing pattern.
|
||
- Frontend tests target **pure functions only** (`MarkdownService.render`, `decideLandingGuard`, `buildLoginFailureMessage`). No DOM rendering, no `HttpClient`, no `TestBed.createComponent`. This keeps the test suite fast and isolated.
|
||
- No Playwright/JSDOM-rendered HTML is asserted (per the global test rule).
|
||
|
||
### 4.4 What is NOT covered (and why)
|
||
|
||
- No end-to-end UI flow tests for the modal open/close/submit (would require TestBed + DOM mocks; not justified for the success path; logic lives in pure helpers).
|
||
- No visual snapshot for the landing layout (per the test rule — no visual confirmation).
|
||
- No concurrency tests for `/api/v1/auth/register` (single-user creation is idempotent-by-username via the unique index; covered by the existing `users` unique-index test).
|
||
|
||
## 5. Acceptance Criteria Mapping
|
||
|
||
| Job acceptance criterion | Implementation |
|
||
|---|---|
|
||
| Fresh-install visit shows Create-admin modal, not landing page. | `decideLandingGuard` returns `/bootstrap` when `!initialized`. Existing `setup-create-admin` modal stays at `/bootstrap`. |
|
||
| After admin exists + logged out → landing page with logo, title, Markdown welcome, Login button, published blog posts only. | `LandingComponent` consumes `BootstrapService.payload()` for logo/title/welcomeMarkdown, `LandingService.posts()` for published-only blog list. |
|
||
| Login button opens modal in Login mode; "Register here" switches mode without closing. | Local `mode` signal + reactive forms; mode toggle is a template button that flips the signal. |
|
||
| Submitting valid credentials logs in + navigates to Challenges (default `/` route). | `auth.login(...) → setSession → router.navigateByUrl('/')`. |
|
||
| Invalid credentials → inline error + exponential backoff per IP/username + remaining-wait message. | Backend `LoginBackoffService` already enforces; `buildLoginFailureMessage` extracts `N s` from server message. |
|
||
| Registration creates account + auto-login. | `AuthService.registerPlayer` creates player + calls `mintSession`. |
|
||
| Registration rate-limit message when >10/min from same IP. | `RegistrationRateLimitService` already enforces; modal surfaces `RATE_LIMITED` via `buildLoginFailureMessage`. |
|
||
| Registration rejected when disabled in General settings. | `SettingsService.get('registrationsEnabled') === 'true'` check in `registerPlayer`. |
|
||
| Authenticated users never see landing/modal; routed to main area. | `decideLandingGuard` + `decideAuthRedirect` + `authGuard` chain. |
|
||
| No challenge/scoreboard/admin data reachable unauthenticated. | `BlogController` is the only new public endpoint; all other admin/challenge/scoreboard controllers are gated by `JwtAuthGuard` + `AdminGuard` already. |
|
||
| All state-changing requests carry CSRF; cookies HttpOnly + sameSite. | CSRF skip-list cleaned up; cookies set via existing `setRefreshCookie` which writes `HttpOnly` + `sameSite=lax/strict`. |
|
||
|
||
## 6. Implementation Order (suggested)
|
||
|
||
1. Backend: new error code + `BlogModule` + `BlogController`/`BlogService` + tests.
|
||
2. Backend: `AuthService.registerPlayer` + DTO + controller method + tests.
|
||
3. Backend: tighten CSRF skip-list + test.
|
||
4. Frontend: add `marked` + `dompurify` to `frontend/package.json`.
|
||
5. Frontend: `MarkdownService` + tests.
|
||
6. Frontend: `decideLandingGuard` + tests.
|
||
7. Frontend: `LandingService`, `buildLoginFailureMessage` + tests.
|
||
8. Frontend: `AuthService.login` discriminated helper.
|
||
9. Frontend: `LandingComponent` (template + css + ts).
|
||
10. Frontend: route wiring (`/login` → landing component, guarded).
|
||
11. Run `npm test` from repo root; ensure both backend and frontend projects pass.
|
||
12. Run `npm run build` to confirm both workspaces compile.
|