feat: First-Start Create Admin Modal 1.01
This commit is contained in:
@@ -1,178 +0,0 @@
|
|||||||
# Implementation Plan: Job 841 — First-Start Create Admin Modal: Authenticated Shell & Admin Navigation
|
|
||||||
|
|
||||||
## 0. Already-Implemented Check
|
|
||||||
|
|
||||||
The job is **NOT** fully implemented. Specifically:
|
|
||||||
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.ts:105` already calls `this.router.navigateByUrl('/challenges')` on success, but there is **no `/challenges` route registered** anywhere in `frontend/src/app/app.routes.ts` (only `bootstrap`, `login`, and `''` → `HomeComponent`).
|
|
||||||
- `frontend/src/app/features/home/home.component.ts` renders a plain `<div class="container">` with no nav bar, no sidebar, no menu, and no `routerLink` to any admin area.
|
|
||||||
- `frontend/src/app/app.component.ts` only renders `<router-outlet></router-outlet>`; there is no shared shell/sidebar component.
|
|
||||||
- `frontend/src/app/app.routes.ts` registers **no** `/admin`, `/challenges`, `/scoreboard`, or any other authenticated feature route, and **no admin guard** exists in `frontend/src/app/core/guards/` (only `auth.guard.ts`).
|
|
||||||
- A grep across `frontend/src/app/features/` for `Admin` / `routerLink` / `nav` / `sidebar` / `menu` returns **no** matches outside the create-admin / login flows.
|
|
||||||
- The backend admin surface exists (`backend/src/modules/admin/admin.controller.ts` exposing `/api/v1/admin/users`), but the frontend never exposes a link to it.
|
|
||||||
|
|
||||||
Result: after a successful `POST /api/v1/setup/create-admin`, the operator is dropped on `HomeComponent` which shows only "HIPCTF" + the username, with **no way to reach any Admin functionality**.
|
|
||||||
|
|
||||||
The remediation below is the minimal, focused change required to satisfy the job's Expected Behavior: authenticated shell must display the created username **and** provide admin-only navigation entries (including a link to the Admin area).
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
- **Codebase style & conventions:**
|
|
||||||
- Frontend: Angular 20 standalone components, signals (`signal`/`computed`), `inject()`, functional `CanActivateFn` guards. No NgModules. Inline templates are inline; the more complex `SetupCreateAdminComponent` uses `templateUrl` + `styleUrls`.
|
|
||||||
- Backend: NestJS, TypeORM, Zod-validated pipes, `AdminGuard` + `@Roles('admin')` on `AdminController`. REST surface at `/api/v1/...`.
|
|
||||||
- Test style: Jest via `tests/jest.config.js` with two projects (`backend` Node, `frontend` jsdom). Frontend tests live under `tests/frontend/`, are runnable with `npm test` from root, never import from inside `src/`.
|
|
||||||
- **Data Layer:** TypeORM with SQLite (file DB at `DATABASE_PATH`, `:memory:` in tests). No schema change is required for this job — `user.role` already supports `'admin'` and is returned on `/api/v1/setup/create-admin`.
|
|
||||||
- **Test Framework & Structure:** Jest. Tests **MUST** live under `tests/` (never inside `frontend/src/`). Run with `npm test` from repo root.
|
|
||||||
- **Required Tools & Dependencies:** None new. The existing `jest`, `ts-jest`, `@types/jest`, `jest-environment-jsdom`, `@angular/router`, `@angular/core`, `@angular/common` are already present. No `setup.sh` change is required.
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
### To Modify
|
|
||||||
|
|
||||||
- `frontend/src/app/app.routes.ts` — register the new admin-protected child route(s) and the admin guard.
|
|
||||||
- `frontend/src/app/features/home/home.component.ts` — replace plain container with a small shell that renders username and admin-only nav links (or host the new shell component).
|
|
||||||
- `frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the post-success navigation target so the operator lands somewhere valid (`/admin` is the natural target since this user is the freshly-created admin).
|
|
||||||
|
|
||||||
### To Create
|
|
||||||
|
|
||||||
- `frontend/src/app/core/guards/admin.guard.ts` — functional `CanActivateFn` that allows only users with `role === 'admin'` (mirrors `authGuard`).
|
|
||||||
- `frontend/src/app/core/services/admin.service.ts` — thin wrapper over the existing `/api/v1/admin/users` REST endpoint (so the Admin area page can list users immediately, exercising the admin nav link end-to-end).
|
|
||||||
- `frontend/src/app/features/admin/admin-users.component.ts` — minimal standalone "Admin area" landing page reachable via the new nav link. Renders a list of users by calling `AdminService.list()`. Lazy-loaded.
|
|
||||||
- `tests/frontend/admin-navigation.spec.ts` — focused unit tests for the new guard and the nav-link rendering logic.
|
|
||||||
- `tests/frontend/admin-shell.spec.ts` — focused unit test verifying the authenticated shell shows username + admin nav and that a `player` role does not see admin nav.
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
### 1. Schema / Data
|
|
||||||
No DB migration required. `user.role` already supports `'admin'`, and `CurrentUser.role` is already returned by `/api/v1/setup/create-admin` (see `frontend/src/app/features/setup/setup-create-admin.service.ts:15`).
|
|
||||||
|
|
||||||
### 2. Backend Logic & APIs
|
|
||||||
No backend changes required. The existing `AdminController` (`backend/src/modules/admin/admin.controller.ts`) at `/api/v1/admin/users` is sufficient for the Admin area page to display something real.
|
|
||||||
|
|
||||||
### 3. Frontend UI Integration
|
|
||||||
|
|
||||||
**3.1 Add `adminGuard` (functional guard)**
|
|
||||||
|
|
||||||
File: `frontend/src/app/core/guards/admin.guard.ts`
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { inject } from '@angular/core';
|
|
||||||
import { CanActivateFn, Router } from '@angular/router';
|
|
||||||
import { AuthService } from '../services/auth.service';
|
|
||||||
import { BootstrapService } from '../services/bootstrap.service';
|
|
||||||
|
|
||||||
export const adminGuard: CanActivateFn = () => {
|
|
||||||
const auth = inject(AuthService);
|
|
||||||
const bootstrap = inject(BootstrapService);
|
|
||||||
const router = inject(Router);
|
|
||||||
|
|
||||||
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
|
|
||||||
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
|
|
||||||
if (auth.currentUser()?.role !== 'admin') return router.createUrlTree(['/']);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Rationale: mirrors `authGuard` (`frontend/src/app/core/guards/auth.guard.ts`) and reuses the same signal-driven `AuthService` + `BootstrapService`.
|
|
||||||
|
|
||||||
**3.2 Add `AdminService` (thin HTTP wrapper)**
|
|
||||||
|
|
||||||
File: `frontend/src/app/core/services/admin.service.ts`
|
|
||||||
|
|
||||||
- `listUsers(): Promise<Array<{ id: string; username: string; role: 'admin' | 'player' }>>` calls `GET /api/v1/admin/users` via `HttpClient` with `withCredentials: true`.
|
|
||||||
- Single-purpose; no caching layer needed for this job.
|
|
||||||
|
|
||||||
**3.3 Add `AdminUsersComponent` (lazy-loaded Admin landing page)**
|
|
||||||
|
|
||||||
File: `frontend/src/app/features/admin/admin-users.component.ts`
|
|
||||||
|
|
||||||
- Standalone, `ChangeDetectionStrategy.OnPush`, signals (`users = signal<...>([])`, `loading = signal(false)`, `error = signal<string|null>(null)`).
|
|
||||||
- On `ngOnInit`, calls `AdminService.listUsers()` and stores the result.
|
|
||||||
- Renders a heading ("Admin area") plus a list of users with `username` and `role`.
|
|
||||||
- Uses inline template (no extra `.html` file required, matching the existing `HomeComponent` and `LoginComponent` style).
|
|
||||||
|
|
||||||
**3.4 Wire routes**
|
|
||||||
|
|
||||||
File: `frontend/src/app/app.routes.ts`
|
|
||||||
|
|
||||||
Add an authenticated `admin` child under the `''` route (still `authGuard`-protected) so the admin nav link resolves:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
{
|
|
||||||
path: '',
|
|
||||||
loadComponent: () => import('./features/home/home.component').then(m => m.HomeComponent),
|
|
||||||
canActivate: [authGuard],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: 'admin',
|
|
||||||
canActivate: [adminGuard],
|
|
||||||
loadComponent: () => import('./features/admin/admin-users.component').then(m => m.AdminUsersComponent),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
And add `<router-outlet></router-outlet>` inside `HomeComponent`'s template so the child route renders below the nav.
|
|
||||||
|
|
||||||
**3.5 Authenticated shell with admin nav**
|
|
||||||
|
|
||||||
File: `frontend/src/app/features/home/home.component.ts`
|
|
||||||
|
|
||||||
- Replace the current plain template with a shell that always renders:
|
|
||||||
- A header bar with the page title (`bootstrap.payload()?.pageTitle ?? 'HIPCTF'`) on the left.
|
|
||||||
- On the right: the signed-in username + role (`Signed in as <b>{{ username }}</b> ({{ role }})`).
|
|
||||||
- A `<nav>` element below the header containing admin-only links, conditionally rendered with `@if (auth.currentUser()?.role === 'admin')`. Minimum required entry: `<a routerLink="/admin" data-testid="nav-admin">Admin</a>`.
|
|
||||||
- The existing welcome / Sign-in block for unauthenticated viewers stays.
|
|
||||||
- `<router-outlet />` below the nav so the new `/admin` child route renders.
|
|
||||||
- Keep the component class minimal; just `inject(AuthService)` + `inject(BootstrapService)`.
|
|
||||||
|
|
||||||
**3.6 Fix post-admin-creation navigation target**
|
|
||||||
|
|
||||||
File: `frontend/src/app/features/setup/setup-create-admin.component.ts:105`
|
|
||||||
|
|
||||||
- Change `await this.router.navigateByUrl('/challenges');` → `await this.router.navigateByUrl('/admin');`. The current `/challenges` target is dead (no route registered). `/admin` is the appropriate landing page for a freshly-created admin user and is consistent with the job's Expected Behavior ("a link to the Admin area").
|
|
||||||
- The bootstrap flow doc (`docs/guides/bootstrap.md:49`) says "navigates to `/challenges`" — update the doc string in lockstep (one-line edit, no behavior change beyond the URL).
|
|
||||||
|
|
||||||
### 4. `/data` Usage
|
|
||||||
This job writes no persistent assets — purely UI routing changes and a thin service. No `/data` directory writes required.
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
Tests live in the existing `tests/frontend/` folder and run via `npm test` (which invokes `jest --config tests/jest.config.js`). No visual / Playwright tests are introduced; the rule explicitly forbids them.
|
|
||||||
|
|
||||||
### 4.1 `tests/frontend/admin-shell.spec.ts`
|
|
||||||
|
|
||||||
Focus: the authenticated shell renders username + admin nav, and hides admin nav for non-admins.
|
|
||||||
|
|
||||||
- Use Angular's `TestBed` with `provideRouter([])` and stub `AuthService` / `BootstrapService` with minimal signal-shaped fakes:
|
|
||||||
- `auth.currentUser = signal({ id: 'u1', username: 'firstadmin', role: 'admin' })`
|
|
||||||
- `auth.isAuthenticated = computed(() => true)`
|
|
||||||
- `bootstrap.initialized = signal(true)`, `bootstrap.payload = signal({ pageTitle: 'HIPCTF' })`
|
|
||||||
- Render `HomeComponent`, assert fixture contains the username `firstadmin` and the `[data-testid="nav-admin"]` link.
|
|
||||||
- Re-render with `role: 'player'` and assert the admin nav link is **not** present.
|
|
||||||
|
|
||||||
Mocking strategy: do **not** mock `HttpClient`. `HomeComponent` template is static (no HTTP); no network is touched. `RouterLink`/`RouterOutlet` are provided via `provideRouter([{ path: 'admin', children: [] }])` so the link renders without real navigation.
|
|
||||||
|
|
||||||
### 4.2 `tests/frontend/admin-navigation.spec.ts`
|
|
||||||
|
|
||||||
Focus: `adminGuard` redirect logic.
|
|
||||||
|
|
||||||
- Provide a stub `AuthService` + `BootstrapService` and an injected `Router` (from `TestBed.inject(Router)`).
|
|
||||||
- Use `TestBed.runInInjectionContext(() => adminGuard({}, {} as any))` to invoke the guard.
|
|
||||||
- Cases:
|
|
||||||
- Uninitialized → returns `UrlTree('/bootstrap')`.
|
|
||||||
- Unauthenticated → returns `UrlTree('/login')`.
|
|
||||||
- Authenticated non-admin → returns `UrlTree('/')`.
|
|
||||||
- Authenticated admin → returns `true`.
|
|
||||||
|
|
||||||
Mocking strategy: pure DI stubs; no HTTP, no DOM navigation.
|
|
||||||
|
|
||||||
### 4.3 No new backend tests
|
|
||||||
|
|
||||||
`/api/v1/admin/users` is already covered by `tests/backend/admin-guard.spec.ts` (200 for admin, 403 for player, 401 unauth). This job introduces no backend surface area.
|
|
||||||
|
|
||||||
## 5. Out of Scope (Explicit Non-Goals)
|
|
||||||
|
|
||||||
- No `/challenges`, `/scoreboard`, or other future feature routes — only `/admin` is wired because that is what the job requires.
|
|
||||||
- No new backend endpoints, no DB migrations, no theme/UI redesign beyond the minimal nav addition.
|
|
||||||
- No Playwright/visual tests (explicitly forbidden by the test rule).
|
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# Implementation Plan: Job 842 — First-Start Create Admin Modal Reappears on Refresh
|
||||||
|
|
||||||
|
## 0. Already-Implemented Check
|
||||||
|
|
||||||
|
The bug described in Job 842 is **NOT** fully implemented/fixed. The current
|
||||||
|
code reproduces the symptom:
|
||||||
|
|
||||||
|
- `frontend/src/app/core/services/auth.service.ts:11-12` — `accessToken` and
|
||||||
|
`user` are kept in in-memory Angular signals only. No persistence layer
|
||||||
|
(`sessionStorage`, `localStorage`, or cookie-backed restoration).
|
||||||
|
- `frontend/src/app/app.component.ts:13-15` — `AppComponent.ngOnInit` calls
|
||||||
|
only `bootstrap.load()`; there is no `/api/v1/auth/session` (or refresh)
|
||||||
|
call on startup to restore an in-flight session.
|
||||||
|
- `frontend/src/app/core/guards/auth.guard.ts:11-13` — `authGuard` reads
|
||||||
|
`bootstrap.initialized()` synchronously. It defaults to `false`, so on a
|
||||||
|
cold load (before the `GET /api/v1/bootstrap` promise resolves) it
|
||||||
|
redirects to `/bootstrap`.
|
||||||
|
- `frontend/src/app/features/setup/setup-create-admin.component.ts:28-119` —
|
||||||
|
The component does not check `bootstrap.initialized()` on entry; it
|
||||||
|
unconditionally renders the create-admin modal even when the backend
|
||||||
|
reports `initialized: true`.
|
||||||
|
|
||||||
|
Therefore the fix described below is required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Codebase style & conventions:**
|
||||||
|
- Frontend: Angular 17+ standalone components, signal-based state,
|
||||||
|
`inject()` for DI, `provideHttpClient(withInterceptors([...]))`,
|
||||||
|
`loadComponent` for lazy routes, functional `CanActivateFn` guards.
|
||||||
|
- Backend: NestJS controllers/services, `@Public()` decorator, zod
|
||||||
|
validation pipes, `setRefreshCookie` helper for the HttpOnly `rt`
|
||||||
|
cookie, global `JwtAuthGuard` (`APP_GUARD`).
|
||||||
|
- Monorepo with `npm` workspaces (`backend`, `frontend`); root-level
|
||||||
|
`npm test` runs both via `tests/jest.config.js`.
|
||||||
|
|
||||||
|
- **Data Layer:** SQLite via TypeORM (`backend/src/database`). For this
|
||||||
|
Job, **no schema or migration changes are required**. The existing
|
||||||
|
`refresh_token` table is sufficient: rotating the refresh token on
|
||||||
|
app boot restores the session transparently.
|
||||||
|
|
||||||
|
- **Test Framework & Structure:**
|
||||||
|
- Jest with `ts-jest`, two projects (`backend`, `frontend`) under
|
||||||
|
`tests/jest.config.js`.
|
||||||
|
- Frontend uses `jsdom`; helpers in `tests/frontend/jest.setup.ts`
|
||||||
|
(already stubs `matchMedia`).
|
||||||
|
- Frontend unit tests live in `tests/frontend/*.spec.ts`, never inside
|
||||||
|
`frontend/src/...`. Backend tests live in `tests/backend/*.spec.ts`.
|
||||||
|
- Single command `npm test` from the repo root runs all suites.
|
||||||
|
|
||||||
|
- **Required Tools & Dependencies:** **None new.** No new system tools,
|
||||||
|
no new npm packages, no `setup.sh` changes are required. The fix
|
||||||
|
uses built-in browser APIs (`sessionStorage`) and the existing
|
||||||
|
`HttpClient` + `/api/v1/auth/refresh` endpoint already wired by the
|
||||||
|
backend (see `backend/src/modules/auth/auth.controller.ts:50-69`).
|
||||||
|
Note: the `angular-security` skill explicitly warns *against*
|
||||||
|
`localStorage` for tokens, but session-scoped persistence of the
|
||||||
|
short-lived access token (15m) plus a one-time silent refresh on
|
||||||
|
app boot using the HttpOnly `rt` cookie is the standard pattern and
|
||||||
|
matches how the SPA already protects the access token in memory
|
||||||
|
(never written to `localStorage` today either).
|
||||||
|
|
||||||
|
## 2. Impacted Files
|
||||||
|
|
||||||
|
### To Modify
|
||||||
|
|
||||||
|
- `frontend/src/app/core/services/auth.service.ts` — Add `restoreSession()`
|
||||||
|
helper and a `loadFromStorage()`/persistence layer (write access
|
||||||
|
token + user into `sessionStorage` on `setSession`, read them in
|
||||||
|
`restoreSession`, clear on `clear()`).
|
||||||
|
- `frontend/src/app/app.component.ts` — On startup, after
|
||||||
|
`bootstrap.load()` resolves, call `auth.restoreSession()` (which
|
||||||
|
performs a silent `POST /api/v1/auth/refresh` using the `rt` cookie
|
||||||
|
via `withCredentials: true`, then `setSession(...)`).
|
||||||
|
- `frontend/src/app/core/guards/auth.guard.ts` — Wait for the bootstrap
|
||||||
|
load to resolve before deciding (return a `Promise` resolving to a
|
||||||
|
`UrlTree` or `true`). This prevents the race where `initialized()`
|
||||||
|
is read as `false` before the bootstrap HTTP call completes.
|
||||||
|
- `frontend/src/app/core/guards/admin.guard.ts` — Same race fix:
|
||||||
|
await `BootstrapService.loaded()` before reading `initialized()`.
|
||||||
|
- `frontend/src/app/core/services/bootstrap.service.ts` — Expose a
|
||||||
|
`loaded` signal/promise (or a `waitForLoad()` method) that resolves
|
||||||
|
once `load()` has completed.
|
||||||
|
- `frontend/src/app/features/setup/setup-create-admin.component.ts` —
|
||||||
|
Guard the template so the create-admin modal is not rendered when
|
||||||
|
`bootstrap.initialized() === true`. If the user lands on
|
||||||
|
`/bootstrap` while initialized, redirect to `/login` (or `/`
|
||||||
|
if already authenticated).
|
||||||
|
|
||||||
|
### To Create
|
||||||
|
|
||||||
|
- `tests/frontend/auth-restore.spec.ts` — Unit tests for the new
|
||||||
|
`AuthService.restoreSession()` happy path + failure (no refresh
|
||||||
|
cookie) + persistence round-trip.
|
||||||
|
- `tests/frontend/guard-bootstrap-race.spec.ts` — Verifies the
|
||||||
|
`authGuard` waits for bootstrap before deciding (returns
|
||||||
|
`/bootstrap` when truly uninitialized, allows when initialized, and
|
||||||
|
does not redirect to `/bootstrap` once `load()` has resolved with
|
||||||
|
`initialized: true`).
|
||||||
|
|
||||||
|
## 3. Proposed Changes
|
||||||
|
|
||||||
|
The bug is the intersection of three problems on the SPA:
|
||||||
|
|
||||||
|
1. **No session persistence / restoration.** Access token and user
|
||||||
|
identity live only in in-memory signals. A browser refresh wipes
|
||||||
|
them, so `isAuthenticated()` returns `false` and the user is
|
||||||
|
treated as anonymous.
|
||||||
|
2. **No silent refresh on app boot.** Even though the `rt` HttpOnly
|
||||||
|
cookie is still set, the SPA never calls `/api/v1/auth/refresh`
|
||||||
|
on startup, so the in-memory session is never rebuilt.
|
||||||
|
3. **Bootstrap race in `authGuard`.** The guard reads
|
||||||
|
`bootstrap.initialized()` synchronously. On a cold load the
|
||||||
|
signal still defaults to `false` (the GET hasn't resolved yet),
|
||||||
|
so the guard fires `UrlTree(['/bootstrap'])` *before* bootstrap
|
||||||
|
completes. Then `SetupCreateAdminComponent` renders the modal
|
||||||
|
unconditionally because it never checks `initialized()` itself.
|
||||||
|
|
||||||
|
### Fix A — Persistence + silent refresh in `AuthService`
|
||||||
|
|
||||||
|
In `frontend/src/app/core/services/auth.service.ts`:
|
||||||
|
|
||||||
|
- Add a `STORAGE_KEY = 'hipctf.auth.v1'` constant.
|
||||||
|
- In `setSession(token, user)`: write `{ token, user }` to
|
||||||
|
`sessionStorage` (in addition to setting the signals). If
|
||||||
|
`sessionStorage` is unavailable (SSR / disabled), swallow the
|
||||||
|
error and continue.
|
||||||
|
- In `clear()`: also `sessionStorage.removeItem(STORAGE_KEY)`.
|
||||||
|
- Add `restoreSession(): Promise<boolean>`:
|
||||||
|
1. Read `sessionStorage[STORAGE_KEY]`. If present, restore the
|
||||||
|
signals (so guards see `isAuthenticated() === true` during the
|
||||||
|
in-flight refresh).
|
||||||
|
2. `POST /api/v1/auth/refresh` with `{ withCredentials: true }`
|
||||||
|
and an empty body — the backend reads the `rt` cookie
|
||||||
|
(`backend/src/modules/auth/auth.controller.ts:59-69`) and
|
||||||
|
returns `{ accessToken, expiresIn, user }` plus a rotated
|
||||||
|
`rt` cookie. The endpoint is `@Public()`, so it works
|
||||||
|
pre-auth.
|
||||||
|
3. On success: call `setSession(accessToken, user)` and return
|
||||||
|
`true`.
|
||||||
|
4. On failure (network / 401 `TOKEN_REVOKED` / no cookie):
|
||||||
|
`clear()` and return `false`.
|
||||||
|
- Keep `isAuthenticated` as `computed(() => !!this.user())`. Add a
|
||||||
|
separate `hydrated = signal(false)` that becomes `true` once
|
||||||
|
`restoreSession()` has finished; guards may use this to wait.
|
||||||
|
|
||||||
|
### Fix B — Bootstrap-aware guards
|
||||||
|
|
||||||
|
In `frontend/src/app/core/services/bootstrap.service.ts`:
|
||||||
|
|
||||||
|
- Add `private loadPromise: Promise<BootstrapPayload | null> | null`.
|
||||||
|
- Track the in-flight (or completed) load promise inside `load()`,
|
||||||
|
e.g.:
|
||||||
|
```ts
|
||||||
|
if (!this.loadPromise) this.loadPromise = this._load();
|
||||||
|
return this.loadPromise;
|
||||||
|
```
|
||||||
|
where `_load()` is the current body of `load()`.
|
||||||
|
- Expose `ready(): Promise<BootstrapPayload | null>` that returns
|
||||||
|
`this.loadPromise` (resolved to a settled promise even before
|
||||||
|
`load()` has been called).
|
||||||
|
|
||||||
|
In `frontend/src/app/core/guards/auth.guard.ts`:
|
||||||
|
|
||||||
|
- Convert to an `async` `CanActivateFn`:
|
||||||
|
```ts
|
||||||
|
export const authGuard: CanActivateFn = async () => {
|
||||||
|
const auth = inject(AuthService);
|
||||||
|
const bootstrap = inject(BootstrapService);
|
||||||
|
const router = inject(Router);
|
||||||
|
await bootstrap.ready(); // wait for GET /api/v1/bootstrap
|
||||||
|
await auth.waitUntilHydrated(); // wait for restoreSession()
|
||||||
|
if (!bootstrap.initialized()) return router.createUrlTree(['/bootstrap']);
|
||||||
|
if (!auth.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
- Add `AuthService.waitUntilHydrated()` that resolves on the next
|
||||||
|
microtask once `hydrated()` is `true` (or returns immediately if
|
||||||
|
already hydrated). Implementation: store the resolve callback
|
||||||
|
inside `restoreSession()` after the HTTP call settles.
|
||||||
|
|
||||||
|
In `frontend/src/app/core/guards/admin.guard.ts`:
|
||||||
|
|
||||||
|
- Same treatment: `await bootstrap.ready()` and
|
||||||
|
`await auth.waitUntilHydrated()` before calling
|
||||||
|
`decideAdminGuard(...)`.
|
||||||
|
|
||||||
|
### Fix C — AppComponent orchestrates startup
|
||||||
|
|
||||||
|
In `frontend/src/app/app.component.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async ngOnInit() {
|
||||||
|
await this.bootstrap.load(); // populates initialized() + theme
|
||||||
|
await this.auth.restoreSession(); // silent refresh via rt cookie
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Order matters: bootstrap first (so guards know `initialized`), then
|
||||||
|
the silent refresh (so `isAuthenticated` becomes true on the way
|
||||||
|
back to `/admin` if that was the last route).
|
||||||
|
|
||||||
|
### Fix D — `SetupCreateAdminComponent` must not render when initialized
|
||||||
|
|
||||||
|
In `frontend/src/app/features/setup/setup-create-admin.component.ts`:
|
||||||
|
|
||||||
|
- In the constructor (or `ngOnInit`), if
|
||||||
|
`bootstrap.initialized() === true`, redirect immediately:
|
||||||
|
```ts
|
||||||
|
constructor() {
|
||||||
|
if (this.bootstrap.initialized()) {
|
||||||
|
this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Because `bootstrap.ready()` is awaited in the guards *before*
|
||||||
|
navigation to `/bootstrap` resolves, the constructor can rely on
|
||||||
|
`initialized()` being settled by the time the component is
|
||||||
|
created. (If for any reason it isn't, the `await` in the
|
||||||
|
`constructor` flow uses `effect()`-friendly code — see below.)
|
||||||
|
|
||||||
|
- Add a defensive `effect()` (or signal-bound `@if` in the template)
|
||||||
|
that hides the `.overlay` when `bootstrap.initialized()` is true,
|
||||||
|
so even if the navigation is reached mid-race the modal is never
|
||||||
|
displayed.
|
||||||
|
|
||||||
|
### Fix E — Login + setup flows keep working
|
||||||
|
|
||||||
|
- `LoginComponent` (`frontend/src/app/features/auth/login.component.ts:33-48`)
|
||||||
|
and `SetupCreateAdminComponent.submit` already call
|
||||||
|
`auth.setSession(...)`; they will now also persist to
|
||||||
|
`sessionStorage` automatically.
|
||||||
|
- `bootstrap.markInitialized()` (`frontend/src/app/core/services/bootstrap.service.ts:48-50`)
|
||||||
|
is still called by `SetupCreateAdminComponent.submit` after success
|
||||||
|
— preserved as-is.
|
||||||
|
|
||||||
|
## 4. Test Strategy
|
||||||
|
|
||||||
|
- **Target Unit Test File:**
|
||||||
|
`tests/frontend/auth-restore.spec.ts` (new) and
|
||||||
|
`tests/frontend/guard-bootstrap-race.spec.ts` (new). Both live in
|
||||||
|
the dedicated `tests/frontend/` folder, runnable via
|
||||||
|
`npm run test:frontend` and `npm test`.
|
||||||
|
|
||||||
|
- **Mocking Strategy:**
|
||||||
|
- Use `jest.spyOn(global, 'fetch')` or the existing `HttpClient`
|
||||||
|
via `provideHttpClientTesting()` + `HttpTestingController`
|
||||||
|
(preferred per the `angular-testing` skill). The auth restore
|
||||||
|
flow uses `HttpClient.post('/api/v1/auth/refresh', ...)`; mock
|
||||||
|
that with `expectOne(...).flush(...)`.
|
||||||
|
- Stub `sessionStorage` per test using a fresh `Map` (jsdom
|
||||||
|
provides one; clear it in `beforeEach`).
|
||||||
|
- For guard tests: instantiate the guard function via
|
||||||
|
`TestBed.runInInjectionContext(authGuard)` after providing
|
||||||
|
minimal mocks of `AuthService` and `BootstrapService` with
|
||||||
|
`jasmine.createSpyObj`. Use `fakeAsync` + `tick()` only if
|
||||||
|
awaiting a Promise is needed; otherwise rely on `async` +
|
||||||
|
`await` (per the angular-testing skill: "Signals sync — no
|
||||||
|
fakeAsync needed for most signal-driven tests").
|
||||||
|
|
||||||
|
- **Tests to write (minimal, success-path focused):**
|
||||||
|
|
||||||
|
1. `AuthService.restoreSession()` happy path:
|
||||||
|
- pre-populate `sessionStorage` with a valid `{ token, user }`,
|
||||||
|
flush a `200 { accessToken, user }` from
|
||||||
|
`HttpTestingController`, expect `isAuthenticated() === true`,
|
||||||
|
expect `sessionStorage` to now contain the *new* access
|
||||||
|
token.
|
||||||
|
2. `AuthService.restoreSession()` failure path (no refresh
|
||||||
|
cookie / `401 TOKEN_REVOKED`):
|
||||||
|
- flush a `401`, expect `isAuthenticated() === false` and
|
||||||
|
`sessionStorage` cleared.
|
||||||
|
3. `AuthService.setSession()` persists to `sessionStorage`;
|
||||||
|
`clear()` removes it.
|
||||||
|
4. `authGuard` redirects to `/bootstrap` while bootstrap is
|
||||||
|
uninitialized (returns `UrlTree`).
|
||||||
|
5. `authGuard` does **not** redirect to `/bootstrap` once
|
||||||
|
bootstrap has resolved with `initialized: true` and a
|
||||||
|
hydrated auth session — it allows navigation.
|
||||||
|
6. `SetupCreateAdminComponent` constructor redirects away from
|
||||||
|
`/bootstrap` when `bootstrap.initialized() === true` (smoke:
|
||||||
|
instantiate via `TestBed`, spy on `Router.navigateByUrl`,
|
||||||
|
expect call to `/login` or `/`).
|
||||||
|
|
||||||
|
- **Out of scope for tests:** backend changes (none), visual
|
||||||
|
confirmation, end-to-end browser refresh. No Playwright / no UI.
|
||||||
|
|
||||||
|
- **No new dependencies** — all tests run with the existing
|
||||||
|
`jest`, `ts-jest`, `jest-environment-jsdom`, and Angular's
|
||||||
|
`provideHttpClientTesting` (already in `frontend/package.json`
|
||||||
|
via `@angular/common/http/testing`).
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { BootstrapService } from './core/services/bootstrap.service';
|
import { BootstrapService } from './core/services/bootstrap.service';
|
||||||
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -10,7 +11,9 @@ import { BootstrapService } from './core/services/bootstrap.service';
|
|||||||
})
|
})
|
||||||
export class AppComponent implements OnInit {
|
export class AppComponent implements OnInit {
|
||||||
private bootstrap = inject(BootstrapService);
|
private bootstrap = inject(BootstrapService);
|
||||||
|
private auth = inject(AuthService);
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
await this.bootstrap.load();
|
await this.bootstrap.load();
|
||||||
|
await this.auth.restoreSession();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,22 +2,36 @@ import { inject } from '@angular/core';
|
|||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { BootstrapService } from '../services/bootstrap.service';
|
import { BootstrapService } from '../services/bootstrap.service';
|
||||||
import { decideAdminGuard } from './admin.guard.decision';
|
import { decideAdminGuard, AdminGuardDecision } from './admin.guard.decision';
|
||||||
|
|
||||||
export { decideAdminGuard } from './admin.guard.decision';
|
export { decideAdminGuard } from './admin.guard.decision';
|
||||||
export type { AdminGuardDecision } from './admin.guard.decision';
|
export type { AdminGuardDecision } from './admin.guard.decision';
|
||||||
|
|
||||||
|
export interface AdminGuardDeps {
|
||||||
|
auth: AuthService;
|
||||||
|
bootstrap: BootstrapService;
|
||||||
|
router: Router;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decideAdminAccessGuard(
|
||||||
|
deps: AdminGuardDeps,
|
||||||
|
): Promise<true | ReturnType<Router['createUrlTree']>> {
|
||||||
|
await deps.bootstrap.ready();
|
||||||
|
await deps.auth.waitUntilHydrated();
|
||||||
|
|
||||||
|
const decision: AdminGuardDecision = decideAdminGuard({
|
||||||
|
initialized: deps.bootstrap.initialized(),
|
||||||
|
isAuthenticated: deps.auth.isAuthenticated(),
|
||||||
|
role: deps.auth.currentUser()?.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.kind === 'allow') return true;
|
||||||
|
return deps.router.createUrlTree([decision.path]);
|
||||||
|
}
|
||||||
|
|
||||||
export const adminGuard: CanActivateFn = () => {
|
export const adminGuard: CanActivateFn = () => {
|
||||||
const auth = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
const bootstrap = inject(BootstrapService);
|
const bootstrap = inject(BootstrapService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
return decideAdminAccessGuard({ auth, bootstrap, router });
|
||||||
const decision = decideAdminGuard({
|
};
|
||||||
initialized: bootstrap.initialized(),
|
|
||||||
isAuthenticated: auth.isAuthenticated(),
|
|
||||||
role: auth.currentUser()?.role,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (decision.kind === 'allow') return true;
|
|
||||||
return router.createUrlTree([decision.path]);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Router } from '@angular/router';
|
||||||
|
|
||||||
|
export interface AuthGuardInput {
|
||||||
|
initialized: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decideAuthRedirect(
|
||||||
|
input: AuthGuardInput,
|
||||||
|
router: Pick<Router, 'createUrlTree'>,
|
||||||
|
): true | ReturnType<Router['createUrlTree']> {
|
||||||
|
if (!input.initialized) {
|
||||||
|
return router.createUrlTree(['/bootstrap']);
|
||||||
|
}
|
||||||
|
if (!input.isAuthenticated) {
|
||||||
|
return router.createUrlTree(['/login']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -2,17 +2,24 @@ import { inject } from '@angular/core';
|
|||||||
import { CanActivateFn, Router } from '@angular/router';
|
import { CanActivateFn, Router } from '@angular/router';
|
||||||
import { AuthService } from '../services/auth.service';
|
import { AuthService } from '../services/auth.service';
|
||||||
import { BootstrapService } from '../services/bootstrap.service';
|
import { BootstrapService } from '../services/bootstrap.service';
|
||||||
|
import { decideAuthRedirect } from './auth.guard.decision';
|
||||||
|
|
||||||
export const authGuard: CanActivateFn = () => {
|
export { decideAuthRedirect } from './auth.guard.decision';
|
||||||
|
export type { AuthGuardInput } from './auth.guard.decision';
|
||||||
|
|
||||||
|
export const authGuard: CanActivateFn = async () => {
|
||||||
const auth = inject(AuthService);
|
const auth = inject(AuthService);
|
||||||
const bootstrap = inject(BootstrapService);
|
const bootstrap = inject(BootstrapService);
|
||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
|
|
||||||
if (!bootstrap.initialized()) {
|
await bootstrap.ready();
|
||||||
return router.createUrlTree(['/bootstrap']);
|
await auth.waitUntilHydrated();
|
||||||
}
|
|
||||||
if (!auth.isAuthenticated()) {
|
return decideAuthRedirect(
|
||||||
return router.createUrlTree(['/login']);
|
{
|
||||||
}
|
initialized: bootstrap.initialized(),
|
||||||
return true;
|
isAuthenticated: auth.isAuthenticated(),
|
||||||
|
},
|
||||||
|
router,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Injectable, signal, computed } from '@angular/core';
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
import {
|
||||||
|
readStoredSession,
|
||||||
|
writeStoredSession,
|
||||||
|
clearStoredSession,
|
||||||
|
StoredSession,
|
||||||
|
} from './auth.session-storage';
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6,10 +14,24 @@ export interface CurrentUser {
|
|||||||
role: 'admin' | 'player';
|
role: 'admin' | 'player';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RefreshResponse {
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: CurrentUser;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
private http: HttpClient;
|
||||||
|
|
||||||
|
constructor(http?: HttpClient) {
|
||||||
|
this.http = http ?? inject(HttpClient);
|
||||||
|
}
|
||||||
|
|
||||||
private accessToken = signal<string | null>(null);
|
private accessToken = signal<string | null>(null);
|
||||||
private user = signal<CurrentUser | null>(null);
|
private user = signal<CurrentUser | null>(null);
|
||||||
|
readonly hydrated = signal<boolean>(false);
|
||||||
|
private hydrateWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
readonly currentUser = computed(() => this.user());
|
readonly currentUser = computed(() => this.user());
|
||||||
readonly isAuthenticated = computed(() => !!this.user());
|
readonly isAuthenticated = computed(() => !!this.user());
|
||||||
@@ -17,14 +39,69 @@ export class AuthService {
|
|||||||
setSession(token: string, user: CurrentUser): void {
|
setSession(token: string, user: CurrentUser): void {
|
||||||
this.accessToken.set(token);
|
this.accessToken.set(token);
|
||||||
this.user.set(user);
|
this.user.set(user);
|
||||||
|
writeStoredSession(this.sessionStorage(), { token, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.accessToken.set(null);
|
this.accessToken.set(null);
|
||||||
this.user.set(null);
|
this.user.set(null);
|
||||||
|
clearStoredSession(this.sessionStorage());
|
||||||
}
|
}
|
||||||
|
|
||||||
getAccessToken(): string | null {
|
getAccessToken(): string | null {
|
||||||
return this.accessToken();
|
return this.accessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sessionStorage(): Storage {
|
||||||
|
return typeof window !== 'undefined' ? window.sessionStorage : (undefined as unknown as Storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreFromStorage(): void {
|
||||||
|
const stored = readStoredSession(this.sessionStorage());
|
||||||
|
if (stored) {
|
||||||
|
this.accessToken.set(stored.token);
|
||||||
|
this.user.set(stored.user as CurrentUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markHydrated(): void {
|
||||||
|
if (this.hydrated()) return;
|
||||||
|
this.hydrated.set(true);
|
||||||
|
const waiters = this.hydrateWaiters;
|
||||||
|
this.hydrateWaiters = [];
|
||||||
|
for (const resolve of waiters) resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
waitUntilHydrated(): Promise<void> {
|
||||||
|
if (this.hydrated()) return Promise.resolve();
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.hydrateWaiters.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreSession(): Promise<boolean> {
|
||||||
|
this.restoreFromStorage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await firstValueFrom(
|
||||||
|
this.http.post<RefreshResponse>(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
{},
|
||||||
|
{ withCredentials: true },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (res && res.accessToken && res.user) {
|
||||||
|
this.setSession(res.accessToken, res.user);
|
||||||
|
this.markHydrated();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this.clear();
|
||||||
|
this.markHydrated();
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
this.clear();
|
||||||
|
this.markHydrated();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface StoredSession {
|
||||||
|
token: string;
|
||||||
|
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshResponse {
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: { id: string; username: string; role: 'admin' | 'player' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStorageLike {
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'hipctf.auth.v1';
|
||||||
|
|
||||||
|
export function readStoredSession(storage: SessionStorageLike): StoredSession | null {
|
||||||
|
const raw = storage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as StoredSession;
|
||||||
|
if (parsed && typeof parsed.token === 'string' && parsed.user) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredSession(
|
||||||
|
storage: SessionStorageLike,
|
||||||
|
session: StoredSession,
|
||||||
|
): void {
|
||||||
|
storage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredSession(storage: SessionStorageLike): void {
|
||||||
|
storage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
@@ -31,7 +31,16 @@ export class BootstrapService {
|
|||||||
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
|
() => this.payload()?.passwordPolicy ?? DEFAULT_POLICY,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private loadPromise: Promise<BootstrapPayload | null> | null = null;
|
||||||
|
|
||||||
async load(): Promise<BootstrapPayload | null> {
|
async load(): Promise<BootstrapPayload | null> {
|
||||||
|
if (!this.loadPromise) {
|
||||||
|
this.loadPromise = this._load();
|
||||||
|
}
|
||||||
|
return this.loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _load(): Promise<BootstrapPayload | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
|
const res = await fetch('/api/v1/bootstrap', { credentials: 'include' });
|
||||||
const data = (await res.json()) as BootstrapPayload;
|
const data = (await res.json()) as BootstrapPayload;
|
||||||
@@ -45,6 +54,13 @@ export class BootstrapService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ready(): Promise<BootstrapPayload | null> {
|
||||||
|
if (!this.loadPromise) {
|
||||||
|
this.loadPromise = this._load();
|
||||||
|
}
|
||||||
|
return this.loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
markInitialized(): void {
|
markInitialized(): void {
|
||||||
this.initialized.set(true);
|
this.initialized.set(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
HostListener,
|
HostListener,
|
||||||
computed,
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -32,6 +33,16 @@ export class SetupCreateAdminComponent {
|
|||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly bootstrap = inject(BootstrapService);
|
private readonly bootstrap = inject(BootstrapService);
|
||||||
|
|
||||||
|
private readonly alreadyInitialized = computed(() => this.bootstrap.initialized());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (this.alreadyInitialized()) {
|
||||||
|
void this.router.navigateByUrl(this.auth.isAuthenticated() ? '/' : '/login');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
|
readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description);
|
||||||
|
|
||||||
readonly form = this.fb.nonNullable.group(
|
readonly form = this.fb.nonNullable.group(
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
STORAGE_KEY,
|
||||||
|
readStoredSession,
|
||||||
|
writeStoredSession,
|
||||||
|
clearStoredSession,
|
||||||
|
StoredSession,
|
||||||
|
} from '../../frontend/src/app/core/services/auth.session-storage';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
function makeStorage(initial: Record<string, string> = {}): {
|
||||||
|
store: Record<string, string>;
|
||||||
|
getItem: jest.Mock;
|
||||||
|
setItem: jest.Mock;
|
||||||
|
removeItem: jest.Mock;
|
||||||
|
} {
|
||||||
|
const store = { ...initial };
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
getItem: jest.fn((k: string) => (k in store ? store[k] : null)),
|
||||||
|
setItem: jest.fn((k: string, v: string) => {
|
||||||
|
store[k] = v;
|
||||||
|
}),
|
||||||
|
removeItem: jest.fn((k: string) => {
|
||||||
|
delete store[k];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminSession: StoredSession = {
|
||||||
|
token: 'tok-1',
|
||||||
|
user: { id: 'u1', username: 'alice', role: 'admin' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('auth.session-storage helpers', () => {
|
||||||
|
it('readStoredSession returns null when empty', () => {
|
||||||
|
const s = makeStorage();
|
||||||
|
expect(readStoredSession(s as any)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeStoredSession + readStoredSession round-trip', () => {
|
||||||
|
const s = makeStorage();
|
||||||
|
writeStoredSession(s as any, adminSession);
|
||||||
|
expect(s.setItem).toHaveBeenCalledWith(STORAGE_KEY, JSON.stringify(adminSession));
|
||||||
|
expect(readStoredSession(s as any)).toEqual(adminSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearStoredSession removes the key', () => {
|
||||||
|
const s = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
|
||||||
|
clearStoredSession(s as any);
|
||||||
|
expect(s.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
|
||||||
|
expect(readStoredSession(s as any)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readStoredSession returns null for malformed JSON', () => {
|
||||||
|
const s = makeStorage({ [STORAGE_KEY]: 'not-json' });
|
||||||
|
expect(readStoredSession(s as any)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readStoredSession returns null for shape mismatch', () => {
|
||||||
|
const s = makeStorage({ [STORAGE_KEY]: JSON.stringify({ foo: 'bar' }) });
|
||||||
|
expect(readStoredSession(s as any)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restoreSession refresh flow (pure)', () => {
|
||||||
|
it('successful refresh updates state and persists new token', async () => {
|
||||||
|
const stored = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
|
||||||
|
const restored = readStoredSession(stored as any);
|
||||||
|
expect(restored).toEqual(adminSession);
|
||||||
|
|
||||||
|
const httpPost = jest.fn();
|
||||||
|
|
||||||
|
const response = await new Promise<{ accessToken: string; user: any }>((resolve, reject) => {
|
||||||
|
const obs = of({ accessToken: 'fresh-tok', expiresIn: 900, user: adminSession.user });
|
||||||
|
httpPost.mockReturnValueOnce(obs);
|
||||||
|
(httpPost as any)('/api/v1/auth/refresh', {}, { withCredentials: true }).subscribe({
|
||||||
|
next: (r: unknown) => resolve(r as any),
|
||||||
|
error: (e: unknown) => reject(e),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(httpPost).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/auth/refresh',
|
||||||
|
{},
|
||||||
|
{ withCredentials: true },
|
||||||
|
);
|
||||||
|
expect(response.accessToken).toBe('fresh-tok');
|
||||||
|
|
||||||
|
writeStoredSession(stored as any, {
|
||||||
|
token: response.accessToken,
|
||||||
|
user: response.user,
|
||||||
|
});
|
||||||
|
expect(stored.store[STORAGE_KEY]).toContain('fresh-tok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('failed refresh triggers clearStoredSession', async () => {
|
||||||
|
const stored = makeStorage({ [STORAGE_KEY]: JSON.stringify(adminSession) });
|
||||||
|
const httpPost = jest.fn();
|
||||||
|
|
||||||
|
let errored = false;
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
httpPost.mockReturnValueOnce(throwError(() => ({ status: 401 })));
|
||||||
|
(httpPost as any)('/api/v1/auth/refresh', {}, { withCredentials: true }).subscribe({
|
||||||
|
next: () => resolve(undefined),
|
||||||
|
error: (e: unknown) => reject(e),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
errored = true;
|
||||||
|
}
|
||||||
|
expect(errored).toBe(true);
|
||||||
|
|
||||||
|
clearStoredSession(stored as any);
|
||||||
|
expect(stored.removeItem).toHaveBeenCalledWith(STORAGE_KEY);
|
||||||
|
expect(readStoredSession(stored as any)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { decideAuthRedirect } from '../../frontend/src/app/core/guards/auth.guard.decision';
|
||||||
|
|
||||||
|
function makeRouter() {
|
||||||
|
return {
|
||||||
|
createUrlTree: jest.fn((cmds: string[]) => ({ __urlTree: true, cmds })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decideAuthRedirect (race-fix core)', () => {
|
||||||
|
it('redirects to /bootstrap while bootstrap is uninitialized', () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const result = decideAuthRedirect(
|
||||||
|
{ initialized: false, isAuthenticated: false },
|
||||||
|
router as any,
|
||||||
|
);
|
||||||
|
expect(router.createUrlTree).toHaveBeenCalledWith(['/bootstrap']);
|
||||||
|
expect((result as any).__urlTree).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to /login when initialized but not authenticated', () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const result = decideAuthRedirect(
|
||||||
|
{ initialized: true, isAuthenticated: false },
|
||||||
|
router as any,
|
||||||
|
);
|
||||||
|
expect(router.createUrlTree).toHaveBeenCalledWith(['/login']);
|
||||||
|
expect((result as any).__urlTree).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows navigation when initialized AND authenticated (no /bootstrap redirect)', () => {
|
||||||
|
const router = makeRouter();
|
||||||
|
const result = decideAuthRedirect(
|
||||||
|
{ initialized: true, isAuthenticated: true },
|
||||||
|
router as any,
|
||||||
|
);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(router.createUrlTree).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user