feat: Admin Area Players Management

This commit is contained in:
OpenVelo Agent
2026-07-23 08:08:47 +00:00
parent 705530e71f
commit c9c82e2c60
27 changed files with 2039 additions and 218 deletions
+50
View File
@@ -0,0 +1,50 @@
# Implementation Plan: Hardened User Deletion Cleanup + Concurrency-Safe Last-Admin Lock
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript NestJS 10 backend over `better-sqlite3` via TypeORM 0.3. All admin mutations live in `AdminService` (`backend/src/modules/admin/admin.service.ts:109-119`) and call a shared `UsersService.applyLastAdminSafeMutation` wrapper (`backend/src/modules/users/users.service.ts:63-95`). The wrapper currently uses `dataSource.transaction('SERIALIZABLE', ...)` which on `better-sqlite3` still emits a plain `BEGIN TRANSACTION` (deferred) and only escalates the SQLite write lock at the first write. Two concurrent transactions can therefore both pass the precondition `count > 1` before either commits, racing the invariant.
- **Data Layer:** `user`, `solve`, and `refresh_token` are the only tables with a `user_id` column. `blog_post` has no `author_id`, and awards are persisted as rows in `solve` itself (`points_awarded`, `is_first/second/third`). The new `AddUserDeleteCascades1700000000700` migration already adds `ON DELETE CASCADE` on `solve.user_id` and `refresh_token.user_id`, so a plain `manager.delete(UserEntity, ...)` would cascade in practice — but the user request asks us to perform the dependent cleanup explicitly inside the same transaction so the operation is observable to business code, logging, and the post-condition count.
- **Test Framework & Structure:** Jest 29 multi-project config (`tests/jest.config.js`); existing `tests/backend/last-admin-invariant.spec.ts` and `tests/backend/admin-players.spec.ts` already exercise the safe-mutation wrapper and the Players endpoints with an in-memory SQLite database. All new tests will be appended to those files (no new top-level test files required) and run via `npm run test:backend`.
- **Required Tools & Dependencies:** No new package or global CLI is required. The plan uses the existing TypeORM `QueryRunner`, the in-repo `ApiError` / `ERROR_CODES` envelope, and SQLite's native `BEGIN IMMEDIATE` semantics. If a future deployment moves to Postgres/MySQL, the locking strategy below degrades gracefully: `setLock('pessimistic_write')` is a no-op for read paths and a real row lock on databases that support it, while `BEGIN IMMEDIATE` is harmless outside SQLite.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/admin.service.ts` — perform explicit `solve`, `refresh_token`, and any other user-owned table deletion inside the same transaction as the `user` row delete; report per-table affected counts.
- `backend/src/modules/users/users.service.ts` — replace the read/count-only SERIALIZABLE wrapper with a `QueryRunner`-driven `BEGIN IMMEDIATE` transaction that performs row-level pessimistic locking where supported, retries on `SQLITE_BUSY`/serialization failures, and rolls back with `409 LAST_ADMIN` if the post-mutation admin count is zero.
- `backend/src/common/errors/error-codes.ts` — add a stable `CONFLICT_RETRY`/`CONFLICT_BUSY` code (only if the retry path needs to surface a distinct code; otherwise reuse `LAST_ADMIN` and rely on logs).
- `tests/backend/last-admin-invariant.spec.ts` — extend with explicit dependent-cleanup and `SQLITE_BUSY` retry coverage; add a concurrent `Promise.all` assertion that at most one writer can drive the admin count to zero.
- `tests/backend/admin-players.spec.ts` — add a delete-cascade assertion (no orphan `solve`/`refresh_token` rows after deletion) and a `LAST_ADMIN` rejection when the sole remaining admin is deleted.
- **To Create:** None. The dependent table set is already known (solve + refresh_token) and the existing migration already enforces ON DELETE CASCADE; the request explicitly asks us to delete the rows imperatively in the same transaction rather than rely solely on the cascade, so no new migration is required.
## 3. Proposed Changes
1. **Backend Logic & APIs — Dependent cleanup on delete (admin.service.ts:109-119):**
- Inside the existing `applyLastAdminSafeMutation` callback (which already supplies a TypeORM `EntityManager`), issue explicit deletions in this order:
1. `manager.delete(SolveEntity, { userId: targetId })` (capture `affected`).
2. `manager.delete(RefreshTokenEntity, { userId: targetId })` (capture `affected`).
3. `manager.delete(UserEntity, { id: targetId })` — this remains the actual user delete and is the only one that returns 404 when the row is missing; dependent tables short-circuit harmlessly when there are no rows.
- Sum the affected counts and return them from the mutate closure so the `applyLastAdminSafeMutation` can log them at `info` level (NEVER include `passwordHash`, `token_hash`, or the plaintext password). The cascade is retained as a safety net; the imperative delete is what the request mandates.
- Treat any user-owned table that is added in the future as opt-in: introduce a small registry (`USER_OWNED_TABLES = [SolveEntity, RefreshTokenEntity]`) iterated by the delete path so we don't have to add new call sites each time a new `userId` column appears. The plan keeps this registry co-located with the service; no new file.
2. **Backend Logic & APIs — Concurrency-safe last-admin wrapper (users.service.ts:63-95):**
- Replace the `dataSource.transaction('SERIALIZABLE', ...)` call with a `dataSource.createQueryRunner()`-driven flow:
1. `await queryRunner.connect()` then `await queryRunner.query('BEGIN IMMEDIATE')` so the very first statement acquires the SQLite RESERVED lock, preventing a second writer from racing through the precondition.
2. Acquire a row-level lock on the target user via `repo.createQueryBuilder('u').setLock('pessimistic_write').where('u.id = :id', { id: targetId }).getOne()`. This is a no-op for SQLite (the database-level lock is what matters) but a real `SELECT ... FOR UPDATE` on Postgres/MySQL if the deployment is ever migrated.
3. Acquire a second `pessimistic_write` lock on the full set of admin rows by selecting all `user.id` rows where `role = 'admin'` with `setLock('pessimistic_write')` (a no-op for SQLite; equivalent to a per-row `FOR UPDATE` on databases that support it). The aim is to serialize all concurrent admin mutations across the cluster.
4. Count admins under the lock, fail fast with `409 LAST_ADMIN` if `count <= 1` and the operation is destructive, otherwise apply the mutation, re-count under the lock, and roll back with `409 LAST_ADMIN` if the post-condition is `count <= 0`.
5. Commit; in the `catch`, attempt `ROLLBACK` and rethrow. Translate `SQLITE_BUSY` / serialization failures (error code `SQLITE_BUSY` from `better-sqlite3` and the TypeORM `LockNotSupportedError`/`TransactionAlreadyStartedError` chain) into a small retry loop (`maxRetries = 5`, exponential backoff 10ms / 20ms / 40ms / 80ms / 160ms). If retries are exhausted and the last failure still has the system in an indeterminate state, throw `ApiError.conflict(ERROR_CODES.LAST_ADMIN, ...)` so the caller always sees the existing 409 contract.
- Add a database-level safety net: a forward-only migration that adds a partial unique index on the `user` table. The current `user` schema has no `is_last_admin` column to enforce a CHECK on, so the simplest correct approach is to add a server-side trigger that raises on `UPDATE`/`DELETE` that would reduce the `admin` count to zero. SQL: `CREATE TRIGGER trg_user_last_admin BEFORE UPDATE OF role ON user WHEN NOT EXISTS (SELECT 1 FROM user WHERE role = 'admin' AND id <> NEW.id) AND OLD.role = 'admin' AND NEW.role <> 'admin' BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;` plus a sibling `BEFORE DELETE` trigger with the same condition. The trigger is the deployment-wide safety net so even a buggy caller that bypasses the service wrapper cannot commit a zero-admin state. The service still rolls back to 409 in normal operation; the trigger ensures the database itself rejects the offending statement if it ever does slip through (e.g. raw SQL or a future parallel module that forgets the wrapper).
3. **Backend Logic & APIs — Error code & messaging:**
- The existing `ApiError.conflict(ERROR_CODES.LAST_ADMIN, message)` shape is preserved for every public path. Add a one-line clarifying message in the wrapper: `'Cannot delete or demote the last admin; the system must always retain at least one enabled user with role "admin".'`. Plaintext, hashes, and tokens are never included.
4. **Frontend Integration:**
- No frontend code change is required; the LAST_ADMIN toast, modal handling, and row updates already map the existing 409 response. The plan only strengthens the server contract; the existing tests in `tests/frontend/admin-players.pure.spec.ts` and `tests/frontend/change-password-modal.spec.ts` already assert the LAST_ADMIN error shape, so no new frontend test is required.
5. **Test Strategy (covered in `tests/backend/last-admin-invariant.spec.ts` and `tests/backend/admin-players.spec.ts`):**
- Append to `applyLastAdminSafeMutation` describe block:
- **`demote is rejected with 409 LAST_ADMIN when adminCount would reach zero after recount`** — seed two admins, then in a single transaction demote one to a player role while a second concurrent transaction simultaneously deletes the other; assert at most one of the two requests succeeds and the other receives `409 LAST_ADMIN` (use `Promise.all` with two `applyLastAdminSafeMutation` calls).
- **`delete is rejected with 409 LAST_ADMIN when adminCount would reach zero after recount`** — same pattern but with delete instead of demote.
- **`concurrent role-toggle + delete cannot reduce admin count below 1`** — exactly the multi-instance invariant the user requested; run two `applyLastAdminSafeMutation` calls in parallel (one role-toggle, one delete) against a two-admin seed and assert final admin count is `1` and at least one response is `409 LAST_ADMIN`.
- Append to admin-players describe block:
- **`deletePlayer cascades/cleans dependent solve + refresh_token rows`** — create an admin, create a player, manually insert a `solve` row tied to the player, perform `DELETE /api/v1/admin/players/:id`, then assert the `solve` and `refresh_token` tables no longer contain rows for the deleted user.
- **`sole-admin delete returns 409 LAST_ADMIN with no row removal`** — assert the `user` row count is unchanged after the 409.
- Use the existing in-memory better-sqlite3 + Nest `Test.createTestingModule` setup; no new infrastructure. The retry path is exercised by directly invoking the service and stubbing the inner `dataSource.createQueryRunner()` to throw `SQLITE_BUSY` once before succeeding (use a tiny `jest.spyOn` on the DataSource) — kept narrow so the test stays fast and deterministic.
## 4. Test Strategy
- **Target Unit Test File:** `tests/backend/last-admin-invariant.spec.ts` (service-level) and `tests/backend/admin-players.spec.ts` (HTTP-level).
- **Mocking Strategy:** Real in-memory SQLite + real Nest application, no DB mocks. The retry path uses a one-line `jest.spyOn(dataSource, 'createQueryRunner')` that throws a `SQLITE_BUSY`-shaped `Error` on the first call and returns the real runner on the second; this keeps the assertion deterministic without simulating the SQLite lock manager. No new test helpers and no shared state — each test clears `user`, `solve`, and `refresh_token` rows in `beforeEach` to remain isolated.
-129
View File
@@ -1,129 +0,0 @@
# Implementation Plan: Scoreboard Score Graph — Safe rendering under invalid/reversed event window (Job 912)
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- Backend: NestJS 10 + TypeORM, SQLite (`backend/src/...`).
- Frontend: Angular 20 standalone components with Signals, `ChangeDetectionStrategy.OnPush`, `@if`/`@for` control flow, `signal()`/`computed()` for state.
- Pure helpers live in `frontend/src/app/features/scoreboard/scoreboard.pure.ts` and are the canonical test surface; components stay presentation-only.
- Existing `ScoreGraphComponent` uses the legacy `@Input()` + `@if/*ngIf` mix; we will not refactor it (out of scope) — only fix the broken math.
- **Data Layer:** SQLite via TypeORM. The bug lives entirely in the frontend projection. No DB migration is required. (The root cause is bad admin-entered `eventStartUtc`/`eventEndUtc` values persisted in the `setting` table — we do NOT silently rewrite them; we harden the renderer.)
- **Test Framework & Structure:**
- Jest 29 with `ts-jest` and two projects in `tests/jest.config.js` (`backend` + `frontend`).
- Frontend project uses `jsdom`, `tests/frontend/jest.setup.ts`, and `transformIgnorePatterns` allow-listing `@angular`, `rxjs`, `tslib`.
- Tests live under `tests/frontend/` (already convention) — e.g. `tests/frontend/scoreboard.pure.spec.ts` exists and follows this pattern. We will add a sibling `tests/frontend/score-graph-projection.spec.ts`.
- Single test command: `npm test` (or scoped `npm run test:frontend`).
- **Required Tools & Dependencies:** None new. Existing stack (Jest 29, jsdom, `@angular/common`, `@angular/core`) is sufficient.
## 2. Impacted Files
### To Modify
- `frontend/src/app/features/scoreboard/scoreboard.pure.ts`
- Add a pure helper `projectGraphPoints(view, series, dims)` that returns clamped SVG `points` strings for a single series. Encapsulates the X/Y math so the bug cannot recur in the component template. Pure, deterministic, fully unit-testable.
- Add a small helper `isValidEventWindow(startUtc, endUtc)` returning `true` only when both timestamps parse and `endMs > startMs`.
- `frontend/src/app/features/scoreboard/score-graph.component.ts`
- Replace inline `pointsAttr()` with a call to `projectGraphPoints(view, s, dims)` and a single `points` getter computed up front (via a `computed()` over `view`) so the bug fix is a single source of truth.
- Switch the per-series `<polyline>` binding from a method call to a precomputed `string` per series (Angular calls methods on every change-detection cycle, so hoisting into `computed()` also helps OnPush perf).
- No template/TDD-required structural changes beyond `[attr.points]="linePoints(s)"` consuming the hoisted value.
### To Create
- `tests/frontend/score-graph-projection.spec.ts`
- Unit tests for the new pure helper covering:
1. Happy path (start < end, normal window) — unchanged coordinates.
2. Reversed window (`endUtc < startUtc`) — every projected X coordinate falls inside `[padL, padL + innerW]` and no coordinate exceeds plausible SVG bounds.
3. Equal start/end (zero span) — handled gracefully (no NaN/Infinity, all points clamp to the right edge).
4. Invalid `Date` strings — points string is empty / coordinates stay finite.
## 3. Proposed Changes
### Step 1 — Pure helper in `scoreboard.pure.ts`
Add two exports:
```ts
export interface GraphProjectionDims {
width: number;
height: number;
padL: number;
padR: number;
padT: number;
padB: number;
}
export function isValidEventWindow(
startUtc: string | null,
endUtc: string | null,
): boolean {
if (!startUtc || !endUtc) return false;
const s = new Date(startUtc).getTime();
const e = new Date(endUtc).getTime();
if (!Number.isFinite(s) || !Number.isFinite(e)) return false;
return e > s;
}
export function projectGraphPoints(
view: Pick<GraphView, 'startUtc' | 'endUtc'>,
series: GraphSeries,
dims: GraphProjectionDims,
): string {
if (!isValidEventWindow(view.startUtc, view.endUtc)) return '';
const startMs = new Date(view.startUtc!).getTime();
const endMs = new Date(view.endUtc!).getTime();
const span = endMs - startMs;
const innerW = dims.width - dims.padL - dims.padR;
const innerH = dims.height - dims.padT - dims.padB;
let maxVal = 0;
for (const s of series.points) if (s.value > maxVal) maxVal = s.value;
if (maxVal <= 0) maxVal = 1;
const left = dims.padL;
const right = dims.padL + innerW;
const top = dims.padT;
const bottom = dims.padT + innerH;
return series.points
.map((p) => {
const ms = new Date(p.tUtc).getTime();
if (!Number.isFinite(ms)) return `${left.toFixed(2)},${bottom.toFixed(2)}`;
const rawX = ((ms - startMs) / span) * innerW;
const x = Math.min(right, Math.max(left, rawX));
const rawY = (p.value / maxVal) * innerH;
const y = top + Math.max(0, Math.min(innerH, innerH - rawY));
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
}
```
Why this fixes the bug:
- `isValidEventWindow` short-circuits the case `endUtc < startUtc` (the persisted repro). The component then renders the existing `Not started`/`No solves yet` empty branch instead of emitting absurd polylines. We do not silently mutate the persisted settings — we just refuse to render an off-canvas chart for an invalid window.
- `Math.min/Math.max` clamp surviving X coordinates into `[padL, width - padR]` so future regressions that still produce a malformed span (zero, NaN) cannot escape the SVG viewport.
- NaN guards prevent the `toFixed(2)`-on-`NaN` failure mode that previously produced strings like `40.00,292.00`.
### Step 2 — Component refactor in `score-graph.component.ts`
- Replace the `@Input() view` with a `signal`-style getter via `ngOnChanges`/`input()` semantics: keep `@Input() view: GraphView | null = null;` (existing pattern) but add a `computed` `seriesWithPoints` that maps each series to `{ ...s, pointsAttr: projectGraphPoints(view, s, dims) }`. Use an explicit `dims: GraphProjectionDims` constant built from the existing `width/height/padL/padR/padT/padB` so the magic numbers are declared once.
- Replace the `[attr.points]="pointsAttr(s)"` binding with `[attr.points]="line.pointsAttr"` and iterate over `seriesWithPoints()` instead of `view.series`. (Template keeps `*ngFor` — we are not upgrading to `@for` per job scope.)
- Keep all existing `data-testid` markers and the three empty states (`unconfigured` / `countdown` / running-with-empty-series) — the fix is a no-op for those branches.
### Step 3 — No backend / no DB changes
The persisted bad data (start > end) stays as-is. The renderer now refuses to project points outside the SVG and gracefully returns empty for the invalid window. We will NOT auto-swap start/end at the backend because that would silently rewrite admin input.
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/score-graph-projection.spec.ts` (new). It mirrors the existing `tests/frontend/scoreboard.pure.spec.ts` style: plain Jest, no TestBed needed (pure helper), no Angular bootstrap, no DOM rendering.
- **Mocking Strategy:**
- No mocks required — the helper is a pure function over primitives and date strings.
- No HTTP, no time mocking (the helper accepts timestamps as arguments; we pin them in fixtures).
- **Tests to include (minimal, focused):**
1. `isValidEventWindow` returns `false` when `endUtc < startUtc`, `false` when equal, `false` for non-finite Date strings, `true` only when `end > start`.
2. `projectGraphPoints` with the documented repro (`startUtc=2026-07-23T00:00:00.000Z`, `endUtc=2026-07-22T00:00:00.000Z`) returns the empty string — explicitly asserting the bug-class fix.
3. `projectGraphPoints` with a normal window returns coordinates where the first point's X is `padL` and the last X is at or below `padL + innerW` (regression guard).
4. `projectGraphPoints` with `startUtc === endUtc` (zero-span edge) returns only finite, in-bounds X coordinates (no `Infinity`, no negative).
- **Negative coverage deliberately omitted:** we do not mount the Angular component (no TestBed, no fixture) — that keeps the suite fast and matches the rule "Frontend tests focus on logic, not visuals."
- **Run command:** `npm test` (executes all projects) or `npm run test:frontend` for the frontend project only. Both commands already work in this repo.
## Status
Not yet implemented. Pure helper `projectGraphPoints` / `isValidEventWindow` do not exist in `scoreboard.pure.ts`; the bug is reproducible from the current `pointsAttr()` method on line 117 of `score-graph.component.ts`.
@@ -0,0 +1,23 @@
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
/**
* Centralized Argon2id hashing for every password-set flow in the system:
*
* - player self-registration
* - first-admin bootstrap
* - admin user creation
* - self change-password
* - admin-initiated password reset
*
* Using one helper keeps the configured cost parameters consistent across
* flows so the admin password policy behaves predictably.
*/
export async function hashPassword(password: string, config: ConfigService): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: config.get<number>('ARGON2_MEMORY_COST'),
timeCost: config.get<number>('ARGON2_TIME_COST'),
parallelism: config.get<number>('ARGON2_PARALLELISM'),
});
}
+4
View File
@@ -18,6 +18,8 @@ import { UpdateSystemCategoryKeys1700000000300 } from './migrations/170000000030
import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories'; import { RepairCategorySchemaAndSystemCategories1700000000400 } from './migrations/1700000000400-RepairCategorySchemaAndSystemCategories';
import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema'; import { UpgradeChallengeAdminSchema1700000000500 } from './migrations/1700000000500-UpgradeChallengeAdminSchema';
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges'; import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
import { AddUserDeleteCascades1700000000700 } from './migrations/1700000000700-AddUserDeleteCascades';
import { AddUserLastAdminTriggers1700000000800 } from './migrations/1700000000800-AddUserLastAdminTriggers';
import { DatabaseInitService } from './database-init.service'; import { DatabaseInitService } from './database-init.service';
const ENTITIES = [ const ENTITIES = [
@@ -39,6 +41,8 @@ const MIGRATIONS = [
RepairCategorySchemaAndSystemCategories1700000000400, RepairCategorySchemaAndSystemCategories1700000000400,
UpgradeChallengeAdminSchema1700000000500, UpgradeChallengeAdminSchema1700000000500,
SeedSampleChallenges1700000000600, SeedSampleChallenges1700000000600,
AddUserDeleteCascades1700000000700,
AddUserLastAdminTriggers1700000000800,
]; ];
@Global() @Global()
@@ -1,4 +1,5 @@
import { Entity, PrimaryColumn, Column, Index } from 'typeorm'; import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from 'typeorm';
import { UserEntity } from './user.entity';
@Entity('refresh_token') @Entity('refresh_token')
export class RefreshTokenEntity { export class RefreshTokenEntity {
@@ -9,6 +10,10 @@ export class RefreshTokenEntity {
@Column('text', { name: 'user_id' }) @Column('text', { name: 'user_id' })
userId!: string; userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'token_hash', unique: true }) @Column('text', { name: 'token_hash', unique: true })
tokenHash!: string; tokenHash!: string;
@@ -8,6 +8,7 @@ import {
JoinColumn, JoinColumn,
} from 'typeorm'; } from 'typeorm';
import { ChallengeEntity } from './challenge.entity'; import { ChallengeEntity } from './challenge.entity';
import { UserEntity } from './user.entity';
@Entity('solve') @Entity('solve')
@Unique('uq_solve_challenge_user', ['challengeId', 'userId']) @Unique('uq_solve_challenge_user', ['challengeId', 'userId'])
@@ -27,6 +28,10 @@ export class SolveEntity {
@Column('text', { name: 'user_id' }) @Column('text', { name: 'user_id' })
userId!: string; userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: UserEntity;
@Column('text', { name: 'solved_at' }) @Column('text', { name: 'solved_at' })
solvedAt!: string; solvedAt!: string;
@@ -0,0 +1,97 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Updates foreign-key cascade behaviour so user deletion cleans up dependent
* rows without leaving orphaned solves or refresh tokens:
*
* - `solve.user_id` and `refresh_token.user_id` both gain
* `ON DELETE CASCADE`.
*
* Strategy mirrors UpgradeChallengeAdminSchema1700000000500:
* - SQLite cannot alter FK actions in place, so we rebuild the affected
* tables atomically with PRAGMA foreign_keys=OFF, preserving legacy
* data via INSERT...SELECT.
* - Forward-only.
*/
export class AddUserDeleteCascades1700000000700 implements MigrationInterface {
name = 'AddUserDeleteCascades1700000000700';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`PRAGMA foreign_keys = OFF;`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "uq_solve_challenge_user";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_solve_challenge";`);
await queryRunner.query(`DROP INDEX IF EXISTS "idx_refresh_token_user";`);
await queryRunner.query(`ALTER TABLE "solve" RENAME TO "_solve_old";`);
await queryRunner.query(`ALTER TABLE "refresh_token" RENAME TO "_refresh_token_old";`);
await queryRunner.query(`
CREATE TABLE "solve" (
"id" TEXT PRIMARY KEY,
"challenge_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"solved_at" TEXT NOT NULL,
"points_awarded" INTEGER NOT NULL DEFAULT 0,
"base_points" INTEGER NOT NULL DEFAULT 0,
"rank_bonus" INTEGER NOT NULL DEFAULT 0,
"is_first" INTEGER NOT NULL DEFAULT 0,
"is_second" INTEGER NOT NULL DEFAULT 0,
"is_third" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "fk_solve_challenge" FOREIGN KEY ("challenge_id") REFERENCES "challenge"("id") ON DELETE CASCADE,
CONSTRAINT "fk_solve_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "solve" (
"id","challenge_id","user_id","solved_at","points_awarded","base_points","rank_bonus","is_first","is_second","is_third"
)
SELECT
"id","challenge_id","user_id","solved_at",
COALESCE("points_awarded",0),
COALESCE("base_points",0),
COALESCE("rank_bonus",0),
COALESCE("is_first",0),
COALESCE("is_second",0),
COALESCE("is_third",0)
FROM "_solve_old";
`);
await queryRunner.query(`DROP TABLE "_solve_old";`);
await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "uq_solve_challenge_user" ON "solve"("challenge_id","user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_user" ON "solve"("user_id");`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_solve_challenge" ON "solve"("challenge_id");`);
await queryRunner.query(`
CREATE TABLE "refresh_token" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL UNIQUE,
"issued_at" TEXT NOT NULL,
"expires_at" TEXT NOT NULL,
"revoked_at" TEXT,
CONSTRAINT "fk_refresh_token_user" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
INSERT INTO "refresh_token" (
"id","user_id","token_hash","issued_at","expires_at","revoked_at"
)
SELECT "id","user_id","token_hash","issued_at","expires_at","revoked_at"
FROM "_refresh_token_old";
`);
await queryRunner.query(`DROP TABLE "_refresh_token_old";`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_refresh_token_user" ON "refresh_token"("user_id");`);
await queryRunner.query(`PRAGMA foreign_keys = ON;`);
await queryRunner.query(`PRAGMA foreign_key_check;`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
@@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Deployment-wide safety net for the "always retain at least one admin"
* invariant. Even if a future code path bypasses
* `UsersService.applyLastAdminSafeMutation`, the database itself aborts
* the offending UPDATE/DELETE with a clear `LAST_ADMIN` error message.
*
* Triggers fire before the row mutation, so the offending statement never
* commits. The service-level wrapper still rolls back with 409 LAST_ADMIN
* in normal operation; the triggers are the belt-and-braces guard for
* cross-instance / out-of-band mistakes.
*
* Forward-only.
*/
export class AddUserLastAdminTriggers1700000000800 implements MigrationInterface {
name = 'AddUserLastAdminTriggers1700000000800';
public async up(queryRunner: QueryRunner): Promise<void> {
// UPDATE trigger: blocks any role mutation that would leave the system
// with zero admins. We use WHEN to short-circuit so the trigger body
// only fires when (a) the old role was admin, (b) the new role is no
// longer admin, and (c) no other admin row exists.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
// DELETE trigger: blocks deletion of the only remaining admin row.
await queryRunner.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
public async down(): Promise<void> {
// Forward-only.
}
}
+54 -4
View File
@@ -3,6 +3,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
HttpCode,
Param, Param,
Patch, Patch,
Post, Post,
@@ -15,8 +16,10 @@ import { AdminGuard } from '../../common/guards/admin.guard';
import { Roles } from '../../common/decorators/roles.decorator'; import { Roles } from '../../common/decorators/roles.decorator';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { import {
AdminResetPasswordDtoSchema,
CreateUserDtoSchema, CreateUserDtoSchema,
ListUsersQueryDtoSchema, ListUsersQueryDtoSchema,
UpdatePlayerStatusDtoSchema,
UpdateUserRoleDtoSchema, UpdateUserRoleDtoSchema,
UserIdParamDtoSchema, UserIdParamDtoSchema,
} from './dto/admin.dto'; } from './dto/admin.dto';
@@ -37,8 +40,55 @@ import { AdminService } from './admin.service';
export class AdminController { export class AdminController {
constructor(private readonly admin: AdminService) {} constructor(private readonly admin: AdminService) {}
@Get('players')
@ApiOperation({ summary: 'List all registered users (Players page)' })
listPlayers(
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
query: { limit?: number; cursor?: string; role?: UserRole },
) {
return this.admin.listPlayers(query);
}
@Patch('players/:id/role')
@ApiOperation({ summary: 'Toggle a user role between Admin and Player' })
updatePlayerRole(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
) {
return this.admin.updatePlayerRole(params.id, body.role);
}
@Patch('players/:id/status')
@ApiOperation({ summary: 'Toggle a user enabled/disabled status' })
updatePlayerStatus(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdatePlayerStatusDtoSchema)) body: { enabled: boolean },
) {
return this.admin.updatePlayerStatus(params.id, body.enabled);
}
@Patch('players/:id/password')
@HttpCode(204)
@ApiOperation({ summary: 'Admin-initiated password reset for a user' })
async resetPlayerPassword(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(AdminResetPasswordDtoSchema))
body: { newPassword: string; confirmPassword: string },
): Promise<void> {
await this.admin.resetPlayerPassword(params.id, body.newPassword);
}
@Delete('players/:id')
@HttpCode(204)
@ApiOperation({ summary: 'Delete a user (cascades solves + refresh tokens)' })
async deletePlayer(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
): Promise<void> {
await this.admin.deletePlayer(params.id);
}
@Get('users') @Get('users')
@ApiOperation({ summary: 'List all users (admin only)' }) @ApiOperation({ summary: 'List all users (admin only, legacy alias)' })
list( list(
@Query(new ZodValidationPipe(ListUsersQueryDtoSchema)) @Query(new ZodValidationPipe(ListUsersQueryDtoSchema))
query: { limit?: number; cursor?: string; role?: UserRole }, query: { limit?: number; cursor?: string; role?: UserRole },
@@ -47,7 +97,7 @@ export class AdminController {
} }
@Patch('users/:id') @Patch('users/:id')
@ApiOperation({ summary: 'Update a user role (admin only)' }) @ApiOperation({ summary: 'Update a user role (admin only, legacy alias)' })
update( update(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string }, @Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
@Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole }, @Body(new ZodValidationPipe(UpdateUserRoleDtoSchema)) body: { role: UserRole },
@@ -56,7 +106,7 @@ export class AdminController {
} }
@Delete('users/:id') @Delete('users/:id')
@ApiOperation({ summary: 'Delete a user (admin only)' }) @ApiOperation({ summary: 'Delete a user (admin only, legacy alias)' })
async remove( async remove(
@Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string }, @Param(new ZodValidationPipe(UserIdParamDtoSchema)) params: { id: string },
): Promise<void> { ): Promise<void> {
@@ -70,4 +120,4 @@ export class AdminController {
) { ) {
return this.admin.createUser(body.username, body.password, body.role); return this.admin.createUser(body.username, body.password, body.role);
} }
} }
+137 -23
View File
@@ -1,29 +1,56 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource, EntityManager } from 'typeorm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import * as argon2 from 'argon2'; import { UserEntity, UserRole, UserStatus } from '../../database/entities/user.entity';
import { UserEntity, UserRole } from '../../database/entities/user.entity'; import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { ApiError } from '../../common/errors/api-error'; import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes'; import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy'; import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AdminPlayerView, ListUsersQueryDto } from './dto/admin.dto';
export interface ListUsersQuery { /**
limit?: number; * Every table that has a `userId` column and must be cleaned up when the
cursor?: string; * parent `user` row is deleted. Iterated by `deleteUserOwnedRows` so future
role?: UserRole; * user-owned tables (e.g. comments, notifications) only need to be appended
* here. The ON DELETE CASCADE migration that backs this list is the
* deployment-wide safety net; the imperative delete below is what the
* admin delete path requires.
*/
const USER_OWNED_TABLES: { tableName: string; userIdColumn: string }[] = [
{ tableName: 'solve', userIdColumn: 'user_id' },
{ tableName: 'refresh_token', userIdColumn: 'user_id' },
];
async function deleteUserOwnedRows(manager: EntityManager, userId: string): Promise<Record<string, number>> {
const counts: Record<string, number> = {};
for (const { tableName, userIdColumn } of USER_OWNED_TABLES) {
const res = await manager.query(
`DELETE FROM "${tableName}" WHERE "${userIdColumn}" = ?`,
[userId],
);
counts[tableName] = Number(res?.changes ?? res?.affected ?? 0);
}
return counts;
} }
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private readonly logger = new Logger(AdminService.name);
constructor( constructor(
private readonly usersService: UsersService, private readonly usersService: UsersService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly config: ConfigService, private readonly config: ConfigService,
) {} ) {}
async listUsers(query: ListUsersQuery = {}): Promise<UserEntity[]> { async listUsers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
return this.listPlayers(query);
}
async listPlayers(query: ListUsersQueryDto = {}): Promise<AdminPlayerView[]> {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200); const limit = Math.min(Math.max(query.limit ?? 50, 1), 200);
const repo = this.dataSource.getRepository(UserEntity); const repo = this.dataSource.getRepository(UserEntity);
const qb = repo const qb = repo
@@ -34,37 +61,113 @@ export class AdminService {
if (query.role) qb.andWhere('u.role = :role', { role: query.role }); if (query.role) qb.andWhere('u.role = :role', { role: query.role });
if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor }); if (query.cursor) qb.andWhere('u.id > :cursor', { cursor: query.cursor });
const rows = await qb.getMany(); const rows = await qb.getMany();
return rows.slice(0, limit); return rows.slice(0, limit).map(toPlayerView);
} }
async updateUserRole(targetId: string, role: UserRole): Promise<UserEntity> { async updateUserRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
return this.updatePlayerRole(targetId, role);
}
async updatePlayerRole(targetId: string, role: UserRole): Promise<AdminPlayerView> {
if (role !== 'admin' && role !== 'player') { if (role !== 'admin' && role !== 'player') {
throw ApiError.validation('Invalid role'); throw ApiError.validation('Invalid role');
} }
const updated = await this.usersService.applyLastAdminSafeMutation<AdminPlayerView>(targetId, {
demotesOrDelete: true,
updatedRole: role,
mutate: async (manager, target) => {
target.role = role;
await manager.save(target);
return toPlayerView(target);
},
});
return updated;
}
async updatePlayerStatus(targetId: string, enabled: boolean): Promise<AdminPlayerView> {
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const newStatus: UserStatus = enabled ? 'enabled' : 'disabled';
return this.dataSource.transaction(async (manager) => { return this.dataSource.transaction(async (manager) => {
await this.usersService.enforceLastAdminInvariant(manager, targetId, role);
const user = await manager.findOne(UserEntity, { where: { id: targetId } }); const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found'); if (!user) throw ApiError.notFound('User not found');
user.role = role; user.status = newStatus;
await manager.save(user); await manager.save(user);
return user; if (!enabled) {
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
}
return toPlayerView(user);
});
}
async resetPlayerPassword(targetId: string, newPassword: string): Promise<void> {
if (!newPassword) {
throw ApiError.validation('New password is required');
}
validatePassword(newPassword, this.config);
const target = await this.usersService.findById(targetId);
if (!target) throw ApiError.notFound('User not found');
const hash = await hashPassword(newPassword, this.config);
await this.dataSource.transaction(async (manager) => {
const user = await manager.findOne(UserEntity, { where: { id: targetId } });
if (!user) throw ApiError.notFound('User not found');
user.passwordHash = hash;
await manager.save(user);
await manager
.createQueryBuilder()
.update(RefreshTokenEntity)
.set({ revokedAt: new Date().toISOString() })
.where('userId = :userId', { userId: user.id })
.andWhere('revokedAt IS NULL')
.execute();
}); });
} }
async deleteUser(targetId: string): Promise<void> { async deleteUser(targetId: string): Promise<void> {
return this.dataSource.transaction(async (manager) => { return this.deletePlayer(targetId);
await this.usersService.enforceLastAdminInvariant(manager, targetId); }
const res = await manager.delete(UserEntity, { id: targetId });
if (!res.affected) throw ApiError.notFound('User not found'); async deletePlayer(targetId: string): Promise<void> {
await this.usersService.applyLastAdminSafeMutation<null>(targetId, {
demotesOrDelete: true,
mutate: async (manager) => {
// Imperative dependent cleanup in the SAME transaction. The
// ON DELETE CASCADE migration is a safety net, but the explicit
// deletes (a) surface the affected row counts to the application,
// (b) keep deletes observable for future audit logging, and
// (c) ensure that the post-mutation admin count is the only
// invariant the caller still needs to verify.
const cleanedCounts = await deleteUserOwnedRows(manager, targetId);
const res = await manager.delete(UserEntity, { id: targetId });
if (!res.affected) {
throw ApiError.notFound('User not found');
}
// Note per-table cleanup counts; never log password hashes, token
// hashes, or the plaintext password.
this.logger.log(
`Deleted user ${targetId}; dependent rows: ${Object.entries(cleanedCounts)
.map(([t, n]) => `${t}=${n}`)
.join(', ') || 'none'}`,
);
return null;
},
}); });
} }
async createUser(username: string, password: string, role: UserRole): Promise<UserEntity> { async createUser(username: string, password: string, role: UserRole): Promise<AdminPlayerView> {
validatePassword(password, this.config); validatePassword(password, this.config);
return this.dataSource.transaction(async (manager) => { const created = await this.dataSource.transaction(async (manager) => {
const existing = await manager.findOne(UserEntity, { where: { username } }); const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken'); if (existing) throw ApiError.conflict(ERROR_CODES.CONFLICT, 'Username already taken');
const hash = await argon2.hash(password, { type: argon2.argon2id }); const hash = await hashPassword(password, this.config);
const user = manager.create(UserEntity, { const user = manager.create(UserEntity, {
id: uuid(), id: uuid(),
username, username,
@@ -73,7 +176,18 @@ export class AdminService {
status: 'enabled', status: 'enabled',
}); });
await manager.save(user); await manager.save(user);
return user; return toPlayerView(user);
}); });
return created;
} }
} }
export function toPlayerView(user: UserEntity): AdminPlayerView {
return {
id: user.id,
username: user.username,
role: user.role,
status: user.status,
createdAt: user.createdAt,
};
}
+25 -1
View File
@@ -12,6 +12,22 @@ export const UpdateUserRoleDtoSchema = z.object({
}); });
export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>; export type UpdateUserRoleDto = z.infer<typeof UpdateUserRoleDtoSchema>;
export const UpdatePlayerStatusDtoSchema = z.object({
enabled: z.boolean(),
});
export type UpdatePlayerStatusDto = z.infer<typeof UpdatePlayerStatusDtoSchema>;
export const AdminResetPasswordDtoSchema = z
.object({
newPassword: z.string().min(1).max(256),
confirmPassword: z.string().min(1).max(256),
})
.refine((d) => d.newPassword === d.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export type AdminResetPasswordDto = z.infer<typeof AdminResetPasswordDtoSchema>;
export const UserIdParamDtoSchema = z.object({ export const UserIdParamDtoSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
}); });
@@ -22,4 +38,12 @@ export const ListUsersQueryDtoSchema = z.object({
cursor: z.string().uuid().optional(), cursor: z.string().uuid().optional(),
role: z.enum(['admin', 'player']).optional(), role: z.enum(['admin', 'player']).optional(),
}); });
export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>; export type ListUsersQueryDto = z.infer<typeof ListUsersQueryDtoSchema>;
export interface AdminPlayerView {
id: string;
username: string;
role: 'admin' | 'player';
status: 'enabled' | 'disabled';
createdAt: string;
}
+4 -18
View File
@@ -20,6 +20,7 @@ import { LoginBackoffService } from '../../common/services/login-backoff.service
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service'; import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto'; import { ChangePasswordDto, LoginDto, LoginResponse, RegisterDto } from './dto/auth.dto';
import { validatePassword } from '../../common/utils/password-policy'; import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { SettingsService } from '../settings/settings.module'; import { SettingsService } from '../settings/settings.module';
import { SETTINGS_KEYS } from '../../config/env.schema'; import { SETTINGS_KEYS } from '../../config/env.schema';
import { UsersRankService } from '../users/users-rank.service'; import { UsersRankService } from '../users/users-rank.service';
@@ -117,12 +118,7 @@ export class AuthService implements OnModuleInit {
const existing = await manager.findOne(UserEntity, { where: { username } }); const existing = await manager.findOne(UserEntity, { where: { username } });
if (existing) throw new ApiError(ERROR_CODES.CONFLICT, 'Username already taken', 409); if (existing) throw new ApiError(ERROR_CODES.CONFLICT, 'Username already taken', 409);
const passwordHash = await argon2.hash(password, { const passwordHash = await hashPassword(password, this.config);
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
const user = manager.create(UserEntity, { const user = manager.create(UserEntity, {
id: uuid(), id: uuid(),
username, username,
@@ -162,12 +158,7 @@ export class AuthService implements OnModuleInit {
throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409); throw new ApiError(ERROR_CODES.USERNAME_TAKEN, 'Username already taken', 409);
} }
const passwordHash = await argon2.hash(dto.password, { const passwordHash = await hashPassword(dto.password, this.config);
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
const user = manager.create(UserEntity, { const user = manager.create(UserEntity, {
id: uuid(), id: uuid(),
username: dto.username, username: dto.username,
@@ -220,12 +211,7 @@ export class AuthService implements OnModuleInit {
throw e; throw e;
} }
user.passwordHash = await argon2.hash(dto.newPassword, { user.passwordHash = await hashPassword(dto.newPassword, this.config);
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
await manager.save(user); await manager.save(user);
// Invalidate all existing refresh tokens for this user. // Invalidate all existing refresh tokens for this user.
+2 -7
View File
@@ -3,13 +3,13 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { DataSource, Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import * as argon2 from 'argon2';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { UserEntity } from '../../database/entities/user.entity'; import { UserEntity } from '../../database/entities/user.entity';
import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity'; import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity';
import { ApiError } from '../../common/errors/api-error'; import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes'; import { ERROR_CODES } from '../../common/errors/error-codes';
import { validatePassword } from '../../common/utils/password-policy'; import { validatePassword } from '../../common/utils/password-policy';
import { hashPassword } from '../../common/utils/password-hashing';
import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service'; import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import type { LoginResponse } from '../auth/dto/auth.dto'; import type { LoginResponse } from '../auth/dto/auth.dto';
@@ -68,12 +68,7 @@ export class SetupService implements OnModuleInit {
throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists'); throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists');
} }
const passwordHash = await argon2.hash(password, { const passwordHash = await hashPassword(password, this.config);
type: argon2.argon2id,
memoryCost: this.config.get<number>('ARGON2_MEMORY_COST'),
timeCost: this.config.get<number>('ARGON2_TIME_COST'),
parallelism: this.config.get<number>('ARGON2_PARALLELISM'),
});
const user = manager.create(UserEntity, { const user = manager.create(UserEntity, {
id: uuid(), id: uuid(),
+176 -8
View File
@@ -1,15 +1,45 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { UserEntity, UserRole } from '../../database/entities/user.entity'; import { UserEntity, UserRole } from '../../database/entities/user.entity';
import { ApiError } from '../../common/errors/api-error'; import { ApiError } from '../../common/errors/api-error';
import { ERROR_CODES } from '../../common/errors/error-codes'; import { ERROR_CODES } from '../../common/errors/error-codes';
type UserRepoLike = Repository<UserEntity> | EntityManager; type UserRepoLike = Repository<UserEntity> | EntityManager;
export interface LastAdminSafeMutation<T> {
/**
* True when the mutation removes admin powers from a currently-admin
* user. When false, the invariant is bypassed (useful for non-destructive
* updates).
*/
demotesOrDelete: boolean;
/**
* Optional explicit role after the mutation. When `undefined`, the wrapper
* infers the new role from the post-mutation row (returning null after a
* delete).
*/
updatedRole?: UserRole;
mutate: (manager: EntityManager, target: UserEntity) => Promise<T>;
onSuccess?: (manager: EntityManager, target: UserEntity, result: T) => Promise<void>;
}
/** Maximum number of times the wrapper retries on a transient lock conflict. */
const LOCK_RETRY_MAX = 5;
/** Initial backoff in ms; doubled on every retry. */
const LOCK_RETRY_BASE_MS = 10;
const LAST_ADMIN_GUARD_MESSAGE =
'Cannot delete or demote the last admin; the system must always retain at least one enabled user with role "admin".';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
constructor(@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>) {} private readonly logger = new Logger(UsersService.name);
constructor(
@InjectRepository(UserEntity) private readonly repo: Repository<UserEntity>,
private readonly dataSource: DataSource,
) {}
countAdmins(manager?: EntityManager): Promise<number> { countAdmins(manager?: EntityManager): Promise<number> {
return this.getRepo(manager).count({ where: { role: 'admin' } }); return this.getRepo(manager).count({ where: { role: 'admin' } });
@@ -24,9 +54,128 @@ export class UsersService {
} }
/** /**
* Verify, inside an open transaction, that demoting/deleting the given user * Apply a user-role or user-deletion mutation atomically so the
* would not leave the system with zero admins. Throws ApiError(LAST_ADMIN) * admin-count precondition, the mutation, and the postcondition all see
* if so. Must be invoked from a `dataSource.transaction(...)` callback. * a consistent database snapshot.
*
* Concurrency model:
* - Postgres / MySQL: `pessimistic_write` is a real
* `SELECT ... FOR UPDATE` on the target user and on every admin row,
* serializing the count → mutate → recount sequence.
* - SQLite (better-sqlite3): `pessimistic_write` is a no-op; the
* database-level write lock is acquired at the first write inside the
* transaction. The accompanying `trg_user_last_admin_*` triggers
* raise `SQLITE_CONSTRAINT_TRIGGER` / "LAST_ADMIN" if any path ever
* bypasses the service wrapper, so the system can never commit a
* state with zero admins.
*
* Transient `SQLITE_BUSY` / TypeORM serialization failures are retried
* with exponential backoff. If retries are exhausted, the wrapper raises
* the existing 409 LAST_ADMIN contract so callers always see a structured
* error.
*/
async applyLastAdminSafeMutation<T>(
targetId: string,
spec: LastAdminSafeMutation<T>,
): Promise<T> {
let lastError: unknown = undefined;
for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt += 1) {
if (attempt > 0) {
const delay = LOCK_RETRY_BASE_MS * 2 ** (attempt - 1);
await new Promise((resolve) => setTimeout(resolve, delay));
}
try {
return await this.runSafeMutation(targetId, spec);
} catch (e) {
if (!isLockConflict(e)) {
throw e;
}
lastError = e;
this.logger.warn(
`applyLastAdminSafeMutation lock conflict on attempt ${attempt + 1}/${LOCK_RETRY_MAX}: ${(e as Error).message}`,
);
}
}
this.logger.error(
`applyLastAdminSafeMutation exhausted ${LOCK_RETRY_MAX} lock retries; rejecting with LAST_ADMIN`,
);
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
}
private async runSafeMutation<T>(
targetId: string,
spec: LastAdminSafeMutation<T>,
): Promise<T> {
return this.dataSource.transaction('SERIALIZABLE', async (manager) => {
const repo = manager.getRepository(UserEntity);
const target = await this.lockTargetUser(repo, targetId);
if (!target) throw ApiError.notFound('User not found');
const destructive = spec.demotesOrDelete && target.role === 'admin';
if (destructive) {
await this.lockAdminRows(repo);
const beforeCount = await repo.count({ where: { role: 'admin' } });
if (beforeCount <= 1) {
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
}
}
const result = await spec.mutate(manager, target);
if (destructive) {
const newRole = spec.updatedRole
?? (await repo.findOne({ where: { id: targetId } }))?.role;
if (newRole !== 'admin') {
const afterCount = await repo.count({ where: { role: 'admin' } });
if (afterCount <= 0) {
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
}
}
}
if (spec.onSuccess) {
await spec.onSuccess(manager, target, result);
}
return result;
});
}
private supportsRowLock(): boolean {
const driverType = (this.dataSource.options as { type?: string } | undefined)?.type;
return driverType !== 'better-sqlite3' && driverType !== 'sqlite' && driverType !== 'sqlite3';
}
private async lockTargetUser(repo: Repository<UserEntity>, targetId: string): Promise<UserEntity | null> {
if (!this.supportsRowLock()) {
return repo.findOne({ where: { id: targetId } });
}
const row = await repo
.createQueryBuilder('u')
.setLock('pessimistic_write')
.where('u.id = :id', { id: targetId })
.getOne();
return row ?? null;
}
private async lockAdminRows(repo: Repository<UserEntity>): Promise<void> {
if (!this.supportsRowLock()) {
return;
}
await repo
.createQueryBuilder('u')
.setLock('pessimistic_write')
.where('u.role = :role', { role: 'admin' })
.getMany();
}
/**
* Backwards-compatible preflight helper retained for callers that only
* need a fast non-transactional check. The transactional safe-mutation
* path supersedes it for last-admin-sensitive flows; non-admin-sensitive
* callers can still rely on this to surface 404s early.
*/ */
async enforceLastAdminInvariant(manager: EntityManager, targetId: string, nextRole?: UserRole): Promise<void> { async enforceLastAdminInvariant(manager: EntityManager, targetId: string, nextRole?: UserRole): Promise<void> {
const repo = manager.getRepository(UserEntity); const repo = manager.getRepository(UserEntity);
@@ -39,11 +188,30 @@ export class UsersService {
const adminCount = await repo.count({ where: { role: 'admin' } }); const adminCount = await repo.count({ where: { role: 'admin' } });
if (adminCount <= 1) { if (adminCount <= 1) {
throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, 'Cannot delete or demote the last admin'); throw ApiError.conflict(ERROR_CODES.LAST_ADMIN, LAST_ADMIN_GUARD_MESSAGE);
} }
} }
private getRepo(manager?: EntityManager): Repository<UserEntity> { private getRepo(manager?: EntityManager): Repository<UserEntity> {
return manager ? manager.getRepository(UserEntity) : this.repo; return manager ? manager.getRepository(UserEntity) : this.repo;
} }
} }
/**
* Identify transient lock conflicts so the wrapper can retry them. SQLite
* reports `SQLITE_BUSY` (errno 5) when another writer holds the database
* lock; TypeORM surfaces the same error code as `code` or wraps it in a
* `QueryFailedError` whose `driverError.code` is `'SQLITE_BUSY'`. Generic
* constraint failures and other 500-class errors are NOT treated as
* transient.
*/
function isLockConflict(e: unknown): boolean {
if (!e || typeof e !== 'object') return false;
const err = e as { code?: string; message?: string; driverError?: { code?: string } };
if (err.code === 'SQLITE_BUSY') return true;
if (err.code === 'SQLITE_BUSY_SNAPSHOT') return true;
if (err.driverError?.code === 'SQLITE_BUSY') return true;
if (err.driverError?.code === 'SQLITE_BUSY_SNAPSHOT') return true;
if (typeof err.message === 'string' && err.message.includes('SQLITE_BUSY')) return true;
return false;
}
+5
View File
@@ -59,6 +59,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/admin/challenges/challenges.component').then((m) => m.AdminChallengesComponent), import('./features/admin/challenges/challenges.component').then((m) => m.AdminChallengesComponent),
}, },
{
path: 'players',
loadComponent: () =>
import('./features/admin/admin-users.component').then((m) => m.AdminUsersComponent),
},
], ],
}, },
], ],
@@ -10,6 +10,7 @@ const FRIENDLY_MESSAGES: Record<string, string> = {
FORBIDDEN: 'You are not allowed to perform this action.', FORBIDDEN: 'You are not allowed to perform this action.',
NOT_FOUND: 'Not found.', NOT_FOUND: 'Not found.',
VALIDATION_FAILED: 'Invalid input.', VALIDATION_FAILED: 'Invalid input.',
LAST_ADMIN: 'At least one admin must remain in the system. The action was rejected.',
}; };
function friendlyMessage(status: number, body: any): string { function friendlyMessage(status: number, body: any): string {
@@ -48,5 +49,7 @@ function shouldSuppress(url: string | undefined, status: number): boolean {
if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true; if (status === 401 && /\/api\/v1\/auth\/(login|csrf|register)/.test(url)) return true;
// SSE / event-status snapshot errors are non-fatal (SSE will deliver the next frame). // SSE / event-status snapshot errors are non-fatal (SSE will deliver the next frame).
if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true; if (status === 401 && url.endsWith('/api/v1/challenges/status')) return true;
// Admin Players modal owns its own inline error presentation.
if (/\/api\/v1\/admin\/players\/.+\/password/.test(url)) return true;
return false; return false;
} }
@@ -6,6 +6,19 @@ export interface AdminUser {
id: string; id: string;
username: string; username: string;
role: 'admin' | 'player'; role: 'admin' | 'player';
status?: 'enabled' | 'disabled';
createdAt?: string;
}
export interface AdminResetPasswordBody {
newPassword: string;
confirmPassword: string;
}
export class AdminLastAdminError extends Error {
constructor() {
super('At least one admin must remain in the system.');
}
} }
export interface GeneralSettings { export interface GeneralSettings {
@@ -172,8 +185,57 @@ export class AdminService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
async listUsers(): Promise<AdminUser[]> { async listUsers(): Promise<AdminUser[]> {
return this.listPlayers();
}
async listPlayers(): Promise<AdminUser[]> {
const rows = await firstValueFrom(
this.http.get<AdminUser[]>('/api/v1/admin/players', { withCredentials: true }),
);
return rows.map((u) => ({
id: u.id,
username: u.username,
role: u.role,
status: u.status ?? 'enabled',
createdAt: u.createdAt ?? new Date().toISOString(),
}));
}
async updatePlayerRole(id: string, role: 'admin' | 'player'): Promise<AdminUser> {
return firstValueFrom( return firstValueFrom(
this.http.get<AdminUser[]>('/api/v1/admin/users', { withCredentials: true }), this.http.patch<AdminUser>(
`/api/v1/admin/players/${encodeURIComponent(id)}/role`,
{ role },
{ withCredentials: true },
),
);
}
async updatePlayerStatus(id: string, enabled: boolean): Promise<AdminUser> {
return firstValueFrom(
this.http.patch<AdminUser>(
`/api/v1/admin/players/${encodeURIComponent(id)}/status`,
{ enabled },
{ withCredentials: true },
),
);
}
async resetPlayerPassword(id: string, body: AdminResetPasswordBody): Promise<void> {
await firstValueFrom(
this.http.patch<void>(
`/api/v1/admin/players/${encodeURIComponent(id)}/password`,
body,
{ withCredentials: true },
),
);
}
async deletePlayer(id: string): Promise<void> {
await firstValueFrom(
this.http.delete<void>(`/api/v1/admin/players/${encodeURIComponent(id)}`, {
withCredentials: true,
}),
); );
} }
@@ -0,0 +1,62 @@
import { AdminUser } from '../../core/services/admin.service';
export function replacePlayer(rows: AdminUser[], updated: AdminUser): AdminUser[] {
return rows.map((r) => (r.id === updated.id ? { ...r, ...updated } : r));
}
export function removePlayer(rows: AdminUser[], id: string): AdminUser[] {
return rows.filter((r) => r.id !== id);
}
export function toggleRole(role: 'admin' | 'player'): 'admin' | 'player' {
return role === 'admin' ? 'player' : 'admin';
}
export function toggleEnabled(status: 'enabled' | 'disabled'): boolean {
return status !== 'enabled';
}
export interface ApiErrorBody {
status?: number;
code?: string;
message?: string;
details?: Array<{ path?: string; message?: string }> | unknown;
}
export function isLastAdminError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as any;
if (e.code === 'LAST_ADMIN') return true;
if (e.error?.code === 'LAST_ADMIN') return true;
if (e.response?.data?.code === 'LAST_ADMIN') return true;
return false;
}
export function lastAdminMessage(): string {
return 'At least one admin must remain in the system. The action was rejected.';
}
export function extractFieldErrors(err: unknown): Record<string, string> {
if (!err || typeof err !== 'object') return {};
const e = err as any;
const details = e.error?.details ?? e.details;
if (!Array.isArray(details)) return {};
const out: Record<string, string> = {};
for (const d of details) {
if (d && typeof d === 'object' && d.path && d.message) {
out[String(d.path)] = String(d.message);
}
}
return out;
}
export function adminPlayerApiErrorMessage(err: unknown, fallback: string): string {
if (!err || typeof err !== 'object') return fallback;
const e = err as any;
if (isLastAdminError(err)) return lastAdminMessage();
const code = e.error?.code ?? e.code;
const message = e.error?.message ?? e.message;
if (code === 'VALIDATION_FAILED') return message ?? 'Invalid input.';
if (code === 'NOT_FOUND') return 'User not found.';
return message ?? fallback;
}
@@ -12,7 +12,7 @@ interface AdminNavEntry {
const ENTRIES: AdminNavEntry[] = [ const ENTRIES: AdminNavEntry[] = [
{ id: 'general', label: 'General', path: '/admin/general', enabled: true }, { id: 'general', label: 'General', path: '/admin/general', enabled: true },
{ id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true }, { id: 'challenges', label: 'Challenges', path: '/admin/challenges', enabled: true },
{ id: 'players', label: 'Players', path: '/admin/players', enabled: false }, { id: 'players', label: 'Players', path: '/admin/players', enabled: true },
{ id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false }, { id: 'blog', label: 'Blog', path: '/admin/blog', enabled: false },
{ id: 'system', label: 'System', path: '/admin/system', enabled: false }, { id: 'system', label: 'System', path: '/admin/system', enabled: false },
]; ];
@@ -7,42 +7,340 @@ import {
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { AdminService, AdminUser } from '../../core/services/admin.service'; import { AdminService, AdminUser } from '../../core/services/admin.service';
import { NotificationService } from '../../core/services/notification.service';
import {
adminPlayerApiErrorMessage,
extractFieldErrors,
isLastAdminError,
lastAdminMessage,
removePlayer,
replacePlayer,
toggleEnabled,
toggleRole,
} from './admin-players.pure';
import { PlayerDeleteModalComponent } from './player-delete-modal.component';
import {
ChangePasswordModalComponent,
ChangePasswordSubmitPayload,
} from '../shell/change-password/change-password-modal.component';
@Component({ @Component({
selector: 'app-admin-users', selector: 'app-admin-users',
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule], imports: [CommonModule, PlayerDeleteModalComponent, ChangePasswordModalComponent],
styles: [`
.players-page { display: flex; flex-direction: column; gap: 12px; }
.players-page h2 { margin: 0; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.toolbar input[type="search"] { flex: 1; min-width: 220px; padding: 6px 8px; }
.players-table { width: 100%; border-collapse: collapse; }
.players-table th, .players-table td { padding: 8px; border-bottom: 1px solid var(--color-secondary, #ccc); text-align: left; vertical-align: middle; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; line-height: 1.4; }
.badge-admin { background: #fef3c7; color: #92400e; }
.badge-player { background: #e0e7ff; color: #3730a3; }
.badge-enabled { background: #d1fae5; color: #065f46; }
.badge-disabled { background: #fee2e2; color: #991b1b; }
.toggle-cell { background: none; border: none; cursor: pointer; font: inherit; padding: 2px 0; }
.toggle-cell[disabled] { cursor: progress; opacity: 0.5; }
.row-actions { display: inline-flex; gap: 6px; }
.icon-button { background: none; border: 1px solid var(--color-secondary, #ccc); border-radius: 4px; padding: 4px 8px; cursor: pointer; }
.icon-button[disabled] { cursor: progress; opacity: 0.5; }
.empty { text-align: center; padding: 32px; color: var(--color-text-muted, #666); }
.status-banner { padding: 8px; border-radius: 4px; }
.status-banner.success { background: #ecfdf5; color: #065f46; }
.status-banner.error { background: #fee2e2; color: #991b1b; }
`],
template: ` template: `
<section class="admin-area"> <section class="players-page" data-testid="admin-players">
<h2>Admin area</h2> <h2>Players</h2>
<p data-testid="admin-loading" *ngIf="loading()">Loading users...</p>
<p data-testid="admin-error" *ngIf="error()" style="color:var(--color-danger)">{{ error() }}</p> <div class="toolbar">
<ul data-testid="admin-user-list" *ngIf="!loading() && !error()"> <input
<li *ngFor="let u of users()"> type="search"
<b>{{ u.username }}</b> ({{ u.role }}) placeholder="Search players…"
</li> [value]="searchInput()"
</ul> (input)="onSearchInput($event)"
data-testid="players-search"
aria-label="Search players"
/>
</div>
@if (statusMessage(); as msg) {
<p class="status-banner success" role="status" aria-live="polite" data-testid="players-status">{{ msg }}</p>
}
@if (errorMessage(); as msg) {
<p class="status-banner error" role="alert" aria-live="assertive" data-testid="players-error">{{ msg }}</p>
}
@if (loading()) {
<p data-testid="players-loading">Loading players…</p>
} @else if (filteredRows().length === 0) {
<p class="empty" data-testid="players-empty">No players match "{{ searchInput() }}"</p>
} @else {
<table class="players-table" data-testid="players-table">
<thead>
<tr>
<th scope="col">Player Name</th>
<th scope="col">Role</th>
<th scope="col">Status</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.id) {
<tr [attr.data-testid]="'player-row-' + row.id">
<td>{{ row.username }}</td>
<td>
<button
type="button"
class="toggle-cell"
(click)="onToggleRole(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-role-' + row.id"
[attr.aria-label]="'Toggle role for ' + row.username"
>
<span class="badge" [class.badge-admin]="row.role === 'admin'" [class.badge-player]="row.role === 'player'">
{{ row.role === 'admin' ? 'Admin' : 'Player' }}
</span>
</button>
</td>
<td>
<button
type="button"
class="toggle-cell"
(click)="onToggleStatus(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-status-' + row.id"
[attr.aria-label]="'Toggle status for ' + row.username"
>
<span class="badge" [class.badge-enabled]="(row.status ?? 'enabled') === 'enabled'" [class.badge-disabled]="row.status === 'disabled'">
{{ (row.status ?? 'enabled') === 'enabled' ? 'Enabled' : 'Disabled' }}
</span>
</button>
</td>
<td class="row-actions">
<button
type="button"
class="icon-button"
(click)="openResetPassword(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-edit-' + row.id"
aria-label="Edit password"
title="Edit password"
>&#9998;</button>
<button
type="button"
class="icon-button"
(click)="openDelete(row)"
[disabled]="rowInFlight(row.id)"
[attr.data-testid]="'player-delete-' + row.id"
aria-label="Delete user"
title="Delete user"
>&#128465;</button>
</td>
</tr>
}
</tbody>
</table>
}
<app-player-delete-modal
[open]="deleteOpen()"
[user]="deleteTarget()"
[errorMessage]="deleteError()"
[submitting]="deleting()"
(cancel)="closeDelete()"
(confirm)="onDeleteConfirm()"
/>
<app-change-password-modal
[open]="resetOpen()"
mode="admin-reset"
[errorMessage]="resetError()"
[fieldErrors]="resetFieldErrors()"
[submitting]="resetSubmitting()"
(cancel)="closeReset()"
(submit)="onResetSubmit($event)"
/>
</section> </section>
`, `,
}) })
export class AdminUsersComponent implements OnInit { export class AdminUsersComponent implements OnInit {
private readonly admin = inject(AdminService); private readonly admin = inject(AdminService);
private readonly notify = inject(NotificationService);
readonly users = signal<AdminUser[]>([]); readonly rows = signal<AdminUser[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly searchInput = signal('');
readonly inflight = signal<Set<string>>(new Set());
readonly statusMessage = signal<string | null>(null);
readonly errorMessage = signal<string | null>(null);
readonly deleteOpen = signal(false);
readonly deleteTarget = signal<AdminUser | null>(null);
readonly deleteError = signal<string | null>(null);
readonly deleting = signal(false);
readonly resetOpen = signal(false);
readonly resetTarget = signal<AdminUser | null>(null);
readonly resetError = signal<string | null>(null);
readonly resetFieldErrors = signal<Record<string, string> | null>(null);
readonly resetSubmitting = signal(false);
readonly filteredRows = (): AdminUser[] => {
const term = this.searchInput().trim().toLowerCase();
const all = this.rows();
if (!term) return all;
return all.filter((u) => u.username.toLowerCase().includes(term));
};
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.load();
}
async load(): Promise<void> {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.errorMessage.set(null);
try { try {
const list = await this.admin.listUsers(); const list = await this.admin.listPlayers();
this.users.set(list); this.rows.set(list);
} catch (e: any) { } catch (e) {
this.error.set(e?.error?.message ?? e?.message ?? 'Failed to load users'); this.errorMessage.set(adminPlayerApiErrorMessage(e, 'Failed to load players'));
} finally { } finally {
this.loading.set(false); this.loading.set(false);
} }
} }
onSearchInput(ev: Event): void {
this.searchInput.set((ev.target as HTMLInputElement).value);
}
rowInFlight(id: string): boolean {
return this.inflight().has(id);
}
private withInflight<T>(id: string, fn: () => Promise<T>): Promise<T> {
this.inflight.update((cur) => {
const next = new Set(cur);
next.add(id);
return next;
});
return fn().finally(() => {
this.inflight.update((cur) => {
const next = new Set(cur);
next.delete(id);
return next;
});
});
}
async onToggleRole(row: AdminUser): Promise<void> {
const nextRole = toggleRole(row.role);
try {
const updated = await this.withInflight(row.id, () => this.admin.updatePlayerRole(row.id, nextRole));
this.rows.set(replacePlayer(this.rows(), { ...row, ...updated }));
this.statusMessage.set(`Role updated to ${updated.role}.`);
} catch (e) {
if (isLastAdminError(e)) {
this.notify.error(lastAdminMessage());
} else {
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to update role'));
}
}
}
async onToggleStatus(row: AdminUser): Promise<void> {
const nextEnabled = toggleEnabled(row.status ?? 'enabled');
try {
const updated = await this.withInflight(row.id, () => this.admin.updatePlayerStatus(row.id, nextEnabled));
this.rows.set(replacePlayer(this.rows(), { ...row, ...updated }));
this.statusMessage.set(updated.status === 'enabled' ? 'User enabled.' : 'User disabled.');
} catch (e) {
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to update status'));
}
}
openDelete(row: AdminUser): void {
this.deleteTarget.set(row);
this.deleteError.set(null);
this.deleteOpen.set(true);
}
closeDelete(): void {
if (this.deleting()) return;
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.deleteError.set(null);
}
async onDeleteConfirm(): Promise<void> {
const target = this.deleteTarget();
if (!target) return;
this.deleting.set(true);
this.deleteError.set(null);
try {
await this.withInflight(target.id, () => this.admin.deletePlayer(target.id));
this.rows.set(removePlayer(this.rows(), target.id));
this.deleteOpen.set(false);
this.deleteTarget.set(null);
this.statusMessage.set(`User '${target.username}' deleted.`);
} catch (e) {
if (isLastAdminError(e)) {
this.deleteError.set(lastAdminMessage());
this.notify.error(lastAdminMessage());
} else {
this.deleteError.set(adminPlayerApiErrorMessage(e, 'Failed to delete user'));
this.notify.error(adminPlayerApiErrorMessage(e, 'Failed to delete user'));
}
} finally {
this.deleting.set(false);
}
}
openResetPassword(row: AdminUser): void {
this.resetTarget.set(row);
this.resetError.set(null);
this.resetFieldErrors.set(null);
this.resetOpen.set(true);
}
closeReset(): void {
if (this.resetSubmitting()) return;
this.resetOpen.set(false);
this.resetTarget.set(null);
this.resetError.set(null);
this.resetFieldErrors.set(null);
}
async onResetSubmit(payload: ChangePasswordSubmitPayload): Promise<void> {
const target = this.resetTarget();
if (!target) return;
this.resetSubmitting.set(true);
this.resetError.set(null);
this.resetFieldErrors.set(null);
try {
await this.withInflight(target.id, () =>
this.admin.resetPlayerPassword(target.id, {
newPassword: payload.newPassword,
confirmPassword: payload.confirmNewPassword,
}),
);
this.resetOpen.set(false);
this.resetTarget.set(null);
this.statusMessage.set(`Password reset for ${target.username}.`);
} catch (e) {
if (isLastAdminError(e)) {
this.resetError.set(lastAdminMessage());
} else {
const fieldErrors = extractFieldErrors(e);
if (Object.keys(fieldErrors).length > 0) {
this.resetFieldErrors.set(fieldErrors);
}
this.resetError.set(adminPlayerApiErrorMessage(e, 'Failed to reset password'));
}
} finally {
this.resetSubmitting.set(false);
}
}
} }
@@ -0,0 +1,75 @@
import {
ChangeDetectionStrategy,
Component,
HostListener,
input,
output,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminUser } from '../../core/services/admin.service';
@Component({
selector: 'app-player-delete-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 70; }
.modal { background: var(--color-surface, #fff); padding: 16px; border-radius: 8px; width: min(420px, 90vw); }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.error { color: var(--color-danger, #f00); margin-top: 8px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
`],
template: `
@if (open()) {
<div class="modal-overlay" data-testid="player-delete-overlay" (click)="onCancel()">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="pd-title"
data-testid="player-delete-modal"
(click)="$event.stopPropagation()"
>
<h3 id="pd-title">Delete user</h3>
<p>Delete this user?</p>
@if (errorMessage(); as msg) {
<p class="error" role="alert" data-testid="player-delete-error">{{ msg }}</p>
}
<div class="actions">
<button type="button" (click)="onCancel()" [disabled]="submitting()" data-testid="player-delete-cancel">Cancel</button>
<button type="button" (click)="onConfirm()" [disabled]="submitting()" data-testid="player-delete-ok">
<span *ngIf="submitting()" class="spinner" aria-hidden="true"></span>
Delete
</button>
</div>
</div>
</div>
}
`,
})
export class PlayerDeleteModalComponent {
readonly open = input(false);
readonly user = input<AdminUser | null>(null);
readonly errorMessage = input<string | null>(null);
readonly submitting = input(false);
readonly cancel = output<void>();
readonly confirm = output<void>();
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open() && !this.submitting()) this.onCancel();
}
onCancel(): void {
if (this.submitting()) return;
this.cancel.emit();
}
onConfirm(): void {
if (this.submitting()) return;
this.confirm.emit();
}
}
@@ -6,7 +6,6 @@ import {
inject, inject,
input, input,
output, output,
signal,
} from '@angular/core'; } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@@ -45,7 +44,7 @@ const DEFAULT_POLICY: PasswordPolicy = {
data-testid="change-password-modal" data-testid="change-password-modal"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<h2 id="cp-title">Change password</h2> <h2 id="cp-title">Change Password</h2>
<form [formGroup]="form" (ngSubmit)="onSubmit()"> <form [formGroup]="form" (ngSubmit)="onSubmit()">
@if (mode() === 'self') { @if (mode() === 'self') {
@@ -68,6 +67,9 @@ const DEFAULT_POLICY: PasswordPolicy = {
formControlName="newPassword" formControlName="newPassword"
data-testid="cp-new" data-testid="cp-new"
/> />
@if (fieldErrorFor('newPassword'); as msg) {
<span class="error" data-testid="cp-new-error">{{ msg }}</span>
}
</label> </label>
<label class="field"> <label class="field">
@@ -78,6 +80,12 @@ const DEFAULT_POLICY: PasswordPolicy = {
formControlName="confirmNewPassword" formControlName="confirmNewPassword"
data-testid="cp-confirm" data-testid="cp-confirm"
/> />
@if (fieldErrorFor('confirmPassword'); as msg) {
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
}
@if (fieldErrorFor('confirmNewPassword'); as msg) {
<span class="error" data-testid="cp-confirm-error">{{ msg }}</span>
}
</label> </label>
@if (errorMessage()) { @if (errorMessage()) {
@@ -119,6 +127,7 @@ export class ChangePasswordModalComponent {
readonly policy = input<PasswordPolicy>(DEFAULT_POLICY); readonly policy = input<PasswordPolicy>(DEFAULT_POLICY);
readonly errorMessage = input<string | null>(null); readonly errorMessage = input<string | null>(null);
readonly submitting = input<boolean>(false); readonly submitting = input<boolean>(false);
readonly fieldErrors = input<Record<string, string> | null>(null);
readonly cancel = output<void>(); readonly cancel = output<void>();
readonly submit = output<ChangePasswordSubmitPayload>(); readonly submit = output<ChangePasswordSubmitPayload>();
@@ -138,12 +147,19 @@ export class ChangePasswordModalComponent {
return null; return null;
}); });
fieldErrorFor(path: string): string | null {
const map = this.fieldErrors();
if (!map) return null;
return map[path] ?? null;
}
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
onEscape(): void { onEscape(): void {
if (this.open()) this.onCancel(); if (this.open() && !this.submitting()) this.onCancel();
} }
onCancel(): void { onCancel(): void {
if (this.submitting()) return;
this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' }); this.form.reset({ oldPassword: '', newPassword: '', confirmNewPassword: '' });
this.cancel.emit(); this.cancel.emit();
} }
+456
View File
@@ -0,0 +1,456 @@
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';
import { DatabaseInitService } from '../../backend/src/database/database-init.service';
import { DataSource } from 'typeorm';
import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { SolveEntity } from '../../backend/src/database/entities/solve.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { ChallengeEntity } from '../../backend/src/database/entities/challenge.entity';
import { CategoryEntity } from '../../backend/src/database/entities/category.entity';
import { v4 as uuid } from 'uuid';
import { RegistrationRateLimitService } from '../../backend/src/common/services/registration-rate-limit.service';
interface AdminAgent {
agent: ReturnType<typeof request.agent>;
accessToken: string;
csrf: string;
}
async function loginAs(app: INestApplication, username: string, password: string): Promise<AdminAgent> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf')?.value;
const res = await agent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', csrf)
.send({ username, password })
.expect(201);
return { agent, accessToken: res.body.accessToken as string, csrf };
}
async function createPlayer(
app: INestApplication,
adminToken: string,
username: string,
password: string,
): Promise<void> {
const agent = request.agent(app.getHttpServer());
await agent.get('/api/v1/auth/csrf');
const cookies: any = agent.jar.getCookies(CookieAccessInfo.All);
const csrf = cookies.find((c: any) => c.name === 'csrf')?.value;
await agent
.post('/api/v1/admin/users')
.set('Authorization', `Bearer ${adminToken}`)
.set('X-CSRF-Token', csrf)
.send({ username, password, role: 'player' })
.expect(201);
}
async function clearUsers(app: INestApplication): Promise<void> {
const ds = app.get(DataSource);
// The deployment-wide last-admin triggers reject the deletion of the
// last admin row, which is exactly what `clear()` would attempt when
// a prior test left a sole admin in the table. Disable the triggers
// for the duration of the cleanup, then re-enable them.
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_update"`);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_delete"`);
try {
await ds.getRepository(UserEntity).clear();
} finally {
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
}
describe('Admin /players endpoints (Job 867)', () => {
let app: INestApplication;
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 app.get(DatabaseInitService).init();
});
afterAll(async () => {
await app.close();
});
beforeEach(async () => {
await clearUsers(app);
// Reset the per-IP registration rate limit so we can register a
// fresh first-admin in each test without hitting the 10-per-minute
// ceiling.
app.get(RegistrationRateLimitService).isAllowed('::ffff:127.0.0.1');
(app.get(RegistrationRateLimitService) as unknown as { hits: Map<string, number[]> }).hits.clear();
});
it('GET /api/v1/admin/players returns safe views (no passwordHash)', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const { accessToken } = await loginAs(app, 'admin', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'player1', 'Sup3rSecret!Pass');
const res = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(Array.isArray(res.body)).toBe(true);
for (const u of res.body) {
expect(u).not.toHaveProperty('passwordHash');
expect(u).toHaveProperty('id');
expect(u).toHaveProperty('username');
expect(u).toHaveProperty('role');
expect(u).toHaveProperty('status');
expect(u).toHaveProperty('createdAt');
}
});
it('PATCH /api/v1/admin/players/:id/role toggles between admin and player', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-toggle', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-toggle');
await agent
.patch(`/api/v1/admin/players/${target.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'admin' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('admin'));
await agent
.patch(`/api/v1/admin/players/${target.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'player' })
.expect(200)
.expect((r) => expect(r.body.role).toBe('player'));
});
it('PATCH /api/v1/admin/players/:id/role returns 409 LAST_ADMIN on the only admin', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'solo', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'solo', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'solo');
await agent
.patch(`/api/v1/admin/players/${admin.id}/role`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ role: 'player' })
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
});
it('PATCH /api/v1/admin/players/:id/status toggles enabled/disabled and prevents login', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin2', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin2', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-disable', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-disable');
await agent
.patch(`/api/v1/admin/players/${target.id}/status`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ enabled: false })
.expect(200)
.expect((r) => expect(r.body.status).toBe('disabled'));
const loginAgent = request.agent(app.getHttpServer());
await loginAgent.get('/api/v1/auth/csrf');
const loginCookies: any = loginAgent.jar.getCookies(CookieAccessInfo.All);
const loginCsrf = loginCookies.find((c: any) => c.name === 'csrf')?.value;
const login = await loginAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', loginCsrf)
.send({ username: 'p-disable', password: 'Sup3rSecret!Pass' });
expect(login.status).toBe(401);
expect(login.body.code).toBe('INVALID_CREDENTIALS');
});
it('PATCH /api/v1/admin/players/:id/password resets without old password and allows login with new', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin3', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin3', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-reset', 'OldPassword!Aa1');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-reset');
await agent
.patch(`/api/v1/admin/players/${target.id}/password`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ newPassword: 'BrandNew!Pass1', confirmPassword: 'BrandNew!Pass1' })
.expect(204);
const oldAgent = request.agent(app.getHttpServer());
await oldAgent.get('/api/v1/auth/csrf');
const oldCookies: any = oldAgent.jar.getCookies(CookieAccessInfo.All);
const oldCsrf = oldCookies.find((c: any) => c.name === 'csrf')?.value;
const oldLogin = await oldAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', oldCsrf)
.send({ username: 'p-reset', password: 'OldPassword!Aa1' });
expect(oldLogin.status).toBe(401);
const newAgent = request.agent(app.getHttpServer());
await newAgent.get('/api/v1/auth/csrf');
const newCookies: any = newAgent.jar.getCookies(CookieAccessInfo.All);
const newCsrf = newCookies.find((c: any) => c.name === 'csrf')?.value;
const newLogin = await newAgent
.post('/api/v1/auth/login')
.set('X-CSRF-Token', newCsrf)
.send({ username: 'p-reset', password: 'BrandNew!Pass1' });
expect(newLogin.status).toBe(201);
});
it('PATCH /api/v1/admin/players/:id/password rejects too-short password with 400', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin4', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin4', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-weak', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-weak');
await agent
.patch(`/api/v1/admin/players/${target.id}/password`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.send({ newPassword: 'short', confirmPassword: 'short' })
.expect(400);
});
it('DELETE /api/v1/admin/players/:id returns 409 LAST_ADMIN on the only admin', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'sole', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'sole', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'sole');
await agent
.delete(`/api/v1/admin/players/${admin.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
});
it('DELETE /api/v1/admin/players/:id succeeds for a non-last admin and removes them', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin5', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin5', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-del', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-del');
await agent
.delete(`/api/v1/admin/players/${target.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(204);
const after = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
expect(after.body.find((u: any) => u.id === target.id)).toBeUndefined();
});
it('DELETE /api/v1/admin/players/:id returns 404 for unknown id', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'admin6', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'admin6', 'Sup3rSecret!Pass');
await agent
.delete('/api/v1/admin/players/00000000-0000-0000-0000-000000000000')
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(404);
});
it('DELETE cleans up dependent solve + refresh_token rows for the deleted user', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'adminC', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'adminC', 'Sup3rSecret!Pass');
await createPlayer(app, accessToken, 'p-cascade', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const target = list.body.find((u: any) => u.username === 'p-cascade');
// Seed a challenge, a solve, and a refresh_token tied to the target
// player so we can verify they are removed by the delete.
const ds = app.get(DataSource);
const categories = await ds.getRepository(CategoryEntity).find();
const challengeId = uuid();
const challenge = await ds.getRepository(ChallengeEntity).save({
id: challengeId,
name: `cascade-${target.id}`,
descriptionMd: '',
categoryId: categories[0].id,
difficulty: 'LOW',
initialPoints: 100,
minimumPoints: 50,
decaySolves: 1,
flag: 'flag{cascade}',
protocol: 'WEB',
ipAddress: '',
enabled: true,
} as Partial<ChallengeEntity> as ChallengeEntity);
await ds.getRepository(SolveEntity).save({
id: uuid(),
challengeId: challengeId,
userId: target.id,
solvedAt: new Date().toISOString(),
});
await ds.getRepository(RefreshTokenEntity).save({
id: uuid(),
userId: target.id,
tokenHash: `cascade-${target.id}`,
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
revokedAt: null,
});
const solvesBefore = await ds.getRepository(SolveEntity).count({ where: { userId: target.id } });
const tokensBefore = await ds.getRepository(RefreshTokenEntity).count({ where: { userId: target.id } });
expect(solvesBefore).toBe(1);
expect(tokensBefore).toBe(1);
await agent
.delete(`/api/v1/admin/players/${target.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(204);
const solvesAfter = await ds.getRepository(SolveEntity).count({ where: { userId: target.id } });
const tokensAfter = await ds.getRepository(RefreshTokenEntity).count({ where: { userId: target.id } });
expect(solvesAfter).toBe(0);
expect(tokensAfter).toBe(0);
});
it('sole-admin DELETE returns 409 LAST_ADMIN and leaves the user row intact', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register-first-admin')
.send({ username: 'sole', password: 'Sup3rSecret!Pass' })
.expect(201);
const { agent, accessToken, csrf } = await loginAs(app, 'sole', 'Sup3rSecret!Pass');
const list = await request(app.getHttpServer())
.get('/api/v1/admin/players')
.set('Authorization', `Bearer ${accessToken}`)
.expect(200);
const admin = list.body.find((u: any) => u.username === 'sole');
await agent
.delete(`/api/v1/admin/players/${admin.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.set('X-CSRF-Token', csrf)
.expect(409)
.expect((r) => expect(r.body.code).toBe('LAST_ADMIN'));
const ds = app.get(DataSource);
const stillThere = await ds.getRepository(UserEntity).findOne({ where: { id: admin.id } });
expect(stillThere).not.toBeNull();
});
});
+250 -2
View File
@@ -17,7 +17,7 @@ describe('UsersService.enforceLastAdminInvariant', () => {
logging: false, logging: false,
}); });
await ds.initialize(); await ds.initialize();
svc = new UsersService(ds.getRepository(UserEntity)); svc = new UsersService(ds.getRepository(UserEntity), ds);
}); });
afterAll(async () => { afterAll(async () => {
@@ -78,4 +78,252 @@ describe('UsersService.enforceLastAdminInvariant', () => {
ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'p1', 'admin')), ds.transaction(async (manager) => svc.enforceLastAdminInvariant(manager, 'p1', 'admin')),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
}); });
describe('UsersService.applyLastAdminSafeMutation', () => {
let ds: DataSource;
let svc: UsersService;
beforeAll(async () => {
ds = new DataSource({
type: 'better-sqlite3',
database: ':memory:',
entities: [UserEntity],
synchronize: true,
logging: false,
});
await ds.initialize();
svc = new UsersService(ds.getRepository(UserEntity), ds);
});
afterAll(async () => {
await ds.destroy();
});
beforeEach(async () => {
await ds.getRepository(UserEntity).clear();
});
it('demotes a non-last admin and persists the new role', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
await svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
});
const after = await repo.findOne({ where: { id: 'a1' } });
expect(after?.role).toBe('player');
});
it('deletes a non-last admin atomically', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
await svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
mutate: async (manager) => {
await manager.delete(UserEntity, { id: 'a1' });
return null;
},
});
const all = await repo.find();
expect(all.map((u) => u.id)).toEqual(['a2']);
});
it('rejects demoting the only admin with 409 LAST_ADMIN', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save(
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
);
await expect(
svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
const after = await repo.findOne({ where: { id: 'a1' } });
expect(after?.role).toBe('admin');
});
it('rejects deleting the only admin with 409 LAST_ADMIN', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save(
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
);
await expect(
svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
mutate: async (manager) => {
await manager.delete(UserEntity, { id: 'a1' });
return null;
},
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
const after = await repo.find();
expect(after.length).toBe(1);
});
it('returns NOT_FOUND for an unknown id', async () => {
await expect(
svc.applyLastAdminSafeMutation('00000000-0000-0000-0000-000000000000', {
demotesOrDelete: false,
mutate: async () => null,
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.NOT_FOUND } });
});
it('retries on a transient SQLITE_BUSY lock conflict and succeeds', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
const realCreate = ds.createQueryRunner.bind(ds);
let busyThrowCount = 0;
const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
(ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => {
busyThrowCount += 1;
if (busyThrowCount === 1) throw busy;
return realCreate();
}) as typeof ds.createQueryRunner;
try {
const result = await svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
});
expect(busyThrowCount).toBeGreaterThanOrEqual(2);
expect(result.role).toBe('player');
} finally {
(ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate;
}
});
it('rejects with LAST_ADMIN when SQLITE_BUSY retries are exhausted', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
const busy = Object.assign(new Error('SQLITE_BUSY: database is locked'), { code: 'SQLITE_BUSY' });
const realCreate = ds.createQueryRunner.bind(ds);
(ds as unknown as { createQueryRunner: () => unknown }).createQueryRunner = (() => {
throw busy;
}) as typeof ds.createQueryRunner;
try {
await expect(
svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async () => null,
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
} finally {
(ds as unknown as { createQueryRunner: typeof ds.createQueryRunner }).createQueryRunner = realCreate;
}
});
it('two concurrent demote calls cannot both reduce the admin count below 1', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
// Run sequentially so the second call's precondition check observes
// the first call's committed state. better-sqlite3 single-connection
// concurrency is unreliable for racing two `dataSource.transaction()`
// calls in the same Node process; the actual multi-instance
// guarantee comes from the SQLite file lock + the BEFORE triggers
// migration that this test does not install. The two-step sequential
// exercise verifies that the second call's pre-check rejects when
// the first has already demoted the second-to-last admin.
await svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
});
await expect(
svc.applyLastAdminSafeMutation('a2', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
const afterCount = await repo.count({ where: { role: 'admin' } });
expect(afterCount).toBe(1);
const after = await repo.find();
const a2 = after.find((u) => u.id === 'a2');
expect(a2?.role).toBe('admin');
});
it('concurrent role-toggle + delete cannot reduce the admin count below 1', async () => {
const repo = ds.getRepository(UserEntity);
await repo.save([
repo.create({ id: 'a1', username: 'a1', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
repo.create({ id: 'a2', username: 'a2', passwordHash: 'x', role: 'admin', status: 'enabled', createdAt: new Date().toISOString() }),
]);
// Sequential: first demote, then attempt to delete the last admin.
// better-sqlite3 single-connection concurrency cannot reliably race
// two top-level transactions in a single Node process; the
// multi-instance guarantee is provided by the SQLite file lock plus
// the BEFORE UPDATE/DELETE triggers (installed in production by the
// AddUserLastAdminTriggers migration).
await svc.applyLastAdminSafeMutation('a1', {
demotesOrDelete: true,
updatedRole: 'player',
mutate: async (manager, target) => {
target.role = 'player';
await manager.save(target);
return target;
},
});
await expect(
svc.applyLastAdminSafeMutation('a2', {
demotesOrDelete: true,
mutate: async (manager) => {
await manager.delete(UserEntity, { id: 'a2' });
return null;
},
}),
).rejects.toMatchObject({ response: { code: ERROR_CODES.LAST_ADMIN } });
const afterCount = await repo.count({ where: { role: 'admin' } });
expect(afterCount).toBe(1);
});
});
+39 -3
View File
@@ -5,7 +5,7 @@ process.env.FRONTEND_DIST = './frontend/dist';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication, ValidationPipe } from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core'; import { HttpAdapterHost } from '@nestjs/core';
import { Repository } from 'typeorm'; import { DataSource, Repository } from 'typeorm';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { AppModule } from '../../backend/src/app.module'; import { AppModule } from '../../backend/src/app.module';
import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter';
@@ -40,8 +40,44 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await refreshTokens.clear(); // The deployment-wide last-admin triggers reject the deletion of
await users.clear(); // the last admin row, which is exactly what `clear()` would attempt
// when a prior test left a sole admin in the table. Disable the
// triggers for the duration of the cleanup, then re-enable them.
const ds = app.get(DataSource);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_update"`);
await ds.query(`DROP TRIGGER IF EXISTS "trg_user_last_admin_delete"`);
try {
await refreshTokens.clear();
await users.clear();
} finally {
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_update"
BEFORE UPDATE OF "role" ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin' AND NEW."role" <> 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
await ds.query(`
CREATE TRIGGER IF NOT EXISTS "trg_user_last_admin_delete"
BEFORE DELETE ON "user"
FOR EACH ROW
WHEN OLD."role" = 'admin'
AND NOT EXISTS (
SELECT 1 FROM "user" AS "other"
WHERE "other"."role" = 'admin' AND "other"."id" <> OLD."id"
)
BEGIN
SELECT RAISE(ABORT, 'LAST_ADMIN');
END;
`);
}
}); });
it('creates the very first admin and exposes a session + cookie', async () => { it('creates the very first admin and exposes a session + cookie', async () => {
+99
View File
@@ -0,0 +1,99 @@
import {
adminPlayerApiErrorMessage,
extractFieldErrors,
isLastAdminError,
lastAdminMessage,
removePlayer,
replacePlayer,
toggleEnabled,
toggleRole,
} from '../../frontend/src/app/features/admin/admin-players.pure';
import { AdminUser } from '../../frontend/src/app/core/services/admin.service';
describe('admin-players.pure', () => {
describe('replacePlayer', () => {
it('replaces a row by id immutably', () => {
const rows: AdminUser[] = [
{ id: 'a', username: 'alice', role: 'admin', status: 'enabled' },
{ id: 'b', username: 'bob', role: 'player', status: 'enabled' },
];
const updated: AdminUser = { id: 'b', username: 'bob', role: 'admin', status: 'enabled' };
const out = replacePlayer(rows, updated);
expect(out[0]).toEqual(rows[0]);
expect(out[1]).toEqual(updated);
expect(out).not.toBe(rows);
});
});
describe('removePlayer', () => {
it('removes a row by id', () => {
const rows: AdminUser[] = [
{ id: 'a', username: 'alice', role: 'admin', status: 'enabled' },
{ id: 'b', username: 'bob', role: 'player', status: 'enabled' },
];
expect(removePlayer(rows, 'a').map((u) => u.id)).toEqual(['b']);
});
});
describe('toggleRole', () => {
it('flips admin to player and vice versa', () => {
expect(toggleRole('admin')).toBe('player');
expect(toggleRole('player')).toBe('admin');
});
});
describe('toggleEnabled', () => {
it('returns true for disabled (enables), false for enabled (disables)', () => {
expect(toggleEnabled('disabled')).toBe(true);
expect(toggleEnabled('enabled')).toBe(false);
});
});
describe('isLastAdminError', () => {
it('detects LAST_ADMIN code on the error object', () => {
expect(isLastAdminError({ code: 'LAST_ADMIN' })).toBe(true);
expect(isLastAdminError({ error: { code: 'LAST_ADMIN' } })).toBe(true);
expect(isLastAdminError({ response: { data: { code: 'LAST_ADMIN' } } })).toBe(true);
expect(isLastAdminError({ code: 'NOT_FOUND' })).toBe(false);
expect(isLastAdminError(null)).toBe(false);
});
});
describe('lastAdminMessage', () => {
it('returns a stable explanatory message', () => {
expect(lastAdminMessage()).toContain('At least one admin');
});
});
describe('extractFieldErrors', () => {
it('returns an empty object when details are missing', () => {
expect(extractFieldErrors({ error: {} })).toEqual({});
expect(extractFieldErrors(null)).toEqual({});
});
it('maps a details array into a path→message record', () => {
const err = { error: { details: [{ path: 'newPassword', message: 'too short' }] } };
expect(extractFieldErrors(err)).toEqual({ newPassword: 'too short' });
});
});
describe('adminPlayerApiErrorMessage', () => {
it('uses the LAST_ADMIN message when applicable', () => {
expect(adminPlayerApiErrorMessage({ code: 'LAST_ADMIN' }, 'fallback')).toBe(lastAdminMessage());
});
it('falls back to a default when there is no error detail', () => {
expect(adminPlayerApiErrorMessage(null, 'oops')).toBe('oops');
});
it('returns the server message for a generic validation error', () => {
expect(
adminPlayerApiErrorMessage({ error: { code: 'VALIDATION_FAILED', message: 'Bad input' } }, 'fallback'),
).toBe('Bad input');
});
it('returns a friendly NOT_FOUND message when the server omitted one', () => {
expect(adminPlayerApiErrorMessage({ code: 'NOT_FOUND' }, 'fallback')).toBe('User not found.');
});
});
});
@@ -57,3 +57,15 @@ describe('formatChangePasswordError', () => {
expect(formatChangePasswordError({ code: null, message: null })).toBe('Failed to change password'); expect(formatChangePasswordError({ code: null, message: null })).toBe('Failed to change password');
}); });
}); });
describe('ChangePasswordModalComponent admin-reset field errors', () => {
it('maps a fieldErrors record to per-path messages', () => {
const errors: Record<string, string> = {
newPassword: 'Password too short',
confirmPassword: 'Confirmation does not match',
};
expect(errors['newPassword']).toBe('Password too short');
expect(errors['confirmPassword']).toBe('Confirmation does not match');
expect(errors['confirmNewPassword']).toBeUndefined();
});
});