diff --git a/.kilo/plans/820-wal-openapi-status.md b/.kilo/plans/820-wal-openapi-status.md deleted file mode 100644 index 85156c2..0000000 --- a/.kilo/plans/820-wal-openapi-status.md +++ /dev/null @@ -1,101 +0,0 @@ -# Implementation Plan: WAL journal mode + OpenAPI 3.1 — Status check - -## 0. Scope vs. Current Branch State - -This reviewer request asks for three changes that were **already implemented -and committed** in `de27a8b` on the `feature-820-1784637300954` branch. The -code currently in the working tree (and in HEAD) matches the request verbatim: - -1. ✅ `InitSchema` migration (`backend/src/database/migrations/1700000000000-InitSchema.ts`) - already contains the WAL PRAGMA at line 8 — immediately after the existing - `PRAGMA foreign_keys = ON` (line 7). -2. ✅ `DatabaseInitService` (`backend/src/database/database-init.service.ts`) - already enforces WAL on every startup via a private `ensureWalMode()` - method that runs after `dataSource.initialize()`. It checks the current - `journal_mode` and applies `PRAGMA journal_mode = WAL` if not already - `wal`; idempotent and safe to call repeatedly. -3. ✅ `main.ts` (`backend/src/main.ts` lines 83–94) now pipes the - NestJS-generated OpenAPI document through a custom `toOpenApi31()` - converter (`backend/src/common/utils/openapi31.ts`) that emits - `openapi: '3.1.0'` and rewrites every legacy `nullable: true` marker - into the JSON Schema 2020-12 type union. - -Per the system instruction "If the feature is ALREADY fully implemented, you -MUST include the exact marker text **[ALREADY_IMPLEMENTED]** at the top or in -the status section of your plan file. Explain in the plan exactly which -files/methods already contain the implementation", this plan documents the -existing implementation rather than adding duplicate code. - -## 1. Architectural Reconnaissance - -- **Migration** (`backend/src/database/migrations/1700000000000-InitSchema.ts:7-8`): - ```ts - await queryRunner.query(`PRAGMA foreign_keys = ON;`); - await queryRunner.query(`PRAGMA journal_mode = WAL;`); - ``` -- **Warm-DB enforcement** (`backend/src/database/database-init.service.ts:34-78`): - - `init()` calls `ensureWalMode()` after `dataSource.initialize()`. - - `ensureWalMode()` queries `PRAGMA journal_mode`, switches to WAL if not - already set, logs the transition. -- **OpenAPI 3.1 pipeline** (`backend/src/main.ts:83-94`): - ```ts - const swaggerConfig = new DocumentBuilder()... - const rawDocument = SwaggerModule.createDocument(app, swaggerConfig) as Record; - const document = toOpenApi31(rawDocument); - SwaggerModule.setup('api/docs', app, document, { jsonDocumentUrl: 'api/docs-json' }); - ``` - - `backend/src/common/utils/openapi31.ts` exports `toOpenApi31(doc)`. - - Rewrites `nullable: true` → `type: ['', 'null']` recursively in - `components.schemas`, `paths`, parameters, request/response bodies, - `properties`, `items`, `oneOf/anyOf/allOf`, and nested `schema` keys. - - Preserves `$ref` nodes. -- **Tests** (`tests/backend/openapi31.spec.ts`, 7 tests): all pass. -- **Full suite**: 83/83 tests across 18 suites. - -## 2. Impacted Files - -None — all changes are already in place at HEAD. - -| File | Status | -| --- | --- | -| `backend/src/database/migrations/1700000000000-InitSchema.ts` | ✅ line 8 | -| `backend/src/database/database-init.service.ts` | ✅ lines 34-78 | -| `backend/src/common/utils/openapi31.ts` | ✅ exists (created in `de27a8b`) | -| `backend/src/main.ts` | ✅ lines 83-94 | -| `tests/backend/openapi31.spec.ts` | ✅ exists, 7 tests passing | - -## 3. Proposed Changes - -No code changes required. If a follow-up task wants to extend the existing -implementation (e.g., add an `info.license` field, support `webhooks`, or -add a Swagger UI bundle switch), that would be a separate plan. - -## 4. Test Strategy - -- The existing `tests/backend/openapi31.spec.ts` (7 tests) locks the - conversion behaviour. -- The full suite `npm test` passes 83/83 across 18 suites. -- A live-server smoke (documented in the previous turn's commit message) - confirms `openapi: '3.1.0'`, 13 paths, zero `nullable: true` occurrences, - WAL applied on cold start AND confirmed on warm restart. - -## 5. Persistent `/data` Usage - -No change. WAL persists to the file-backed SQLite DB; the SQLite WAL mode is -persisted in the database header, so subsequent connections inherit it. - -## 6. Definition of Done (already met) - -- `git log --oneline -3` shows commit `de27a8b` "WAL journal mode + OpenAPI 3.1" -- `npm test` → 83/83 passing -- `npm run build` → backend + frontend succeed -- `GET /api/docs-json` returns `openapi: "3.1.0"` -- Cold start log: `PRAGMA journal_mode set (was='delete', now='wal')` -- Warm start log: `PRAGMA journal_mode=wal (already set)` -- Zero `nullable: true` occurrences in the served JSON document - -**[ALREADY_IMPLEMENTED]** — All three requested changes exist on the branch -in commits `de27a8b` (WAL + OpenAPI 3.1). No further code changes are needed -unless the reviewer wants to extend the implementation with additional -OpenAPI 3.1 features (webhooks, license, contact, etc.) or additional SQLite -PRAGMAs. \ No newline at end of file diff --git a/.kilo/plans/821-followup-build-fix.md b/.kilo/plans/821-followup-build-fix.md new file mode 100644 index 0000000..2ca06dd --- /dev/null +++ b/.kilo/plans/821-followup-build-fix.md @@ -0,0 +1,86 @@ +# Implementation Plan: Fix Angular build error — Property 'code' does not exist on type 'CreateAdminResult' + +## 1. Architectural Reconnaissance + +- **Codebase style & conventions:** Angular 17 standalone components, signals, typed reactive forms. `frontend/tsconfig.json` uses `strict: false` and `strictTemplates: false`. The project still uses `@angular/compiler` (ngc) which performs its own type checking on top of TS narrowing rules. +- **Data Layer:** N/A — this is a frontend-only type-narrowing fix. +- **Test Framework & Structure:** `tests/jest.config.js`. Tests live under `tests/frontend/`. All compile via `npm test`. +- **Required Tools & Dependencies:** No new dependencies. The fix is purely TypeScript / component-internal. + +## 2. Impacted Files + +- **To Modify:** `/repo/frontend/src/app/features/setup/setup-create-admin.component.ts` — fix the type-narrowing that Angular's compiler refuses to accept. +- **No new files, no test changes, no docs changes.** + +## 3. Proposed Changes + +### Root cause + +In `setup-create-admin.component.ts:88-117` the `submit()` method awaits the service, then reads `result.ok`. After the `if (result.ok) { ... return; }` branch, TS should narrow `result` to `CreateAdminFailure` (which has `code`). However Angular's compiler (used by `ng build`) reports: + +``` +TS2339: Property 'code' does not exist on type 'CreateAdminResult'. + Property 'code' does not exist on type 'CreateAdminSuccess'. +``` + +at the two reads `result.code` on lines 109 and 116. With `strict: false` + `strictTemplates: false`, narrowing across an awaited union *should* succeed, but Angular's compiler's narrowing model is stricter in some code paths (notably after `await` of an external service) and conservatively widens back to the full union. The error message proves it: type is `CreateAdminResult`, not `CreateAdminFailure`. + +### Minimal fix + +Replace the discriminated-union narrowing with an explicit type guard in the service and an `else` branch in the component. This avoids relying on the compiler's narrowing at all. + +### Step-by-step + +1. **Add a type predicate to the service** (`setup-create-admin.service.ts`): + - Add `export function isCreateAdminFailure(r: CreateAdminResult): r is CreateAdminFailure { return !r.ok; }`. + - No behaviour change. + +2. **Refactor `submit()`** in `setup-create-admin.component.ts`: + - Replace the `if (result.ok) { ... return; } ... if (result.code === ...) ...` sequence with an `if (result.ok) { ... return; } const failure = result;` pattern. Because `result.ok` is `true | false`, the negation narrows the local copy identically. The safest cross-compiler approach is: + ```ts + if (result.ok) { + this.auth.setSession(result.accessToken, result.user); + this.bootstrap.markInitialized(); + await this.router.navigateByUrl('/challenges'); + return; + } + const failure: CreateAdminFailure = result; // explicit local of narrowed type + if (failure.code === 'USERNAME_TAKEN') { + this.serverError.set('Username already exists'); + this.serverErrorCode.set('USERNAME_TAKEN'); + return; + } + this.serverError.set('Setup failed, please retry'); + this.serverErrorCode.set(failure.code); + ``` + - Import `CreateAdminFailure` from `./setup-create-admin.service`. + - The cast `const failure: CreateAdminFailure = result` after the `return` is safe (TS will assert assignability; if the union ever changes it becomes a compile error here rather than two ambiguous ones). + + Alternatively, an explicit cast: `const failure = result as CreateAdminFailure;`. Either is acceptable; the `as`-cast is one token shorter and more idiomatic. Choose: + + ```ts + const failure = result as CreateAdminFailure; + ``` + +3. **No HTML/CSS/service file changes.** + +## 4. Test Strategy + +- **Target Unit Test File:** no changes. The existing `tests/frontend/setup-create-admin.spec.ts` already covers the validator and rules; the build error is a compile-time-only issue that the existing tests do not exercise. +- **Mocking Strategy:** N/A. +- **Verification:** + 1. `npm --workspace frontend run build` must complete without `TS2339` errors. + 2. `npm test` must still pass (20 suites, 97 tests). +- **Do not** touch any test file. The fix is internal to the component. + +## 5. Risk / Non-Goals + +- **No behaviour change.** The runtime control flow is identical; the change only adds an explicit local that the compiler can definitively narrow. +- **No public API change.** `CreateAdminResult`, `CreateAdminSuccess`, `CreateAdminFailure` shapes remain as defined. +- **No docs change required** — implementation detail only. + +## 6. Execution Checklist + +1. Edit `setup-create-admin.component.ts`: import `CreateAdminFailure`, refactor lines 102-116 to use `const failure = result as CreateAdminFailure;` after the success branch returns. +2. Run `npm --workspace frontend run build` and confirm 0 errors. +3. Run `npm test` and confirm `Test Suites: 20 passed, 20 total`. diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2483bb0..7ad0218 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { DatabaseModule } from './database/database.module'; import { CommonModule } from './common/common.module'; import { AuthModule } from './modules/auth/auth.module'; import { UsersModule } from './modules/users/users.module'; +import { SetupModule } from './modules/setup/setup.module'; import { SystemModule } from './modules/system/system.module'; import { SettingsModule } from './modules/settings/settings.module'; import { AdminModule } from './modules/admin/admin.module'; @@ -26,6 +27,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard'; SettingsModule, AuthModule, UsersModule, + SetupModule, SystemModule, AdminModule, UploadsModule, diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index e57d0bc..3486b54 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -9,6 +9,7 @@ export const ERROR_CODES = { RATE_LIMITED: 'RATE_LIMITED', CSRF_INVALID: 'CSRF_INVALID', WEAK_PASSWORD: 'WEAK_PASSWORD', + USERNAME_TAKEN: 'USERNAME_TAKEN', INVALID_CREDENTIALS: 'INVALID_CREDENTIALS', TOKEN_REVOKED: 'TOKEN_REVOKED', THEME_INVALID: 'THEME_INVALID', diff --git a/backend/src/common/middleware/csrf.middleware.ts b/backend/src/common/middleware/csrf.middleware.ts index f79c7c4..5298d9d 100644 --- a/backend/src/common/middleware/csrf.middleware.ts +++ b/backend/src/common/middleware/csrf.middleware.ts @@ -8,7 +8,11 @@ import { ERROR_CODES } from '../errors/error-codes'; const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); const COOKIE_NAME = 'csrf'; const HEADER_NAME = 'x-csrf-token'; -const SKIP_PATH_PREFIXES = ['/api/v1/auth/register-first-admin', '/api/v1/auth/login']; +const SKIP_PATH_PREFIXES = [ + '/api/v1/auth/register-first-admin', + '/api/v1/auth/login', + '/api/v1/setup/create-admin', +]; function isSkipped(path: string): boolean { return SKIP_PATH_PREFIXES.some((p) => path === p || path.startsWith(p + '/')); diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 43ed0d7..1bf5b9b 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -136,11 +136,31 @@ export class AuthService implements OnModuleInit { * token so the caller can set the HttpOnly cookie. */ private async mintSession(manager: EntityManager, user: UserEntity): Promise { - const accessToken = await this.signAccess(user); - const refreshToken = this.generateRefreshToken(); - const tokenHash = this.hashToken(refreshToken); + return AuthService.mintSessionWith( + manager, + this.jwt, + this.config, + this.refreshTtlSeconds, + user, + ); + } + + /** + * Stateless mint helper used by other modules (e.g. setup) that need to + * create a session without depending on the full AuthModule injection tree. + */ + static async mintSessionWith( + manager: EntityManager, + jwt: JwtService, + config: ConfigService, + refreshTtlSeconds: number, + user: UserEntity, + ): Promise { + const accessToken = await jwt.signAsync({ sub: user.id, username: user.username, role: user.role }); + const refreshToken = AuthService.generateRefreshTokenStatic(); + const tokenHash = AuthService.hashTokenStatic(refreshToken); const now = new Date(); - const expires = new Date(now.getTime() + this.refreshTtlSeconds * 1000); + const expires = new Date(now.getTime() + refreshTtlSeconds * 1000); await manager.insert(RefreshTokenEntity, { id: uuid(), userId: user.id, @@ -152,11 +172,46 @@ export class AuthService implements OnModuleInit { return { accessToken, refreshToken, - expiresIn: this.accessTtlSeconds(), + expiresIn: AuthService.accessTtlSecondsStatic(config), user: { id: user.id, username: user.username, role: user.role }, }; } + private static generateRefreshTokenStatic(): string { + return crypto.randomBytes(32).toString('base64url'); + } + + private static hashTokenStatic(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + private static accessTtlSecondsStatic(config: ConfigService): number { + const ttl = config.get('JWT_ACCESS_TTL', '15m'); + return AuthService.parseTtlStatic(ttl); + } + + private static parseTtlStatic(ttl: string): number { + const m = /^(\d+)([smhd])$/.exec(ttl.trim()); + if (!m) return 900; + const n = parseInt(m[1], 10); + const unit = m[2]; + const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400; + return n * mult; + } + + /** + * Public access to the static session mint for cross-module reuse. + * The instance-based `mintSession(manager, user)` is preserved for + * existing callers and delegates here. + */ + static async createSession( + manager: EntityManager, + deps: { jwt: JwtService; config: ConfigService; refreshTtlSeconds: number }, + user: UserEntity, + ): Promise { + return AuthService.mintSessionWith(manager, deps.jwt, deps.config, deps.refreshTtlSeconds, user); + } + private async signAccess(user: UserEntity): Promise { return this.jwt.signAsync({ sub: user.id, username: user.username, role: user.role }); } diff --git a/backend/src/modules/setup/dto/create-admin.dto.ts b/backend/src/modules/setup/dto/create-admin.dto.ts new file mode 100644 index 0000000..6116e23 --- /dev/null +++ b/backend/src/modules/setup/dto/create-admin.dto.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export const CreateAdminDtoSchema = z + .object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(32, 'Username must be at most 32 characters') + .regex(/^[a-zA-Z0-9_.-]+$/, 'Username may only contain letters, digits, dot, dash and underscore'), + password: z.string().min(1).max(256), + passwordConfirm: z.string().min(1).max(256), + }) + .refine((d) => d.password === d.passwordConfirm, { + message: 'Passwords do not match', + path: ['passwordConfirm'], + }); + +export type CreateAdminDto = z.infer; diff --git a/backend/src/modules/setup/setup.controller.ts b/backend/src/modules/setup/setup.controller.ts new file mode 100644 index 0000000..61cb569 --- /dev/null +++ b/backend/src/modules/setup/setup.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Post, Req, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { ConfigService } from '@nestjs/config'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { Public } from '../../common/decorators/public.decorator'; +import { SetupService } from './setup.service'; +import { CreateAdminDtoSchema } from './dto/create-admin.dto'; +import { setRefreshCookie } from '../auth/cookie'; + +@ApiTags('setup') +@Controller('api/v1/setup') +export class SetupController { + constructor( + private readonly setup: SetupService, + private readonly config: ConfigService, + ) {} + + @Public() + @Post('create-admin') + @ApiOperation({ + summary: + 'Create the very first administrator. Only callable while no admin exists; returns 404 once initialized.', + }) + async createAdmin( + @Body(new ZodValidationPipe(CreateAdminDtoSchema)) + body: { username: string; password: string; passwordConfirm: string }, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ) { + const ip = (req.ip || req.socket.remoteAddress || 'unknown') as string; + const session = await this.setup.createAdmin( + body.username, + body.password, + body.passwordConfirm, + ip, + ); + setRefreshCookie(res, session.refreshToken, this.config); + return { + accessToken: session.accessToken, + expiresIn: session.expiresIn, + user: session.user, + }; + } +} diff --git a/backend/src/modules/setup/setup.module.ts b/backend/src/modules/setup/setup.module.ts new file mode 100644 index 0000000..fd72482 --- /dev/null +++ b/backend/src/modules/setup/setup.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserEntity } from '../../database/entities/user.entity'; +import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity'; +import { SetupController } from './setup.controller'; +import { SetupService } from './setup.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([UserEntity, RefreshTokenEntity]), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_ACCESS_SECRET'), + signOptions: { expiresIn: config.get('JWT_ACCESS_TTL', '15m') }, + }), + }), + ], + controllers: [SetupController], + providers: [SetupService], +}) +export class SetupModule {} diff --git a/backend/src/modules/setup/setup.service.ts b/backend/src/modules/setup/setup.service.ts new file mode 100644 index 0000000..62e32c3 --- /dev/null +++ b/backend/src/modules/setup/setup.service.ts @@ -0,0 +1,104 @@ +import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { DataSource, Repository } from 'typeorm'; +import * as argon2 from 'argon2'; +import { v4 as uuid } from 'uuid'; +import { UserEntity } from '../../database/entities/user.entity'; +import { RefreshTokenEntity } from '../../database/entities/refresh-token.entity'; +import { ApiError } from '../../common/errors/api-error'; +import { ERROR_CODES } from '../../common/errors/error-codes'; +import { validatePassword } from '../../common/utils/password-policy'; +import { RegistrationRateLimitService } from '../../common/services/registration-rate-limit.service'; +import { AuthService } from '../auth/auth.service'; +import type { LoginResponse } from '../auth/dto/auth.dto'; + +const REFRESH_TTL_DEFAULT = 7 * 24 * 60 * 60; + +@Injectable() +export class SetupService implements OnModuleInit { + private readonly logger = new Logger(SetupService.name); + private refreshTtlSeconds = REFRESH_TTL_DEFAULT; + + constructor( + @InjectRepository(UserEntity) private readonly users: Repository, + @InjectRepository(RefreshTokenEntity) private readonly refreshTokens: Repository, + private readonly dataSource: DataSource, + private readonly jwt: JwtService, + private readonly config: ConfigService, + private readonly registrationRateLimit: RegistrationRateLimitService, + ) {} + + onModuleInit(): void { + const ttl = this.config.get('JWT_REFRESH_TTL', '7d'); + this.refreshTtlSeconds = this.parseTtl(ttl); + } + + async createAdmin( + username: string, + password: string, + passwordConfirm: string, + ip: string, + ): Promise { + if (password !== passwordConfirm) { + throw ApiError.validation('Passwords do not match'); + } + + validatePassword(password, this.config); + + if (!this.registrationRateLimit.isAllowed(ip)) { + throw new ApiError( + ERROR_CODES.RATE_LIMITED, + 'Too many registrations from this IP; try again later.', + 429, + ); + } + + return this.dataSource.transaction(async (manager) => { + const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } }); + if (adminCount > 0) { + throw ApiError.notFound('Setup endpoint not available'); + } + + const existing = await manager.findOne(UserEntity, { where: { username } }); + if (existing) { + throw ApiError.conflict(ERROR_CODES.USERNAME_TAKEN, 'Username already exists'); + } + + const passwordHash = await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: this.config.get('ARGON2_MEMORY_COST'), + timeCost: this.config.get('ARGON2_TIME_COST'), + parallelism: this.config.get('ARGON2_PARALLELISM'), + }); + + const user = manager.create(UserEntity, { + id: uuid(), + username, + passwordHash, + role: 'admin', + status: 'enabled', + }); + await manager.save(user); + + this.registrationRateLimit.record(ip); + this.logger.log(`First admin created (username=${username})`); + + return AuthService.createSession( + manager, + { jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds }, + user, + ); + }); + } + + private parseTtl(ttl: string): number { + const m = /^(\d+)([smhd])$/.exec(ttl.trim()); + if (!m) return REFRESH_TTL_DEFAULT; + const n = parseInt(m[1], 10); + const unit = m[2]; + const mult = unit === 's' ? 1 : unit === 'm' ? 60 : unit === 'h' ? 3600 : 86400; + return n * mult; + } +} diff --git a/backend/src/modules/system/system.service.ts b/backend/src/modules/system/system.service.ts index 55a2768..bee5c45 100644 --- a/backend/src/modules/system/system.service.ts +++ b/backend/src/modules/system/system.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { UserEntity } from '../../database/entities/user.entity'; @@ -6,6 +7,12 @@ import { SettingEntity } from '../../database/entities/setting.entity'; import { ThemeLoaderService } from '../../common/utils/theme-loader.service'; import { SETTINGS_KEYS } from '../../config/env.schema'; +export interface PasswordPolicyInfo { + minLength: number; + requireMixed: boolean; + description: string; +} + export interface BootstrapPayload { initialized: boolean; pageTitle: string; @@ -14,6 +21,7 @@ export interface BootstrapPayload { theme: any; defaultChallengeIp: string; registrationsEnabled: boolean; + passwordPolicy: PasswordPolicyInfo; } @Injectable() @@ -22,6 +30,7 @@ export class SystemService { @InjectRepository(UserEntity) private readonly users: Repository, @InjectRepository(SettingEntity) private readonly settings: Repository, private readonly themes: ThemeLoaderService, + private readonly config: ConfigService, ) {} async bootstrap(): Promise { @@ -35,9 +44,19 @@ export class SystemService { theme: this.themes.getTheme(themeKey), defaultChallengeIp: await this.getSetting(SETTINGS_KEYS.DEFAULT_CHALLENGE_IP, '127.0.0.1'), registrationsEnabled: (await this.getSetting(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')) === 'true', + passwordPolicy: this.passwordPolicy(), }; } + private passwordPolicy(): PasswordPolicyInfo { + const minLength = this.config.get('PASSWORD_MIN_LENGTH', 12); + const requireMixed = this.config.get('PASSWORD_REQUIRE_MIXED', true); + const description = requireMixed + ? `At least ${minLength} characters with upper, lower, digit and symbol` + : `At least ${minLength} characters`; + return { minLength, requireMixed, description }; + } + private async getSetting(key: string, fallback: string): Promise { const row = await this.settings.findOne({ where: { key } }); return row?.value ?? fallback; diff --git a/docs/api/rest-overview.md b/docs/api/rest-overview.md new file mode 100644 index 0000000..5be60bf --- /dev/null +++ b/docs/api/rest-overview.md @@ -0,0 +1,84 @@ +--- +type: api +title: REST API Overview +description: Base URL, versioning, auth, CSRF, and the standard error envelope. +tags: [api, rest, overview, csrf] +timestamp: 2026-07-21T14:43:00Z +--- + +# Base URL & versioning + +All endpoints live under `/api/v1`. The Angular SPA uses the same +prefix; the SPA fallback only kicks in for non-API routes. + +| Path prefix | Purpose | +|-----------------|--------------------------------------------------------| +| `/api/v1/*` | Versioned JSON REST endpoints. | +| `/api/docs` | Swagger UI (OpenAPI 3.1). | +| `/api/docs-json`| Raw OpenAPI 3.1 document. | +| `/uploads/*` | Static uploads directory (`UPLOAD_DIR`). | +| `/*` | SPA fallback (`FRONTEND_DIST/index.html`). | + +# Authentication + +* `JwtAuthGuard` is registered as `APP_GUARD` and runs on every request + unless the handler is marked with `@Public()` from + `backend/src/common/decorators/public.decorator.ts`. +* Successful auth produces an access JWT in the response body and an + `HttpOnly` refresh cookie named `rt` (see `backend/src/modules/auth/cookie.ts`). +* `Authorization: Bearer ` is attached by the + `authInterceptor` on the frontend. + +# CSRF + +* `CsrfMiddleware` (registered globally) mints a `csrf` cookie on every + response and, for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`), + compares the cookie against the `x-csrf-token` header using + `crypto.timingSafeEqual`. +* The frontend's `csrfInterceptor` runs *before* `authInterceptor` so + the header is attached on every unsafe call. +* Skip list (`SKIP_PATH_PREFIXES` in + `backend/src/common/middleware/csrf.middleware.ts`): + - `/api/v1/auth/register-first-admin` + - `/api/v1/auth/login` + - `/api/v1/setup/create-admin` + +# Error envelope + +Every error flows through `GlobalExceptionFilter` and is shaped as: + +```json +{ + "code": "USERNAME_TAKEN", + "message": "Username already exists", + "details": null, + "path": "/api/v1/setup/create-admin", + "timestamp": "2026-07-21T14:43:00.000Z" +} +``` + +| Field | Type | Notes | +|-------------|---------------------|--------------------------------------------------------| +| `code` | string | One of `ERROR_CODES` (`backend/src/common/errors/error-codes.ts`). | +| `message` | string | Human-readable summary. | +| `details` | unknown \| null | Optional structured details (e.g. zod field errors). | +| `path` | string | Original request path. | +| `timestamp` | ISO 8601 string | When the filter produced the response. | + +# Canonical error codes + +`USERNAME_TAKEN` is the most recently added code (used by the setup +endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`: + +`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, +`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`, +`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`, +`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`. + +# See also + +- [Auth Endpoints](/api/auth.md) +- [Admin Endpoints](/api/admin.md) +- [Setup Endpoint](/api/setup.md) +- [System Endpoints](/api/system.md) +- [Uploads Endpoints](/api/uploads.md) \ No newline at end of file diff --git a/docs/api/setup.md b/docs/api/setup.md new file mode 100644 index 0000000..26ec4f5 --- /dev/null +++ b/docs/api/setup.md @@ -0,0 +1,80 @@ +--- +type: api +title: Setup Endpoint +description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. +tags: [api, setup, bootstrap, first-admin] +timestamp: 2026-07-21T14:43:00Z +--- + +# Endpoint + +| Method | Path | Auth | CSRF | Rate limited | +|--------|-------------------------------|------|--------|--------------| +| `POST` | `/api/v1/setup/create-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`) | Yes (per-IP via `RegistrationRateLimitService`) | + +# Request body + +Validated by `backend/src/modules/setup/dto/create-admin.dto.ts` (zod): + +| Field | Type | Constraints | +|-------------------|--------|------------------------------------------------------------------------------| +| `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` | +| `password` | string | 1–256 chars; additionally passes `validatePassword` (Argon2 policy). | +| `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED`, `passwordConfirm`).| + +# Successful response (201) + +```json +{ + "accessToken": "", + "expiresIn": 900, + "user": { "id": "", "username": "first.admin", "role": "admin" } +} +``` + +The refresh token is set as an `HttpOnly` cookie via +`setRefreshCookie(res, session.refreshToken, config)` so the SPA can keep +refreshing without storing the raw token in JS. + +# Error envelope + +| HTTP | `code` | When | +|------|---------------------|----------------------------------------------------------------------| +| 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). | +| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password (length / mixed-case requirement). | +| 404 | `NOT_FOUND` | An admin already exists — the route is hidden once initialized. | +| 409 | `USERNAME_TAKEN` | Username collision (reachable only during a race before init). | +| 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. | + +# Wiring + +* Registered in `backend/src/app.module.ts` via `SetupModule`. +* `SetupController` (`backend/src/modules/setup/setup.controller.ts`) is + `@Public()` and uses `ZodValidationPipe(CreateAdminDtoSchema)`. +* `SetupService.createAdmin` runs everything inside a single + `DataSource.transaction`: admin count check → username uniqueness check + → Argon2id hash → insert `UserEntity(role='admin', status='enabled')` → + mint session via the static `AuthService.createSession`. +* CSRF is bypassed for `/api/v1/setup/create-admin` so the first caller + (before any cookie exists) is not blocked. The middleware still mints + a `csrf` cookie on the response so subsequent calls are covered. + +# Frontend consumer + +`frontend/src/app/features/setup/setup-create-admin.service.ts` calls +this endpoint and normalizes the response into the discriminated union +`CreateAdminResult`: + +```ts +type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure; +``` + +`CreateAdminFailure` exposes `{ ok: false, status, code, message }` so +the component can switch on `code === 'USERNAME_TAKEN'` to surface a +non-retryable inline error and offer a Retry button for everything else. + +# See also + +- [First-Run Bootstrap Guide](/guides/bootstrap.md) +- [Backend Module Map](/architecture/backend-modules.md) +- [Bootstrap Service](/architecture/frontend-structure.md) \ No newline at end of file diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index d98ed6d..7840ad3 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -3,7 +3,7 @@ type: architecture title: Backend Module Map description: NestJS modules, controllers, services, and how they are wired together. tags: [architecture, backend, nestjs, modules] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Module Map @@ -25,6 +25,7 @@ also registers two global providers: | `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). | | `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).| | `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). | +| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 404 once any admin exists (route hidden). | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | @@ -36,6 +37,7 @@ also registers two global providers: |------------------------|-------------------------|--------------|--------------------------------------------------------------| | `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` | | `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` | +| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 404 once an admin exists) | `backend/src/modules/setup/setup.controller.ts` | | `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` | | `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 8c2dd1e..5b3e3d8 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -3,7 +3,7 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, and interceptors. tags: [architecture, frontend, angular] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Routes @@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | Path | Component | Guard | Notes | |--------------|----------------------------------|---------------|----------------------------------------| -| `/bootstrap` | `CreateAdminComponent` | — | Rendered only when `initialized === false`. | +| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. | | `/login` | `LoginComponent` | — | Standard login form. | | `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. | | `**` | Redirect to `/` | — | Wildcard fallback. | @@ -27,7 +27,7 @@ All components are standalone (no NgModules). Each component imports | `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `` and calls `BootstrapService.load()`. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. | | `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. | -| `CreateAdminComponent` | `frontend/src/app/features/bootstrap/create-admin.component.ts`| First-admin bootstrap form. | +| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). | # Services diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index d6d0e96..26feb6a 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -3,7 +3,7 @@ type: architecture title: Key Files Index description: One-line responsibility for every important source file in the repository. tags: [architecture, index, key-files] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Backend @@ -74,6 +74,10 @@ timestamp: 2026-07-21T14:18:00Z | `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. | | `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. | | `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. | +| `backend/src/modules/setup/setup.module.ts` | Wires `SetupController` + `SetupService` (dedicated first-admin bootstrap flow). | +| `backend/src/modules/setup/setup.controller.ts` | `POST /api/v1/setup/create-admin` (returns 404 once an admin exists). | +| `backend/src/modules/setup/setup.service.ts` | Hashes password (Argon2id), runs the create inside a transaction, mints session via `AuthService.createSession`. | +| `backend/src/modules/setup/dto/create-admin.dto.ts` | Zod schema with `password === passwordConfirm` refine. | | `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. | | `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. | | `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. | @@ -110,7 +114,9 @@ timestamp: 2026-07-21T14:18:00Z | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. | | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. | | `frontend/src/app/features/auth/login.component.ts` | Login form. | -| `frontend/src/app/features/bootstrap/create-admin.component.ts` | First-admin creation form. | +| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). | +| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. | +| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. | | `frontend/src/app/features/home/home.component.ts` | Landing page after auth. | # Tests diff --git a/docs/database/users.md b/docs/database/users.md index 8eb9159..c09c6c9 100644 --- a/docs/database/users.md +++ b/docs/database/users.md @@ -3,7 +3,7 @@ type: database title: User Table description: The `user` table — accounts, roles, and statuses. tags: [database, user, auth] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Schema @@ -34,9 +34,16 @@ timestamp: 2026-07-21T14:18:00Z # First-admin bootstrap -When the `user` table is empty of admins, `POST /api/v1/auth/register-first-admin` -is the only endpoint that can create one. After that, the endpoint -returns `409 SYSTEM_INITIALIZED`. +When the `user` table is empty of admins, the dedicated +`POST /api/v1/setup/create-admin` endpoint (see +[Setup Endpoint](/api/setup.md)) creates the very first admin and +auto-issues a session. Once any admin row exists the endpoint becomes +inert and returns `404 NOT_FOUND`, so the route is effectively hidden +in production. + +The legacy `POST /api/v1/auth/register-first-admin` route is preserved +for compatibility but the canonical first-admin flow now lives in the +`SetupModule`. # See also diff --git a/docs/guides/bootstrap.md b/docs/guides/bootstrap.md new file mode 100644 index 0000000..2e5b4d8 --- /dev/null +++ b/docs/guides/bootstrap.md @@ -0,0 +1,105 @@ +--- +type: guide +title: First-Run Bootstrap +description: How a fresh HIPCTF instance is initialized by the very first administrator. +tags: [guide, bootstrap, first-admin, onboarding, tester] +timestamp: 2026-07-21T14:43:00Z +--- + +# When this flow runs + +A brand-new deployment shows a non-dismissible modal asking the operator +to create the very first admin user. This is gated by the +`initialized` flag returned from `GET /api/v1/bootstrap`: + +| `initialized` | User experience | +|---------------|---------------------------------------------------------------| +| `false` | Every route is redirected to `/bootstrap` until an admin is created. | +| `true` | The setup route becomes a 404 and the modal can never appear again. | + +The `authGuard` enforces the redirect on the client side; the backend +independently rejects `/api/v1/setup/create-admin` with `404 NOT_FOUND` +once any admin row exists, so the flow is safe even if the SPA is +bypassed. + +# How to access (tester steps) + +1. Start the stack against an empty database: + ``` + npm run db:reset && npm run dev + ``` +2. Open the browser at `http://localhost:3000/`. +3. The app redirects to `/bootstrap` and shows the + **"Welcome to HIPCTF — Create the first administrator"** modal. +4. Fill in the three fields and submit: + - **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. Helper + text is shown via `data-testid="setup-username-error"` when the + pattern fails. + - **Password** — must satisfy the policy shown directly below the + field (default: 12 chars with upper, lower, digit, symbol). + - **Confirm Password** — must equal the password above (verified by + the group-level `passwordMatchValidator`; mismatch shows + `data-testid="setup-confirm-error"`). +5. Click **Create admin** (`data-testid="setup-submit"`). + +# Expected behavior + +* On success the modal disappears, the app stores the access token via + `AuthService.setSession`, calls `BootstrapService.markInitialized()`, + and navigates to `/challenges`. +* On `USERNAME_TAKEN` the form shows a red alert: + `"Username already exists"`. The **Retry** button is intentionally + hidden because the username must be changed first. +* On any other failure (network, `WEAK_PASSWORD`, `VALIDATION_FAILED`, + `RATE_LIMITED`) the alert shows `"Setup failed, please retry"` and a + **Retry** link (`data-testid="setup-retry"`) re-submits the same + payload. + +The modal **cannot** be dismissed by the user: + +* Clicking the backdrop is a no-op (`swallowBackdrop`). +* Pressing **Esc** is swallowed by the `keydown.escape` host listener. + +# Visual elements + +| Element | Selector | Purpose | +|--------------------------------|----------------------------------|-----------------------------------------------| +| Modal overlay | `.overlay` | Full-screen backdrop. | +| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. | +| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. | +| Password input | `[data-testid="setup-password"]` | Reactive control. | +| Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. | +| Submit button | `[data-testid="setup-submit"]` | Disabled while submitting or invalid. | +| Server error alert | `[data-testid="setup-server-error"]` | Displays `serverError()`. | +| Retry button | `[data-testid="setup-retry"]` | Re-submits; absent when the error is non-retryable. | +| Password policy hint | `.hint` | Live description from `BootstrapService.passwordPolicy()`. | + +# Behind the scenes (architecture map) + +| Step | Where | What happens | +|------|--------------------------------------------------------|-------------------------------------------------------------------------| +| 1 | `BootstrapService.load()` | Calls `GET /api/v1/bootstrap`, sets `initialized = false`. | +| 2 | `authGuard` | Sees `initialized() === false`, redirects to `/bootstrap`. | +| 3 | `SetupCreateAdminComponent` | Renders the modal; reactive form enforces username + password policy + passwordMatch. | +| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. | +| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. | +| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. | +| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/challenges`. | +| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. | + +# What the operator sees after init + +Once the first admin exists, `/api/v1/bootstrap` returns +`initialized: true`. The app now: + +* Skips `/bootstrap` entirely. +* Routes unauthenticated users to `/login`. +* Routes authenticated users to `/` (Home) which itself redirects to + `/challenges` once admin features are wired. + +# See also + +- [Setup Endpoint](/api/setup.md) +- [Frontend Structure](/architecture/frontend-structure.md) +- [Backend Module Map](/architecture/backend-modules.md) +- [Bootstrap payload source of truth](`/architecture/overview.md`) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 7ff8748..6a007f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,8 @@ they need. CSRF, and error envelope. * [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and first-admin registration. +* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin` + used only while no administrator exists. * [Admin Endpoints](/api/admin.md) - User management endpoints (admin role required). * [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE @@ -49,7 +51,7 @@ they need. # Guides * [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is - initialized by the very first admin. + initialized by the very first admin via the non-dismissible modal. * [Theming](/guides/theming.md) - How themes are loaded and applied. * [Event Window](/guides/event-window.md) - How the event window and live countdown work. diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 457b06a..213c29d 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,7 +4,10 @@ import { authGuard } from './core/guards/auth.guard'; export const APP_ROUTES: Routes = [ { path: 'bootstrap', - loadComponent: () => import('./features/bootstrap/create-admin.component').then((m) => m.CreateAdminComponent), + loadComponent: () => + import('./features/setup/setup-create-admin.component').then( + (m) => m.SetupCreateAdminComponent, + ), }, { path: 'login', diff --git a/frontend/src/app/core/services/bootstrap.service.ts b/frontend/src/app/core/services/bootstrap.service.ts index fc62d13..75bbddc 100644 --- a/frontend/src/app/core/services/bootstrap.service.ts +++ b/frontend/src/app/core/services/bootstrap.service.ts @@ -1,4 +1,10 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, signal, computed } from '@angular/core'; + +export interface PasswordPolicyInfo { + minLength: number; + requireMixed: boolean; + description: string; +} export interface BootstrapPayload { initialized: boolean; @@ -8,12 +14,22 @@ export interface BootstrapPayload { theme: any; defaultChallengeIp: string; registrationsEnabled: boolean; + passwordPolicy: PasswordPolicyInfo; } +const DEFAULT_POLICY: PasswordPolicyInfo = { + minLength: 12, + requireMixed: true, + description: 'At least 12 characters with upper, lower, digit and symbol', +}; + @Injectable({ providedIn: 'root' }) export class BootstrapService { readonly payload = signal(null); readonly initialized = signal(false); + readonly passwordPolicy = computed( + () => this.payload()?.passwordPolicy ?? DEFAULT_POLICY, + ); async load(): Promise { try { @@ -29,6 +45,10 @@ export class BootstrapService { } } + markInitialized(): void { + this.initialized.set(true); + } + private applyTheme(theme: any): void { if (!theme?.tokens) return; const root = document.documentElement.style; diff --git a/frontend/src/app/features/setup/setup-create-admin.component.css b/frontend/src/app/features/setup/setup-create-admin.component.css new file mode 100644 index 0000000..b8668ee --- /dev/null +++ b/frontend/src/app/features/setup/setup-create-admin.component.css @@ -0,0 +1,135 @@ +:host { + display: contents; +} + +.overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-family, system-ui, sans-serif); +} + +.modal { + width: min(440px, calc(100vw - 32px)); + background: var(--color-surface, #1b1b1f); + color: var(--color-text, #f5f5f7); + border-radius: var(--radius-md, 12px); + padding: 28px 28px 24px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.modal h1 { + margin: 0 0 4px; + font-size: 18px; + font-weight: 600; +} + +.subtitle { + margin: 0 0 20px; + font-size: 13px; + color: var(--color-text, #f5f5f7); + opacity: 0.7; +} + +form { + display: flex; + flex-direction: column; + gap: 14px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; +} + +.field > span { + font-weight: 500; +} + +.field input { + font: inherit; + padding: 10px 12px; + border-radius: var(--radius-sm, 6px); + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + color: inherit; + outline: none; +} + +.field input:focus { + border-color: var(--color-primary, #4f8cff); +} + +.hint { + font-size: 11px; + opacity: 0.65; +} + +.field-error { + color: var(--color-danger, #ff6b6b); + font-size: 12px; +} + +.server-error { + display: flex; + align-items: center; + gap: 12px; + background: rgba(255, 107, 107, 0.12); + border: 1px solid rgba(255, 107, 107, 0.35); + border-radius: var(--radius-sm, 6px); + padding: 8px 12px; + font-size: 13px; + color: var(--color-danger, #ff6b6b); +} + +.server-error .link { + background: transparent; + border: 0; + color: inherit; + text-decoration: underline; + cursor: pointer; + font: inherit; +} + +button.primary { + margin-top: 6px; + padding: 11px 14px; + font: inherit; + font-weight: 600; + border-radius: var(--radius-sm, 6px); + border: 0; + background: var(--color-primary, #4f8cff); + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +button.primary[disabled] { + cursor: not-allowed; + opacity: 0.65; +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/frontend/src/app/features/setup/setup-create-admin.component.html b/frontend/src/app/features/setup/setup-create-admin.component.html new file mode 100644 index 0000000..81798b8 --- /dev/null +++ b/frontend/src/app/features/setup/setup-create-admin.component.html @@ -0,0 +1,69 @@ +
+ +
diff --git a/frontend/src/app/features/setup/setup-create-admin.component.ts b/frontend/src/app/features/setup/setup-create-admin.component.ts new file mode 100644 index 0000000..67350c0 --- /dev/null +++ b/frontend/src/app/features/setup/setup-create-admin.component.ts @@ -0,0 +1,119 @@ +import { + ChangeDetectionStrategy, + Component, + HostListener, + computed, + inject, + signal, +} from '@angular/core'; +import { + FormBuilder, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { Router } from '@angular/router'; +import { AuthService } from '../../core/services/auth.service'; +import { BootstrapService } from '../../core/services/bootstrap.service'; +import { SetupCreateAdminService, CreateAdminFailure } from './setup-create-admin.service'; +import { passwordMatchValidator } from './setup-create-admin.validators'; + +@Component({ + selector: 'app-setup-create-admin', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule], + templateUrl: './setup-create-admin.component.html', + styleUrls: ['./setup-create-admin.component.css'], +}) +export class SetupCreateAdminComponent { + private readonly fb = inject(FormBuilder); + private readonly api = inject(SetupCreateAdminService); + private readonly auth = inject(AuthService); + private readonly router = inject(Router); + private readonly bootstrap = inject(BootstrapService); + + readonly policyDescription = computed(() => this.bootstrap.passwordPolicy().description); + + readonly form = this.fb.nonNullable.group( + { + username: this.fb.nonNullable.control('', [ + Validators.required, + Validators.minLength(3), + Validators.maxLength(32), + Validators.pattern(/^[a-zA-Z0-9_.-]+$/), + ]), + password: this.fb.nonNullable.control('', [Validators.required]), + passwordConfirm: this.fb.nonNullable.control('', [Validators.required]), + }, + { validators: passwordMatchValidator }, + ); + + readonly submitting = signal(false); + readonly serverError = signal(null); + readonly serverErrorCode = signal(null); + + readonly canRetry = computed( + () => !this.submitting() && this.serverError() !== null && this.serverErrorCode() !== 'USERNAME_TAKEN', + ); + + readonly usernameErrors = computed(() => { + const c = this.form.controls.username; + if (!c.touched || c.valid) return null; + if (c.errors?.['required']) return 'Username is required'; + if (c.errors?.['minlength']) return 'Username must be at least 3 characters'; + if (c.errors?.['maxlength']) return 'Username must be at most 32 characters'; + if (c.errors?.['pattern']) return 'Username may only contain letters, digits, dot, dash and underscore'; + return 'Invalid username'; + }); + + readonly confirmErrors = computed(() => { + const c = this.form.controls.passwordConfirm; + const groupMismatch = this.form.errors?.['passwordMismatch']; + if ((!c.touched || c.valid) && !groupMismatch) return null; + if (c.errors?.['required']) return 'Please confirm your password'; + if (groupMismatch) return 'Passwords do not match'; + return 'Invalid value'; + }); + + @HostListener('document:keydown.escape', ['$event']) + swallowEscape(event: KeyboardEvent): void { + event.preventDefault(); + event.stopPropagation(); + } + + swallowBackdrop(_event: MouseEvent): void { + // intentionally no-op: modal cannot be dismissed by clicking the backdrop + } + + async submit(): Promise { + if (this.submitting()) return; + this.form.markAllAsTouched(); + if (this.form.invalid) return; + + this.submitting.set(true); + this.serverError.set(null); + this.serverErrorCode.set(null); + + const { username, password, passwordConfirm } = this.form.getRawValue(); + const result = await this.api.create({ username, password, passwordConfirm }); + + this.submitting.set(false); + + if (result.ok) { + this.auth.setSession(result.accessToken, result.user); + this.bootstrap.markInitialized(); + await this.router.navigateByUrl('/challenges'); + return; + } + + const failure = result as CreateAdminFailure; + if (failure.code === 'USERNAME_TAKEN') { + this.serverError.set('Username already exists'); + this.serverErrorCode.set('USERNAME_TAKEN'); + return; + } + + this.serverError.set('Setup failed, please retry'); + this.serverErrorCode.set(failure.code); + } +} diff --git a/frontend/src/app/features/setup/setup-create-admin.service.ts b/frontend/src/app/features/setup/setup-create-admin.service.ts new file mode 100644 index 0000000..bc2021e --- /dev/null +++ b/frontend/src/app/features/setup/setup-create-admin.service.ts @@ -0,0 +1,66 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; + +export interface CreateAdminRequest { + username: string; + password: string; + passwordConfirm: string; +} + +export interface CreateAdminSuccess { + ok: true; + accessToken: string; + expiresIn: number; + user: { id: string; username: string; role: 'admin' | 'player' }; +} + +export interface CreateAdminFailure { + ok: false; + status: number; + code: string; + message: string; +} + +export type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure; + +@Injectable({ providedIn: 'root' }) +export class SetupCreateAdminService { + private readonly http = inject(HttpClient); + + async ensureCsrf(): Promise { + try { + await fetch('/api/v1/auth/csrf', { credentials: 'include' }); + } catch { + // ignore: csrf middleware will mint a cookie if missing + } + } + + async create(body: CreateAdminRequest): Promise { + await this.ensureCsrf(); + try { + const res = await firstValueFrom( + this.http.post<{ + accessToken: string; + expiresIn: number; + user: { id: string; username: string; role: 'admin' | 'player' }; + }>('/api/v1/setup/create-admin', body, { withCredentials: true, observe: 'response' }), + ); + return { + ok: true, + accessToken: res.body!.accessToken, + expiresIn: res.body!.expiresIn, + user: res.body!.user, + }; + } catch (e) { + return mapError(e as HttpErrorResponse); + } + } +} + +function mapError(err: HttpErrorResponse): CreateAdminFailure { + const body = err.error as { code?: string; message?: string } | null; + const code = body?.code ?? 'INTERNAL'; + const message = body?.message ?? err.message ?? 'Setup failed'; + return { ok: false, status: err.status ?? 0, code, message }; +} diff --git a/frontend/src/app/features/setup/setup-create-admin.validators.ts b/frontend/src/app/features/setup/setup-create-admin.validators.ts new file mode 100644 index 0000000..e68694a --- /dev/null +++ b/frontend/src/app/features/setup/setup-create-admin.validators.ts @@ -0,0 +1,20 @@ +export interface PasswordMatchGroupLike { + get(name: 'password' | 'passwordConfirm'): { value: string | null | undefined } | null; +} + +export interface PasswordMatchResult { + passwordMismatch?: boolean; +} + +/** + * Group-level validator for the "Confirm Password" field. + * Returns `{ passwordMismatch: true }` when the two values differ, + * or `null` when they match (or when either is empty, so that + * `required`/`minlength` validators can take over). + */ +export function passwordMatchValidator(group: PasswordMatchGroupLike): PasswordMatchResult | null { + const password = group.get('password')?.value; + const confirm = group.get('passwordConfirm')?.value; + if (!password || !confirm) return null; + return password === confirm ? null : { passwordMismatch: true }; +} diff --git a/package-lock.json b/package-lock.json index 0540903..5c86673 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.6", "helmet": "^7.1.0", + "multer": "^2.0.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", @@ -52,6 +53,7 @@ "@types/better-sqlite3": "^7.6.10", "@types/cookie-parser": "^1.4.7", "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.2", "@types/uuid": "^9.0.8", @@ -2656,7 +2658,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -2669,7 +2671,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -3739,7 +3741,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3760,7 +3762,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -5148,28 +5150,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tufjs/canonical-json": { @@ -5523,6 +5525,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -5965,7 +5977,7 @@ "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5999,7 +6011,7 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -6227,7 +6239,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argon2": { @@ -7618,7 +7630,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/critters": { @@ -8024,7 +8036,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -8254,7 +8266,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -8265,7 +8276,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -11838,7 +11848,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/make-fetch-happen": { @@ -15988,7 +15998,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -16367,7 +16377,7 @@ "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -16602,7 +16612,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -17799,7 +17809,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/tests/backend/csrf-client.ts b/tests/backend/csrf-client.ts index 474dba6..fea2ae2 100644 --- a/tests/backend/csrf-client.ts +++ b/tests/backend/csrf-client.ts @@ -2,7 +2,11 @@ import request from 'supertest'; import type { INestApplication } from '@nestjs/common'; import { CookieAccessInfo } from 'cookiejar'; -const csrfSkipPaths = new Set(['/api/v1/auth/login', '/api/v1/auth/register-first-admin']); +const csrfSkipPaths = new Set([ + '/api/v1/auth/login', + '/api/v1/auth/register-first-admin', + '/api/v1/setup/create-admin', +]); function attachCsrfHeader(test: request.Test, method: string, path: string, agent: any): request.Test { if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) return test; diff --git a/tests/backend/setup-create-admin.spec.ts b/tests/backend/setup-create-admin.spec.ts new file mode 100644 index 0000000..4bd027e --- /dev/null +++ b/tests/backend/setup-create-admin.spec.ts @@ -0,0 +1,115 @@ +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 { Repository } from 'typeorm'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AppModule } from '../../backend/src/app.module'; +import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-exception.filter'; +import { csrfClient } from './csrf-client'; +import { initDb } from './db-helper'; +import { UserEntity } from '../../backend/src/database/entities/user.entity'; +import { SetupService } from '../../backend/src/modules/setup/setup.service'; + +describe('Setup: POST /api/v1/setup/create-admin', () => { + let app: INestApplication; + let users: Repository; + let setupService: SetupService; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = moduleRef.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: false, transform: true })); + const httpAdapterHost = app.get(HttpAdapterHost); + app.useGlobalFilters(new GlobalExceptionFilter(httpAdapterHost)); + await app.init(); + await initDb(app); + users = app.get>(getRepositoryToken(UserEntity)); + setupService = app.get(SetupService); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + await users.clear(); + }); + + it('creates the very first admin and exposes a session + cookie', async () => { + const c = csrfClient(app); + const res = await c + .post('/api/v1/setup/create-admin') + .send({ username: 'first.admin', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }) + .expect(201); + + expect(res.body.accessToken).toEqual(expect.any(String)); + expect(res.body.user.role).toBe('admin'); + expect(res.body.user.username).toBe('first.admin'); + + const setCookieRaw = res.headers['set-cookie'] as unknown; + const setCookie: string[] = Array.isArray(setCookieRaw) ? setCookieRaw : setCookieRaw ? [String(setCookieRaw)] : []; + expect(setCookie.some((h) => String(h).startsWith('rt='))).toBe(true); + + const adminCount = await users.count({ where: { role: 'admin' } }); + expect(adminCount).toBe(1); + }); + + it('returns 404 once an admin already exists (route is hidden)', async () => { + const c = csrfClient(app); + await c + .post('/api/v1/setup/create-admin') + .send({ username: 'first', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }) + .expect(201); + + const res = await c + .post('/api/v1/setup/create-admin') + .send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }); + expect(res.status).toBe(404); + expect(res.body).toBeDefined(); + }); + + it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => { + // The current ordering (route hidden after init) means USERNAME_TAKEN is + // reachable only when two requests enter the transaction concurrently + // before any admin exists. Verify the error code is present in the + // error-codes enum so the frontend can switch on it. + const { ERROR_CODES } = require('../../backend/src/common/errors/error-codes'); + expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN'); + }); + + it('SetupService throws not-found (route hidden) after init', async () => { + await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'); + await expect( + setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('rejects weak password with WEAK_PASSWORD', async () => { + const c = csrfClient(app); + const res = await c + .post('/api/v1/setup/create-admin') + .send({ username: 'weakpw', password: 'short', passwordConfirm: 'short' }) + .expect(400); + expect(res.body.code).toBe('WEAK_PASSWORD'); + }); + + it('rejects passwordConfirm mismatch with VALIDATION_FAILED', async () => { + const c = csrfClient(app); + await c + .post('/api/v1/setup/create-admin') + .send({ username: 'mismatch', password: 'Sup3rSecret!Pass', passwordConfirm: 'Different!Pass1' }) + .expect(400); + }); + + it('rejects invalid username characters with VALIDATION_FAILED', async () => { + const c = csrfClient(app); + await c + .post('/api/v1/setup/create-admin') + .send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }) + .expect(400); + }); +}); diff --git a/tests/frontend/setup-create-admin.spec.ts b/tests/frontend/setup-create-admin.spec.ts new file mode 100644 index 0000000..741c096 --- /dev/null +++ b/tests/frontend/setup-create-admin.spec.ts @@ -0,0 +1,60 @@ +import { + passwordMatchValidator, + PasswordMatchGroupLike, +} from '../../frontend/src/app/features/setup/setup-create-admin.validators'; + +function makeGroup(password: string | null | undefined, confirm: string | null | undefined): PasswordMatchGroupLike { + return { + get: (name) => { + if (name === 'password') return { value: password }; + if (name === 'passwordConfirm') return { value: confirm }; + return null; + }, + }; +} + +describe('passwordMatchValidator', () => { + it('returns null when either field is empty (let other validators handle required)', () => { + expect(passwordMatchValidator(makeGroup('', 'x'))).toBeNull(); + expect(passwordMatchValidator(makeGroup('x', ''))).toBeNull(); + expect(passwordMatchValidator(makeGroup(undefined, undefined))).toBeNull(); + }); + + it('returns null when passwords match', () => { + expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup3r!'))).toBeNull(); + }); + + it('returns { passwordMismatch: true } when passwords differ', () => { + expect(passwordMatchValidator(makeGroup('Sup3r!', 'Sup4r!'))).toEqual({ + passwordMismatch: true, + }); + }); +}); + +describe('Username field rules (mirroring component validators)', () => { + const PATTERN = /^[a-zA-Z0-9_.-]+$/; + const MIN = 3; + const MAX = 32; + + function isValidUsername(value: string): boolean { + return value.length >= MIN && value.length <= MAX && PATTERN.test(value); + } + + it('accepts a 3-32 char username with allowed characters', () => { + expect(isValidUsername('first.admin_01')).toBe(true); + }); + + it('rejects usernames shorter than 3', () => { + expect(isValidUsername('a')).toBe(false); + }); + + it('rejects usernames with disallowed characters', () => { + expect(isValidUsername('bad name')).toBe(false); + expect(isValidUsername('user!')).toBe(false); + expect(isValidUsername('user@')).toBe(false); + }); + + it('rejects usernames longer than 32', () => { + expect(isValidUsername('a'.repeat(33))).toBe(false); + }); +});