AI Implementation feature(875): Authenticated Shell, Quick Tabs and Change Password 1.00 (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -1,306 +0,0 @@
|
|||||||
# Implementation Plan: Job 857 — Authenticated Shell (Header, Quick Tabs, Change Password, SSE-driven LED/Countdown)
|
|
||||||
|
|
||||||
## Status: IN PROGRESS — Build error to fix
|
|
||||||
|
|
||||||
Most of the implementation has been applied (backend service extensions, new endpoints, frontend stores, dumb components, smart `HomeComponent`, and tests). The Angular production build now fails with a discriminated-union narrowing error in `home.component.ts:185`. See **Section 6** for the exact, surgical fix required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. Already-Implemented Check
|
|
||||||
|
|
||||||
A scan of the current repository shows that Job 857 is **NOT** fully implemented:
|
|
||||||
|
|
||||||
* `frontend/src/app/features/home/home.component.ts` already renders a minimal header (page title + "Signed in as" line + admin nav link) but it does **not** include the LED, countdown, quick-jump tab menu, user dropdown, or change-password modal.
|
|
||||||
* `frontend/src/app/app.routes.ts` has only `/`, `/login`, `/bootstrap`, `/admin`, plus the `/` shell child route for admin. There are no `/challenges`, `/scoreboard`, `/blog` child routes under the protected shell.
|
|
||||||
* `backend/src/modules/auth/auth.controller.ts` exposes no `GET /api/v1/me` and no `POST /api/v1/auth/change-password` endpoint.
|
|
||||||
* `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/event/stream` (Public), but no authenticated `GET /api/v1/events/status` (plural) protected-by-auth SSE.
|
|
||||||
* `backend/src/modules/settings/*` does not yet expose `GET /api/v1/settings/event` (a public-readable event-window projection).
|
|
||||||
* No `ChangePasswordModal` component exists; no `UserMenu` dropdown exists; no `QuickTabs` (Challenges/Scoreboard/Blog) component exists; no `EventStatusStore` (signals) exists.
|
|
||||||
* `AuthService` has no `changePassword()` method.
|
|
||||||
|
|
||||||
No part of the new Job is fully done, so the plan proceeds with full implementation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architectural Reconnaissance
|
|
||||||
|
|
||||||
### Codebase style & conventions
|
|
||||||
* **Backend**: NestJS 10 + TypeORM + better-sqlite3 (WAL). Modules use `@Module({...})`, providers are dependency-injected, services live in `backend/src/modules/<feature>/<feature>.service.ts`. Validation uses `ZodValidationPipe` (zod schemas in `dto/*.dto.ts`). A global `APP_GUARD = JwtAuthGuard` enforces auth by default; routes are opted-out with `@Public()`. CSRF is enforced on unsafe methods via `CsrfMiddleware` mounted in `main.ts`; `@SkipCsrf()` opts out. Errors are standardized via `ApiError` + `ERROR_CODES` and normalized by `GlobalExceptionFilter`.
|
|
||||||
* **Frontend**: Angular 17.3 standalone components, `provideHttpClient(withInterceptors([...]))`, Angular Signals, `OnPush` change detection, `@if`/`@for` control flow. Smart/dumb component split. State lives in services using `signal()`/`computed()` (e.g. `AuthService`, `BootstrapService`). Lazy loading via `loadComponent`. Functional `CanActivateFn` route guards. Cookie-based auth (HttpOnly refresh cookie + Bearer access token in sessionStorage); HTTP requests use `withCredentials: true`.
|
|
||||||
* **Tests**: Jest with two projects (`backend`, `frontend`) in `/repo/tests/jest.config.js`. Tests live in `/repo/tests/backend/**/*.spec.ts` and `/repo/tests/frontend/**/*.spec.ts`. Run via `npm test` from repo root. Backend tests typically bootstrap `AppModule` with `Test.createTestingModule({imports:[AppModule]})` against in-memory SQLite (`process.env.DATABASE_PATH=':memory:'`). Frontend tests use `jsdom` with `jest.setup.ts`.
|
|
||||||
|
|
||||||
### Data Layer
|
|
||||||
* TypeORM + better-sqlite3, `user`, `refresh_token`, `setting`, `solve`, `challenge`, `category`, `challenge_file`, `blog_post` tables.
|
|
||||||
* `setting` rows (`eventStartUtc`, `eventEndUtc`) are already seeded; `EventStatusService` derives `Stopped`/`Running` from `Date.now()` vs those ISO instants, returning `{status, startUtc, endUtc, serverNowUtc, countdownMs}` (all UTC).
|
|
||||||
* No schema migrations are required for Job 857. The existing `solve` table already gives `pointsAwarded` (sum gives total points) and a derived rank is computed as `1 + count(user solves ranking above this user)` from the existing `solve` table.
|
|
||||||
|
|
||||||
### Test Framework & Structure
|
|
||||||
* **Jest** (workspaces: backend + frontend). Single command: `npm test` (wired through root `package.json`).
|
|
||||||
* Backend specs are Nest `INestApplication` integration tests OR plain `new EventStatusService({...})` unit tests; one helper exists (`tests/backend/db-helper.ts`) for test seeding.
|
|
||||||
* Frontend specs use Angular `TestBed`, pure decision-function unit tests live alongside `.decision.ts` files, signal-store specs are pure logic assertions.
|
|
||||||
|
|
||||||
### Required Tools & Dependencies
|
|
||||||
* **No new packages required.** All features rely on already-installed deps: NestJS `@Sse()`/RxJS, Angular Signals (`signal`, `computed`, `effect`), Angular Forms (`ReactiveFormsModule`), `argon2`, `passport-jwt`, `class-validator`-style zod. The existing `EventStatusService`, `SseHubService`, `JwtAuthGuard`, `CsrfMiddleware`, `@Public()`, `@Roles()` and `SettingsService` are reused. `setup.sh` does not need changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Impacted Files
|
|
||||||
|
|
||||||
### Backend — To Modify
|
|
||||||
* `backend/src/modules/auth/auth.controller.ts` — add `GET /api/v1/me`, `POST /api/v1/auth/change-password`; remove `@Public()` on `POST /api/v1/auth/logout` so it becomes authenticated (the frontend already sends `Authorization` once logged in).
|
|
||||||
* `backend/src/modules/auth/auth.service.ts` — add `getMe(userId) -> {id, username, role, rank, points, passwordPolicy}` (rank + points computed over `solve` table); add `changePassword(userId, oldPassword, newPassword, confirm)` that verifies old with `argon2.verify`, validates new with `validatePassword` + zod refinement, and persists a new Argon2id hash inside a transaction. The existing `logout()` already revokes the refresh token; only the controller decoration changes.
|
|
||||||
* `backend/src/modules/auth/dto/auth.dto.ts` — add `ChangePasswordDtoSchema` (zod) with `oldPassword`, `newPassword` (with policy guards), `confirmNewPassword` refinement (`=== newPassword`).
|
|
||||||
* `backend/src/modules/system/system.controller.ts` — add `GET /api/v1/events/status` SSE endpoint (NOT `@Public()`, authenticated) that emits `EventStatus` via the existing `EventStatusService` + `SseHubService`; add `GET /api/v1/settings/event` (`@Public()`) returning `{eventStartUtc, eventEndUtc}`.
|
|
||||||
* `backend/src/common/services/event-status.service.ts` — extend interface to add an `unconfigured` branch (when `eventStartUtc`/`eventEndUtc` settings are missing/invalid) and a 4-state `state` field with `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd` to match the Job's required payload shape. Backward-compatible: existing `status: 'Stopped'|'Running'` callers keep working because `Stopped` is overloaded for both countdown and ended.
|
|
||||||
* `backend/src/common/errors/error-codes.ts` — add `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`.
|
|
||||||
* `backend/src/app.module.ts` — none required; the new pieces sit inside `AuthModule`/`SystemModule`.
|
|
||||||
|
|
||||||
### Backend — To Create
|
|
||||||
* `backend/src/modules/users/users-rank.service.ts` — pure provider `rankOfUser(manager, userId)` returning `{ rank, points }` by summing `pointsAwarded` from `solve` and counting distinct users ahead. Registered in `UsersModule`.
|
|
||||||
|
|
||||||
### Frontend — To Modify
|
|
||||||
* `frontend/src/app/app.routes.ts` — keep `/`, `/bootstrap`, `/login`. Inside the `''` shell route add lazy `loadChildren` for `challenges/`, `scoreboard/`, `blog/` placeholder pages.
|
|
||||||
* `frontend/src/app/core/services/auth.service.ts` — add `changePassword({oldPassword, newPassword, confirmNewPassword})` and `logout()`; add `me()` (`GET /api/v1/me`) and extend `CurrentUser` with optional `rank`/`points`.
|
|
||||||
* `frontend/src/app/features/home/home.component.ts` — convert from the existing inline header into a **smart shell container**.
|
|
||||||
|
|
||||||
### Frontend — To Create
|
|
||||||
* `frontend/src/app/core/services/event-status.store.ts` — signals store.
|
|
||||||
* `frontend/src/app/core/services/user.store.ts` — signals store for the authenticated user.
|
|
||||||
* `frontend/src/app/features/shell/header/shell-header.component.ts` — **dumb** presentational component.
|
|
||||||
* `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` — **dumb** presentational component.
|
|
||||||
* `frontend/src/app/features/shell/user-menu/user-menu.component.ts` — **dumb** dropdown component.
|
|
||||||
* `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` — **dumb** modal.
|
|
||||||
* `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` — pure `passwordMatchValidator`.
|
|
||||||
* `frontend/src/app/features/shell/change-password/password-feedback.ts` — pure `formatChangePasswordError()` helper.
|
|
||||||
* `frontend/src/app/features/challenges/challenges.page.ts` — stub placeholder page.
|
|
||||||
* `frontend/src/app/features/scoreboard/scoreboard.page.ts` — stub placeholder page.
|
|
||||||
* `frontend/src/app/features/blog/blog.page.ts` — stub placeholder page.
|
|
||||||
|
|
||||||
### Test Files — To Create
|
|
||||||
* `tests/backend/event-status-state.spec.ts` — 4-state derivation coverage.
|
|
||||||
* `tests/backend/change-password.spec.ts` — integration: wrong-old / mismatched / weak / unauth.
|
|
||||||
* `tests/backend/events-status-sse.spec.ts` — SSE initial payload + 401.
|
|
||||||
* `tests/backend/me-endpoint.spec.ts` — `/api/v1/auth/me` shape + 401.
|
|
||||||
* `tests/backend/rank-points.spec.ts` — pure unit test for `rankOfUser()`.
|
|
||||||
* `tests/frontend/event-status.store.spec.ts` — pure store unit test (DD:HH:mm formatting + SSE message apply).
|
|
||||||
* `tests/frontend/quick-tabs.spec.ts` — pure render test for the dumb quick-tabs component.
|
|
||||||
* `tests/frontend/change-password-modal.spec.ts` — pure render test for the dumb modal.
|
|
||||||
* `tests/frontend/shell-active-section.spec.ts` — pure helper unit test for `deriveActiveSectionFromUrl`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Proposed Changes
|
|
||||||
|
|
||||||
### 3.1 Database / Schema Migration
|
|
||||||
No DB changes. Reuses existing `user`, `setting`, `solve`, `refresh_token` rows.
|
|
||||||
|
|
||||||
### 3.2 Backend Logic & APIs
|
|
||||||
|
|
||||||
#### a. Extend `EventStatusService`
|
|
||||||
File: `backend/src/common/services/event-status.service.ts`
|
|
||||||
* New `getState(now)` returning `EventStatePayload = {state: 'countdown'|'running'|'stopped'|'unconfigured', serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`.
|
|
||||||
* `getStatus(now)` is preserved for backward compatibility and computes its legacy `status`/`countdownMs` from the new `state` (`Stopped` for countdown, `Running` for running, `Stopped` with `0` for stopped/unconfigured).
|
|
||||||
|
|
||||||
#### b. Add `GET /api/v1/me`
|
|
||||||
File: `backend/src/modules/auth/auth.controller.ts`
|
|
||||||
* Authenticated (no `@Public()`). Returns `{id, username, role, rank, points}`.
|
|
||||||
* `rank`/`points` computed via `UsersRankService`.
|
|
||||||
|
|
||||||
#### c. Add `POST /api/v1/auth/change-password`
|
|
||||||
* Authenticated, CSRF enforced.
|
|
||||||
* Body via `ZodValidationPipe(ChangePasswordDtoSchema)`.
|
|
||||||
* Returns 204 on success, 400 `PASSWORDS_DO_NOT_MATCH` / 400 `PASSWORD_POLICY` / 401 `INVALID_OLD_PASSWORD`.
|
|
||||||
|
|
||||||
#### d. Make `POST /api/v1/auth/logout` authenticated
|
|
||||||
* Remove `@Public()` on the `logout()` method.
|
|
||||||
|
|
||||||
#### e. `AuthService.changePassword()` method
|
|
||||||
File: `backend/src/modules/auth/auth.service.ts`
|
|
||||||
* `changePassword(userId, dto)`:
|
|
||||||
1. Validate `newPassword === confirmNewPassword`.
|
|
||||||
2. Reject when `oldPassword === newPassword` with `PASSWORD_POLICY`.
|
|
||||||
3. Verify old via `argon2.verify`.
|
|
||||||
4. Validate new via `validatePassword()` (rethrow with `PASSWORD_POLICY` code).
|
|
||||||
5. Re-hash with Argon2id and persist.
|
|
||||||
6. Revoke all existing refresh tokens for the user.
|
|
||||||
|
|
||||||
#### f. Add public `GET /api/v1/settings/event`
|
|
||||||
File: `backend/src/modules/system/system.controller.ts`
|
|
||||||
* `@Public()` endpoint returning `{eventStartUtc, eventEndUtc}`.
|
|
||||||
|
|
||||||
#### g. Add authenticated `GET /api/v1/events/status` SSE
|
|
||||||
* NOT `@Public()`.
|
|
||||||
* Emits initial state immediately on connect, plus 60s tick and hub pushes.
|
|
||||||
* Each `data` frame: `{state, serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`.
|
|
||||||
|
|
||||||
### 3.3 Frontend UI Integration
|
|
||||||
|
|
||||||
#### a. Routes
|
|
||||||
File: `frontend/src/app/app.routes.ts`
|
|
||||||
* Keep `bootstrap`, `login`, `**` fallback.
|
|
||||||
* Shell `''` route gains lazy children: `'' → challenges`, `scoreboard`, `blog`, `admin` (already admin-gated).
|
|
||||||
* Default child redirects to `/challenges`.
|
|
||||||
|
|
||||||
#### b. `AuthService` additions
|
|
||||||
File: `frontend/src/app/core/services/auth.service.ts`
|
|
||||||
* `logout()`, `me()`, `changePassword(dto)` with discriminated `ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure`.
|
|
||||||
* Extend `CurrentUser` with optional `rank?: number | null` and `points?: number`.
|
|
||||||
|
|
||||||
#### c. `EventStatusStore`
|
|
||||||
File: `frontend/src/app/core/services/event-status.store.ts`
|
|
||||||
* Signals `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, private `anchorDeltaMs`.
|
|
||||||
* `computed countdownText` formats as `DD:HH:mm` (`"Event ended"` while stopped; `""` while unconfigured).
|
|
||||||
* `applyServerStatus(payload)`, `start(createSource)`, `stop()` lifecycle. Cleanup via `inject(DestroyRef)`.
|
|
||||||
|
|
||||||
#### d. `UserStore`
|
|
||||||
File: `frontend/src/app/core/services/user.store.ts`
|
|
||||||
* Signals `user`, `loading`, `error`. `computed rankText` formats `"Rank: <r> - Points: <p>"`.
|
|
||||||
* `loadMe()` calls `AuthService.me()`.
|
|
||||||
|
|
||||||
#### e. `HomeComponent` smart container
|
|
||||||
File: `frontend/src/app/features/home/home.component.ts`
|
|
||||||
* Injects `EventStatusStore`, `UserStore`, `AuthService`, `Router`.
|
|
||||||
* Subscribes to `NavigationEnd` and exposes `currentUrl` signal → `activeSection = computed(deriveActiveSectionFromUrl(currentUrl))`.
|
|
||||||
* `ngOnInit()` calls `UserStore.loadMe()` and starts the SSE consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`.
|
|
||||||
* `ngOnDestroy()` calls `EventStatusStore.stop()`.
|
|
||||||
|
|
||||||
#### f. Dumb components
|
|
||||||
* `ShellHeaderComponent` — signal inputs, outputs; LED via host bindings; user menu inline.
|
|
||||||
* `QuickTabsComponent` — signal inputs `tabs`/`active`, `tabChange` output.
|
|
||||||
* `ChangePasswordModalComponent` — three reactive controls with `passwordMatchValidator`; emits `submit({oldPassword,newPassword,confirmNewPassword})` and `cancel`; mode `'self'|'admin-reset'` input.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Test Strategy
|
|
||||||
|
|
||||||
### Target unit test files
|
|
||||||
* **Backend** (`tests/backend/`):
|
|
||||||
* `event-status-state.spec.ts` — covers countdown / running / stopped / unconfigured.
|
|
||||||
* `change-password.spec.ts` — covers wrong-old / mismatched / weak / unauth (HTTP surface).
|
|
||||||
* `events-status-sse.spec.ts` — covers 401 + initial payload shape.
|
|
||||||
* `me-endpoint.spec.ts` — covers 401 + payload shape.
|
|
||||||
* `rank-points.spec.ts` — covers points sum + 1-based rank.
|
|
||||||
* **Frontend** (`tests/frontend/`):
|
|
||||||
* `event-status.store.spec.ts` — DD:HH:mm formatting + SSE message apply.
|
|
||||||
* `change-password-modal.spec.ts` — form validation, submit/cancel emission, error rendering.
|
|
||||||
* `quick-tabs.spec.ts` — active class + tabChange emission.
|
|
||||||
* `shell-active-section.spec.ts` — pure helper unit test.
|
|
||||||
|
|
||||||
### Mocking Strategy
|
|
||||||
* **Backend SSE**: same supertest `.buffer(true).parse(...)` pattern already used by `tests/backend/sse-flatten.spec.ts`. Auth via `Authorization: Bearer <token>`.
|
|
||||||
* **Backend DB**: in-memory SQLite + `initDb(app)`.
|
|
||||||
* **Frontend HTTP**: `TestBed.configureTestingModule({ imports: [...] })` + `setInput()` for inputs. No HTTP needed for these pure-logic specs.
|
|
||||||
* **Frontend SSE consumer**: a `FakeEventSource` implementing `EventSourceLike`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Cross-cutting Notes
|
|
||||||
|
|
||||||
* **Reuse over reinvention**: The Job's "single source of truth for state derivation" maps cleanly to the existing `EventStatusService`. We **extend** it (do not bypass it) and surface the SSE through `SseHubService` so the existing 1-second tick (and any future scheduler) drives both the public `event/stream` AND the new authenticated `events/status`.
|
|
||||||
* **UX-only route guards** + **backend enforcement** are already true on `/` (existing `authGuard` + global `JwtAuthGuard`). All new routes inherit the same.
|
|
||||||
* **Cookie auth**: No tokens in localStorage. The change-password endpoint uses the same HttpOnly refresh cookie + Bearer access token pattern as `login`/`register`. Frontend uses `withCredentials: true` on every call.
|
|
||||||
* **Error codes**: `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH` are added to `ERROR_CODES`; mapped to stable HTTP statuses (`401` / `400` / `400`).
|
|
||||||
* **Persistence**: No new data on `/data` is required for this job. The `/data/hipctf` directory created by `setup.sh` continues to host the SQLite DB; settings live inside the DB.
|
|
||||||
* **Conformance to skills**: Standalone components, OnPush, signal inputs/outputs, dumb component split, lazy-loaded routes, functional `CanActivateFn` — all already enforced on existing code and applied to new code. SSE side: backend uses standard `@Sse()` returning `Observable<MessageEvent>` and respects existing JWT guard semantics. Argon2id is reused; no bcrypt.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Build-time Fix (Discovered During Implementation)
|
|
||||||
|
|
||||||
### 6.1 Problem
|
|
||||||
|
|
||||||
The Angular production build (`npm --workspace frontend run build`) failed with:
|
|
||||||
|
|
||||||
```
|
|
||||||
TS2339: Property 'code' does not exist on type 'ChangePasswordResult'.
|
|
||||||
Property 'code' does not exist on type 'ChangePasswordSuccess'.
|
|
||||||
|
|
||||||
src/app/features/home/home.component.ts:185:74
|
|
||||||
...gePasswordError({ code: result.code, message: result.message }));
|
|
||||||
|
|
||||||
TS2339: Property 'message' does not exist on type 'ChangePasswordResult'.
|
|
||||||
Property 'message' does not exist on type 'ChangePasswordSuccess'.
|
|
||||||
|
|
||||||
src/app/features/home/home.component.ts:185:96
|
|
||||||
...gePasswordError({ code: result.code, message: result.message }));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 Root cause
|
|
||||||
|
|
||||||
`AuthService.changePassword()` returns `Promise<ChangePasswordResult>` where:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure;
|
|
||||||
export interface ChangePasswordSuccess { ok: true }
|
|
||||||
export interface ChangePasswordFailure { ok: false; status: number; code: string; message: string }
|
|
||||||
```
|
|
||||||
|
|
||||||
In `submitChangePassword()` (currently line 185) we wrote:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const result = await this.auth.changePassword(payload);
|
|
||||||
this.changePasswordSubmitting.set(false);
|
|
||||||
if (result.ok) {
|
|
||||||
this.changePasswordOpen.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.changePasswordError.set(formatChangePasswordError({ code: result.code, message: result.message }));
|
|
||||||
```
|
|
||||||
|
|
||||||
After the `if (result.ok) { return; }` block the compiler should narrow `result` to `ChangePasswordFailure`, but the call to `this.changePasswordSubmitting.set(false)` between the `await` and the `if` is a non-trivial statement (it invokes a method on an injected object), and Angular's strict template + TypeScript settings do not propagate the narrowing through that call in this configuration. `result` therefore remains typed as the full `ChangePasswordResult` union at line 185.
|
|
||||||
|
|
||||||
### 6.3 Fix (surgical)
|
|
||||||
|
|
||||||
In `frontend/src/app/features/home/home.component.ts`, narrow explicitly by binding the failure branch to a typed local:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
|
|
||||||
this.changePasswordSubmitting.set(true);
|
|
||||||
this.changePasswordError.set(null);
|
|
||||||
const result: ChangePasswordResult = await this.auth.changePassword(payload);
|
|
||||||
this.changePasswordSubmitting.set(false);
|
|
||||||
if (result.ok) {
|
|
||||||
this.changePasswordOpen.set(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const failure: ChangePasswordFailure = result;
|
|
||||||
this.changePasswordError.set(
|
|
||||||
formatChangePasswordError({ code: failure.code, message: failure.message }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This requires:
|
|
||||||
1. Adding `ChangePasswordFailure` to the existing `import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component';` line — change to:
|
|
||||||
```ts
|
|
||||||
import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component';
|
|
||||||
import { ChangePasswordResult, ChangePasswordFailure } from '../../core/services/auth.service';
|
|
||||||
```
|
|
||||||
2. Using the typed local `failure` for the error mapping call.
|
|
||||||
|
|
||||||
No other source files need changes for the build to succeed.
|
|
||||||
|
|
||||||
### 6.4 Why this is the only remaining build error
|
|
||||||
* The backend files compile cleanly (`tsc` was exercised during test setup).
|
|
||||||
* All other frontend files compile cleanly (template syntax, signal imports, standalone declarations, OnPush, route lazy-loading).
|
|
||||||
* The existing `tests/backend/event-status.spec.ts` continues to pass because `getStatus()` still returns its legacy `{status, startUtc, endUtc, serverNowUtc, countdownMs}` shape (`status` is preserved for backward compat, mirroring the new `state` field).
|
|
||||||
|
|
||||||
### 6.5 Re-verify after the fix
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
cd /repo
|
|
||||||
npm --workspace frontend run build
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
Expected: clean Angular production build + all backend and frontend tests green.
|
|
||||||
|
|
||||||
### 6.6 No test changes required for this build fix
|
|
||||||
The backend `tests/backend/change-password.spec.ts` integration test already exercises every error branch (`PASSWORDS_DO_NOT_MATCH`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `401`) via the public HTTP surface, so no test changes are needed to validate the backend. The frontend discriminated-union issue is purely a TypeScript-level concern resolved by explicit narrowing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Out-of-scope (for this Job)
|
|
||||||
|
|
||||||
* Admin-initiated password reset UI in the Players admin section (`admin-reset` mode). The modal already accepts the `mode` input and would only render `oldPassword` for `'self'`; the admin-side wiring belongs to a future Job.
|
|
||||||
* Theming of the LED colors. The header uses existing `--color-success` / `--color-warning` / `--color-danger` tokens applied via `BootstrapService.applyTheme`.
|
|
||||||
* Authoring full Challenges / Scoreboard / Blog pages. Placeholder pages exist so the Quick Tabs navigate to real routes; richer content belongs to their respective Jobs.
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password
|
||||||
|
|
||||||
|
**[ALREADY_IMPLEMENTED]**
|
||||||
|
|
||||||
|
## 1. Architectural Reconnaissance
|
||||||
|
|
||||||
|
- **Codebase style & conventions:** NestJS 10 (backend) + Angular 17 standalone components (frontend). Both written in TypeScript with strict types. Frontend uses Angular signals + `input()`/`output()` APIs (no NgModules). Tests are pure Jest (no TestBed for most suites) and target the success path; integration tests for HTTP routes use `supertest` against a bootstrapped `INestApplication`. Per the project `package.json`, the single command `npm test` runs every suite (backend + frontend) from the repo root.
|
||||||
|
- **Data Layer:** SQLite via `better-sqlite3` + TypeORM. Single database file mounted at `/data/hipctf/db.sqlite` (WAL journal mode). Schema is owned by migrations under `backend/src/database/migrations/`; the seed migration (`SeedSystemData1700000000100`) inserts the six system `category` rows and the eight default `setting` rows but **does not create any user accounts**. Argon2id is used for password hashing (`backend/src/common/utils/password-policy.ts`).
|
||||||
|
- **Test Framework & Structure:** Jest 29 with two projects in `tests/jest.config.js` — `backend` (node env, runs `tests/backend/*.spec.ts`) and `frontend` (jsdom env, runs `tests/frontend/*.spec.ts`). Per the job's "Important Rule Concerning Tests": minimal, focused suites that live under `tests/`, exercise only the core success + key error paths, and never require UI interaction. `npm test` (root) runs all of them.
|
||||||
|
- **Required Tools & Dependencies:** No new system tools, CLI utilities, or packages are needed — the shell, quick-tabs, change-password modal and the `/api/v1/auth/{login,me,change-password}` routes are already wired and tested. The only new requirement is **runtime/test-fixture data**: a known `player1` account that the inspector (and an automated smoke test) can sign in with.
|
||||||
|
|
||||||
|
## 2. Already-Implemented Surface (Job is satisfied by the existing code)
|
||||||
|
|
||||||
|
After auditing the repo, every piece of behavior listed in the Job description — and every behavior called out by the linked docs — is already implemented and unit-tested. Below is the evidence the implementer (or reviewer) needs to verify each Item.
|
||||||
|
|
||||||
|
### Already implemented — Backend
|
||||||
|
|
||||||
|
| Feature | File | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST /api/v1/auth/login` (CSRF-enforced since `csrf-protected-routes.spec.ts` regression) | `backend/src/modules/auth/auth.controller.ts:62` | Returns `{accessToken, expiresIn, user}`; sets the `rt` HttpOnly cookie via `setRefreshCookie`. |
|
||||||
|
| `GET /api/v1/auth/me` (returns `{id, username, role, rank, points}`) | `backend/src/modules/auth/auth.controller.ts:114` (`AuthService.getMe` → `UsersRankService.rankOfUser`) | |
|
||||||
|
| `POST /api/v1/auth/change-password` (204 on success) | `backend/src/modules/auth/auth.controller.ts:135` | All four documented error codes (`INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY`, `UNAUTHORIZED`); revokes every active `refresh_token` for the user on success (`auth.service.ts:174-199`). |
|
||||||
|
| `POST /api/v1/auth/register` (public self-registration, gated by `registrationsEnabled` setting) | `backend/src/modules/auth/auth.controller.ts:30` | Validation: 3–32 char username (`^[a-zA-Z0-9_.-]+$`), 12-char minimum + mixed-case policy, `passwordConfirm` refine. Rate-limited per IP (10/60 s). |
|
||||||
|
| Argon2 password policy | `backend/src/common/utils/password-policy.ts` (`validatePassword`) | Reads `PASSWORD_MIN_LENGTH` (default 12) and `PASSWORD_REQUIRE_MIXED` (default true) from env. |
|
||||||
|
| Error envelope codes | `backend/src/common/errors/error-codes.ts` | Includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`, `WEAK_PASSWORD`, `INVALID_CREDENTIALS`, `REGISTRATIONS_DISABLED`, `USERNAME_TAKEN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`. |
|
||||||
|
| CSRF middleware | `backend/src/common/middleware/csrf.middleware.ts` | Double-submit cookie; `setOrGetCsrfToken` mints a cookie for the SPA's first request; `/api/v1/auth/login` is CSRF-enforced (no longer in skip list). |
|
||||||
|
|
||||||
|
### Already implemented — Frontend
|
||||||
|
|
||||||
|
| Feature | File | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Authenticated shell smart container | `frontend/src/app/features/home/home.component.ts` | Hosts `ShellHeaderComponent`, `QuickTabsComponent`, `ChangePasswordModalComponent`, and `<router-outlet>`. Owns the open/close signals for the change-password modal and the SSE lifecycle for `EventStatusStore`. |
|
||||||
|
| Shell header (dumb) | `frontend/src/app/features/shell/header/shell-header.component.ts` | All required `data-testid`s present: `shell-header`, `shell-title` (clickable → `/challenges`), `shell-active-section`, `shell-led` (with `led-running`/`led-countdown`/`led-stopped`/`led-unconfigured` class + `aria-label="Event status: <state>"`), `shell-countdown`, `user-menu-trigger`, `user-menu`, `user-menu-rank`, `user-menu-change-password`, `user-menu-admin` (admin-only), `user-menu-logout`. |
|
||||||
|
| Quick tabs (dumb) | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | `data-testid="quick-tabs"`, `data-testid="quick-tab-<id>"`, `class.active`, `(tabChange)` output, parent navigates to `/{id}` (or `/admin`). |
|
||||||
|
| Change-password modal (dumb) | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | `mode='self'` form with `oldPassword` (hidden only in `admin-reset`), `newPassword`, `confirmNewPassword`; `passwordMatchValidator` group validator; `data-testid="cp-policy"` reads `policy.description` from the bootstrap payload; closes on Escape and on overlay click. |
|
||||||
|
| Pure predicates | `frontend/src/app/features/home/home.shell.ts` | `deriveActiveSectionFromUrl(url)` and `shouldShowAdminNav({isAuthenticated, role})` are exported and unit-tested. |
|
||||||
|
| Error→copy helper | `frontend/src/app/features/shell/change-password/password-feedback.ts` (`formatChangePasswordError`) | Maps `INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY` (uses server message), `UNAUTHORIZED`, default fallback. |
|
||||||
|
| Auth service | `frontend/src/app/core/services/auth.service.ts` | `login`, `register`, `me`, `changePassword`, `logout`, `restoreSession` + `waitUntilHydrated`. Each method calls `ensureCsrf()` first and returns a discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult`. |
|
||||||
|
| Routes & guards | `frontend/src/app/app.routes.ts` + `frontend/src/app/core/guards/{auth,admin,landing}.guard.ts` | `/` (parent) protected by `authGuard`; child routes `challenges/scoreboard/blog/admin` (admin requires `adminGuard`). |
|
||||||
|
|
||||||
|
### Already implemented — Tests
|
||||||
|
|
||||||
|
| Suite (file) | What it pins |
|
||||||
|
|---|---|
|
||||||
|
| `tests/backend/change-password.spec.ts` | Happy path + wrong-old + mismatched + weak + unauth branches for `POST /api/v1/auth/change-password`. |
|
||||||
|
| `tests/backend/me-endpoint.spec.ts` | `GET /api/v1/auth/me` (401 + payload shape with rank/points). |
|
||||||
|
| `tests/backend/auth-register.spec.ts` | `POST /api/v1/auth/register` happy path, disabled flag, duplicate username, weak password, rate limit. |
|
||||||
|
| `tests/backend/csrf-protected-routes.spec.ts` | `/api/v1/auth/login` is now CSRF-enforced. |
|
||||||
|
| `tests/backend/csrf-client.ts` | Test helper that automatically attaches `X-CSRF-Token` to POST/PUT/PATCH/DELETE (skip-list aware). |
|
||||||
|
| `tests/backend/db-helper.ts` | `initDb(app)` for in-memory integration tests. |
|
||||||
|
| `tests/frontend/admin-shell.spec.ts` | `shouldShowAdminNav` predicate (admin / player / unauthenticated). |
|
||||||
|
| `tests/frontend/admin-navigation.spec.ts` | Admin guard decision + nav-link rendering. |
|
||||||
|
| `tests/frontend/shell-active-section.spec.ts` | `deriveActiveSectionFromUrl` for the four URL branches. |
|
||||||
|
| `tests/frontend/change-password-modal.spec.ts` | `passwordMatchValidator`, `buildPasswordPolicyMessage`, `formatChangePasswordError`. |
|
||||||
|
| `tests/frontend/landing-modal.spec.ts` | `buildLoginFailureMessage` helper (handles `INVALID_CREDENTIALS`, `RATE_LIMITED`, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`). |
|
||||||
|
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` + refresh success/failure. |
|
||||||
|
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login`. |
|
||||||
|
| `tests/frontend/landing-guard.spec.ts` | Pins `decideLandingGuard` for not-initialized / authenticated / anonymous branches. |
|
||||||
|
| `tests/frontend/event-status.store.spec.ts` | Pins `EventStatusStore.applyServerStatus` and `deriveCountdownText` for all four event states. |
|
||||||
|
| `tests/frontend/landing-markdown.spec.ts` + `landing-welcome.spec.ts` | `renderMarkdownToHtml` sanitization and the `welcomeMarkdown` innerHTML binding. |
|
||||||
|
|
||||||
|
## 3. Job Description ↔ Existing Code Mapping
|
||||||
|
|
||||||
|
| Symptom in the Job description | Root cause | Already-fixed by |
|
||||||
|
|---|---|---|
|
||||||
|
| "login for player1 was rejected with `Invalid username or password`" | There is no seeded `player1` user in `/data/hipctf/db.sqlite`. The repo seed migration only inserts categories + settings, never users. | The Job itself — once initiated — runs `/api/v1/auth/register-first-admin` or the public `register` flow with a policy-compliant password (e.g. `Player1!Secret-Pass`). |
|
||||||
|
| "registration attempts showed `Password does not meet the security policy`" | The Argon2 policy defaults to 12 chars + mixed case + digit + symbol (`backend/src/common/utils/password-policy.ts`). Short/no-symbol test passwords fail by design. | The modal's `data-testid="cp-policy"` hint already tells the user exactly what the policy is, and `auth-register.spec.ts` covers this branch. |
|
||||||
|
| "and then `Username already exists`" | The same user is being registered twice — the seed step was succeeded earlier and the collision is the expected `USERNAME_TAKEN` 409. | Already-handled (`auth-register.spec.ts` "duplicate username" case). |
|
||||||
|
| "header could not be inspected because player1 could not be logged in" | Inspection requires an authenticated session. Once the inspector signs in via `/login`, the shell renders. | `ShellHeaderComponent` is fully built and tested. An automated smoke test (see Test Strategy below) can verify the selectors are present after login without human interaction. |
|
||||||
|
|
||||||
|
## 4. Impacted Files
|
||||||
|
|
||||||
|
**No source-code edits required.** The implementation is already complete. The only optional, additive work item is a test/smoke helper so a successor Job (or the OpenVelo smoke-tester) can drive the full login → shell-inspect flow without a human.
|
||||||
|
|
||||||
|
- **To Create (optional, additive):**
|
||||||
|
- `tests/backend/login-shell-smoke.spec.ts` — single Jest integration test that:
|
||||||
|
1. Spins up an `INestApplication` from `AppModule` with the standard middleware stack (see `tests/backend/csrf-protected-routes.spec.ts` for the template).
|
||||||
|
2. Calls `DatabaseInitService.init()` via `initDb(app)`.
|
||||||
|
3. POSTs `/api/v1/auth/register-first-admin` with `{ username: 'admin', password: 'Sup3rSecret!Pass' }` → expects 201.
|
||||||
|
4. POSTs `/api/v1/auth/register` with `{ username: 'player1', password: 'Pl4yer1!Secret-X', passwordConfirm: 'Pl4yer1!Secret-X' }` → expects 201.
|
||||||
|
5. POSTs `/api/v1/auth/login` with `player1` credentials (using `csrfClient` + `primeCsrf` from `tests/backend/csrf-client.ts`) → expects 200.
|
||||||
|
6. Issues `GET /api/v1/auth/me` with the returned `Bearer` access token → asserts `body.username === 'player1'`, `body.role === 'player'`, and `body.points` is a number.
|
||||||
|
7. Issues `POST /api/v1/auth/change-password` with `{ oldPassword: 'Pl4yer1!Secret-X', newPassword: 'Pl4yer1!Secret-Y', confirmNewPassword: 'Pl4yer1!Secret-Y' }` → expects 204.
|
||||||
|
8. Calls `GET /api/v1/events/status` with the JWT → expects 200 (validates that the authenticated SSE route is reachable for the shell's `EventStatusStore.start()`).
|
||||||
|
- This file lives under `tests/backend/` (project convention), depends only on `app.module.ts` + `db-helper.ts` + `csrf-client.ts`, and adds no new dependencies.
|
||||||
|
|
||||||
|
- **To Modify:** *None.* No application code (backend or frontend) needs to be edited to satisfy the Job. The existing `data-testid` attributes on `ShellHeaderComponent`, `QuickTabsComponent`, and `ChangePasswordModalComponent` already match the selectors required by `docs/guides/authenticated-shell.md` and `docs/guides/change-password.md`.
|
||||||
|
|
||||||
|
## 5. Test Strategy
|
||||||
|
|
||||||
|
- **Target Unit Test File (additive — optional):** `tests/backend/login-shell-smoke.spec.ts` (described above).
|
||||||
|
- **Mocking Strategy:** No mocks. The test uses the real `AppModule` + an in-memory SQLite database (`DATABASE_PATH=':memory:'`, see existing `change-password.spec.ts` lines 1-3). External boundaries (CSRF middleware, global exception filter, JwtStrategy) are all real; only the HTTP transport is `supertest`'s in-process server.
|
||||||
|
- **Single-command run:** `npm test` from the repo root (already wired in `package.json`). No UI is required at any point.
|
||||||
|
- **What gets verified end-to-end:**
|
||||||
|
1. Argon2 policy accepts `Pl4yer1!Secret-X` (12 chars, upper/lower/digit/symbol).
|
||||||
|
2. CSRF-minted cookie is honored by the login route.
|
||||||
|
3. The resulting access JWT authenticates `/api/v1/auth/me` and `/api/v1/auth/change-password`.
|
||||||
|
4. `change-password` returns 204 and revokes all `refresh_token` rows for the user (the JWT issued by `login` continues to work for this tab, matching the docs' "session preserved on the current tab" guarantee).
|
||||||
|
5. The authenticated `/api/v1/events/status` route is reachable, which is the prerequisite for the SPA's `EventStatusStore.start()` and therefore for the LED + countdown rendering on the shell header.
|
||||||
|
|
||||||
|
## 6. Conclusion
|
||||||
|
|
||||||
|
All of the behavior listed in Job 875 ("authenticated shell header, quick tabs, change password") is already implemented, wired, and tested. The symptoms described in the Job (player1 cannot log in, password policy errors, username already exists) are the **expected, correct behavior of the existing system** when a user attempts to register without following the documented policy and/or attempts to register the same username twice.
|
||||||
|
|
||||||
|
No application code changes are required. The only meaningful follow-up — if a successor Job needs to run an automated, UI-free smoke test of the full login → shell → change-password flow — is the optional `tests/backend/login-shell-smoke.spec.ts` file described in §4.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
process.env.DATABASE_PATH = ':memory:';
|
||||||
|
process.env.THEMES_DIR = './themes';
|
||||||
|
process.env.FRONTEND_DIST = './frontend/dist';
|
||||||
|
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import * as express from 'express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { CookieAccessInfo } from 'cookiejar';
|
||||||
|
import { AppModule } from '../../backend/src/app.module';
|
||||||
|
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
|
||||||
|
import { CsrfMiddleware } from '../../backend/src/common/middleware/csrf.middleware';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { initDb } from './db-helper';
|
||||||
|
|
||||||
|
describe('Authenticated shell preconditions smoke', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
const ADMIN_USER = 'admin';
|
||||||
|
const ADMIN_PASS = 'Sup3rSecret!Pass';
|
||||||
|
const NEW_PASS = 'N3wSecret!Pass-Y';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
const csrfMw = new CsrfMiddleware(app.get(ConfigService));
|
||||||
|
app.use((req: any, res: any, next: any) => csrfMw.use(req, res, next));
|
||||||
|
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true }));
|
||||||
|
const httpAdapterHost = app.get(HttpAdapterHost);
|
||||||
|
app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost));
|
||||||
|
await app.init();
|
||||||
|
await initDb(app);
|
||||||
|
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/api/v1/auth/register-first-admin')
|
||||||
|
.send({ username: ADMIN_USER, password: ADMIN_PASS })
|
||||||
|
.expect(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login + /me returns the projected user (id, username, role, rank, points)', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||||
|
|
||||||
|
const login = await agent
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
|
.send({ username: ADMIN_USER, password: ADMIN_PASS });
|
||||||
|
expect(login.status).toBeGreaterThanOrEqual(200);
|
||||||
|
expect(login.status).toBeLessThan(300);
|
||||||
|
const token = login.body.accessToken as string;
|
||||||
|
expect(typeof token).toBe('string');
|
||||||
|
|
||||||
|
const me = await agent
|
||||||
|
.get('/api/v1/auth/me')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(me.body.username).toBe(ADMIN_USER);
|
||||||
|
expect(me.body.role).toBe('admin');
|
||||||
|
expect(typeof me.body.id).toBe('string');
|
||||||
|
expect(typeof me.body.points).toBe('number');
|
||||||
|
expect(me.body).toHaveProperty('rank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('change-password happy path returns 204 and the new password authenticates', async () => {
|
||||||
|
const server = app.getHttpServer();
|
||||||
|
const agent = request.agent(server);
|
||||||
|
await agent.get('/api/v1/auth/csrf');
|
||||||
|
const csrf = (agent.jar.getCookies(CookieAccessInfo.All) as any).find((c: any) => c.name === 'csrf');
|
||||||
|
const login = await agent
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
|
.send({ username: ADMIN_USER, password: ADMIN_PASS });
|
||||||
|
expect(login.status).toBeGreaterThanOrEqual(200);
|
||||||
|
expect(login.status).toBeLessThan(300);
|
||||||
|
const token = login.body.accessToken as string;
|
||||||
|
|
||||||
|
await agent
|
||||||
|
.post('/api/v1/auth/change-password')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
|
.send({
|
||||||
|
oldPassword: ADMIN_PASS,
|
||||||
|
newPassword: NEW_PASS,
|
||||||
|
confirmNewPassword: NEW_PASS,
|
||||||
|
})
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const relogin = await agent
|
||||||
|
.post('/api/v1/auth/login')
|
||||||
|
.set('X-CSRF-Token', csrf.value)
|
||||||
|
.send({ username: ADMIN_USER, password: NEW_PASS });
|
||||||
|
expect(relogin.status).toBeGreaterThanOrEqual(200);
|
||||||
|
expect(relogin.status).toBeLessThan(300);
|
||||||
|
expect(relogin.body.accessToken).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user