From 4deff7ea9a29f692a136a2084f86366c701d8688 Mon Sep 17 00:00:00 2001 From: Pascal Klesse <54418919+pascal-klesse@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:07:35 +0100 Subject: [PATCH 1/3] Add CoreSystemSetupModule for initial admin creation on fresh deployments Provides public REST endpoints (GET /api/system-setup/status, POST /api/system-setup/init) to create the first admin user when zero users exist. Bypasses BetterAuth's disableSignUp via internalAdapter, following the same pattern as Better-Auth's admin plugin. Co-Authored-By: Claude Opus 4.6 --- .claude/rules/architecture.md | 6 +- .claude/rules/configurable-features.md | 1 + CLAUDE.md | 2 +- src/config.env.ts | 2 + src/core.module.ts | 24 +- .../interfaces/server-options.interface.ts | 45 ++++ src/core/modules/error-code/error-codes.ts | 31 +++ .../system-setup/INTEGRATION-CHECKLIST.md | 102 ++++++++ src/core/modules/system-setup/README.md | 208 +++++++++++++++ .../core-system-setup.controller.ts | 69 +++++ .../system-setup/core-system-setup.module.ts | 19 ++ .../system-setup/core-system-setup.service.ts | 160 ++++++++++++ src/index.ts | 8 + tests/stories/system-setup.e2e-spec.ts | 242 ++++++++++++++++++ 14 files changed, 908 insertions(+), 11 deletions(-) create mode 100644 src/core/modules/system-setup/INTEGRATION-CHECKLIST.md create mode 100644 src/core/modules/system-setup/README.md create mode 100644 src/core/modules/system-setup/core-system-setup.controller.ts create mode 100644 src/core/modules/system-setup/core-system-setup.module.ts create mode 100644 src/core/modules/system-setup/core-system-setup.service.ts create mode 100644 tests/stories/system-setup.e2e-spec.ts diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 2e3682b..81b649d 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -45,9 +45,13 @@ Key areas: JWT, MongoDB, GraphQL, email, security, static assets |--------|---------| | **Auth** | JWT authentication, refresh tokens, role-based access | | **BetterAuth** | Modern auth integration (2FA, Passkey, Social) | +| **ErrorCode** | Centralized error codes with unique identifiers | | **File** | File upload/download with GridFS storage | -| **User** | Core user management functionality | | **HealthCheck** | Application health monitoring | +| **Migrate** | Database migration utilities | +| **SystemSetup** | Initial admin creation for fresh deployments | +| **Tus** | Resumable file uploads via tus.io protocol | +| **User** | Core user management functionality | ## Security Implementation diff --git a/.claude/rules/configurable-features.md b/.claude/rules/configurable-features.md index e979b63..9ceba7e 100644 --- a/.claude/rules/configurable-features.md +++ b/.claude/rules/configurable-features.md @@ -203,6 +203,7 @@ This pattern is currently applied to: | BetterAuth 2FA Plugin | `betterAuth.twoFactor` | Boolean Shorthand | `appName: 'Nest Server'` | | BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` | | BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled) | +| System Setup | `systemSetup` | Presence Implies Enabled | (none) | ## Checklist for New Configurable Features diff --git a/CLAUDE.md b/CLAUDE.md index a8d0d26..4a2f008 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ npm run reinit # Clean reinstall + tests + build **Key Components:** - `CoreModule` - Dynamic module with GraphQL, MongoDB, security - `src/core/common/` - Decorators, helpers, interceptors, services -- `src/core/modules/` - Auth, BetterAuth, File, User, HealthCheck +- `src/core/modules/` - Auth, BetterAuth, ErrorCode, File, HealthCheck, Migrate, SystemSetup, Tus, User See `.claude/rules/architecture.md` for detailed documentation. diff --git a/src/config.env.ts b/src/config.env.ts index 0feae6d..1569c9a 100644 --- a/src/config.env.ts +++ b/src/config.env.ts @@ -144,6 +144,7 @@ const config: { [env: string]: IServerOptions } = { options: { prefix: '' }, path: join(__dirname, '..', 'public'), }, + systemSetup: {}, templates: { engine: 'ejs', path: join(__dirname, 'templates'), @@ -388,6 +389,7 @@ const config: { [env: string]: IServerOptions } = { options: { prefix: '' }, path: join(__dirname, '..', 'public'), }, + systemSetup: {}, templates: { engine: 'ejs', path: join(__dirname, 'templates'), diff --git a/src/core.module.ts b/src/core.module.ts index 0e6e342..187cf9f 100755 --- a/src/core.module.ts +++ b/src/core.module.ts @@ -24,6 +24,7 @@ import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-aut import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service'; import { ErrorCodeModule } from './core/modules/error-code/error-code.module'; import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module'; +import { CoreSystemSetupModule } from './core/modules/system-setup/core-system-setup.module'; /** * Core module (dynamic) @@ -143,9 +144,9 @@ export class CoreModule implements NestModule { // Build GraphQL driver configuration based on auth mode const graphQlDriverConfig = isIamOnlyMode - ? (isAutoRegisterDisabledEarly + ? isAutoRegisterDisabledEarly ? this.buildLazyIamGraphQlDriver(cors, options) - : this.buildIamOnlyGraphQlDriver(cors, options)) + : this.buildIamOnlyGraphQlDriver(cors, options) : this.buildLegacyGraphQlDriver(AuthService, AuthModule, cors, options); const config: IServerOptions = merge( @@ -252,6 +253,13 @@ export class CoreModule implements NestModule { imports.push(CoreHealthCheckModule); } + // Add CoreSystemSetupModule based on configuration + // Follows "presence implies enabled" pattern + const systemSetupConfig = config.systemSetup; + if (systemSetupConfig !== undefined && systemSetupConfig !== null && systemSetupConfig?.enabled !== false) { + imports.push(CoreSystemSetupModule); + } + // Add CoreBetterAuthModule based on mode // IAM-only mode: BetterAuth is enabled by default (it's the only auth option) // Legacy mode: Only register if autoRegister is explicitly true @@ -261,16 +269,14 @@ export class CoreModule implements NestModule { // Determine if BetterAuth is explicitly disabled // In IAM-only mode: enabled by default (undefined = true), only false or { enabled: false } disables // In Legacy mode: disabled by default (undefined = false), must be explicitly enabled - const isExplicitlyDisabled = betterAuthConfig === false || - (typeof betterAuthConfig === 'object' && betterAuthConfig?.enabled === false); - const isExplicitlyEnabled = betterAuthConfig === true || - (typeof betterAuthConfig === 'object' && betterAuthConfig?.enabled !== false); + const isExplicitlyDisabled = + betterAuthConfig === false || (typeof betterAuthConfig === 'object' && betterAuthConfig?.enabled === false); + const isExplicitlyEnabled = + betterAuthConfig === true || (typeof betterAuthConfig === 'object' && betterAuthConfig?.enabled !== false); // IAM-only mode: enabled unless explicitly disabled // Legacy mode: enabled only if explicitly enabled - const isBetterAuthEnabled = isIamOnlyMode - ? !isExplicitlyDisabled - : isExplicitlyEnabled; + const isBetterAuthEnabled = isIamOnlyMode ? !isExplicitlyDisabled : isExplicitlyEnabled; const isAutoRegister = typeof betterAuthConfig === 'object' && betterAuthConfig?.autoRegister === true; // autoRegister: false means the project imports its own BetterAuthModule separately diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index 6e8fc00..5952c0f 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -1429,6 +1429,33 @@ export interface IServerOptions { path?: string; }; + /** + * System setup configuration for initial admin creation. + * + * When enabled, provides REST endpoints for creating the first admin user + * on a fresh deployment (zero users in the database). + * + * Follows the "presence implies enabled" pattern: + * - `undefined`: Disabled (default, backward compatible) + * - `{}`: Enabled with defaults + * - `{ enabled: false }`: Disabled explicitly + * + * @since 11.14.0 + * + * @example + * ```typescript + * // Enable system setup (for fresh deployments) + * systemSetup: {}, + * + * // Useful with disableSignUp to allow first admin creation + * systemSetup: {}, + * betterAuth: { + * emailAndPassword: { disableSignUp: true }, + * }, + * ``` + */ + systemSetup?: ISystemSetup; + /** * Templates */ @@ -1476,6 +1503,24 @@ export interface IServerOptions { tus?: boolean | ITusConfig; } +/** + * System setup configuration interface + * + * Follows the "presence implies enabled" pattern: + * - `undefined`: Disabled (default, backward compatible) + * - `{}`: Enabled with defaults + * - `{ enabled: false }`: Disabled explicitly + * + * @since 11.14.0 + */ +export interface ISystemSetup { + /** + * Whether system setup is enabled. + * @default true (when config block is present) + */ + enabled?: boolean; +} + /** * TUS Upload Configuration Interface * diff --git a/src/core/modules/error-code/error-codes.ts b/src/core/modules/error-code/error-codes.ts index be6b480..0915f8e 100644 --- a/src/core/modules/error-code/error-codes.ts +++ b/src/core/modules/error-code/error-codes.ts @@ -454,6 +454,37 @@ export const LtnsErrors = { en: 'Legacy authentication is disabled. Please use the new authentication.', }, }, + + // ===================================================== + // System Setup Errors (LTNS_0050-LTNS_0059) + // ===================================================== + + SYSTEM_SETUP_NOT_AVAILABLE: { + code: 'LTNS_0050', + message: 'System setup not available - users already exist', + translations: { + de: 'System-Setup nicht verfügbar - es existieren bereits Benutzer.', + en: 'System setup not available - users already exist.', + }, + }, + + SYSTEM_SETUP_DISABLED: { + code: 'LTNS_0051', + message: 'System setup is disabled', + translations: { + de: 'System-Setup ist deaktiviert.', + en: 'System setup is disabled.', + }, + }, + + SYSTEM_SETUP_BETTERAUTH_REQUIRED: { + code: 'LTNS_0052', + message: 'System setup requires BetterAuth', + translations: { + de: 'System-Setup erfordert BetterAuth.', + en: 'System setup requires BetterAuth.', + }, + }, } as const satisfies IErrorRegistry; /* eslint-enable perfectionist/sort-objects */ diff --git a/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md b/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md new file mode 100644 index 0000000..80cc51a --- /dev/null +++ b/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md @@ -0,0 +1,102 @@ +# System Setup Integration Checklist + +**For enabling initial admin creation on fresh deployments in projects using `@lenne.tech/nest-server`.** + +> **Note:** System setup is **disabled by default**. This checklist shows how to enable it. No custom code is needed unless you want to extend the controller. + +--- + +## Do You Need This Checklist? + +| Scenario | Checklist Needed? | +|----------|-------------------| +| Fresh deployment needs initial admin creation | Yes - Step 1 | +| Using `disableSignUp: true` and need first admin | Yes - Step 1 | +| Custom setup logic (extra fields, notifications) | Yes - Steps 1 + 2 | +| Don't need initial admin creation | No | + +--- + +## Reference Implementation + +**Local (in your node_modules):** +``` +node_modules/@lenne.tech/nest-server/src/core/modules/system-setup/ +``` + +**GitHub:** +https://github.com/lenneTech/nest-server/tree/develop/src/core/modules/system-setup + +--- + +## Step 1: Enable in Configuration (Required) + +**Edit:** `src/config.env.ts` + +Add `systemSetup: {}` to your environment configuration: + +```typescript +// config.env.ts +{ + // ... other config + + systemSetup: {}, + + // BetterAuth must also be enabled + betterAuth: { + // ... + }, +} +``` + +That's it. The module is auto-registered when the config is present. + +--- + +## Step 2: Custom Controller (Optional) + +Only needed if you want to add extra validation, logging, or custom fields. + +**Create:** `src/server/modules/system-setup/system-setup.controller.ts` + +```typescript +import { Controller } from '@nestjs/common'; +import { CoreSystemSetupController, Roles, RoleEnum } from '@lenne.tech/nest-server'; + +@Controller('api/system-setup') +@Roles(RoleEnum.ADMIN) +export class SystemSetupController extends CoreSystemSetupController { + // Override methods here for custom logic +} +``` + +> **Note:** Unlike other modules, the SystemSetup module auto-registers its controller via CoreModule. To use a custom controller, you would need to disable auto-registration and register your own module. + +--- + +## Verification Checklist + +- [ ] `npm run build` succeeds +- [ ] `npm test` passes +- [ ] `GET /api/system-setup/status` returns `{ needsSetup: true }` on empty database +- [ ] `POST /api/system-setup/init` creates admin user with correct role +- [ ] `GET /api/system-setup/status` returns `{ needsSetup: false }` after init +- [ ] `POST /api/system-setup/init` returns 403 when users already exist + +--- + +## Common Mistakes + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Missing `systemSetup` in config | 404 on `/api/system-setup/*` | Add `systemSetup: {}` to config.env.ts | +| BetterAuth not enabled | 403 "System setup requires BetterAuth" | Ensure `betterAuth` is configured | +| Calling init with existing users | 403 "System setup not available" | Init only works on empty database | +| Password too short | 400 validation error | Password must be at least 8 characters | + +--- + +## Detailed Documentation + +- **README.md:** `node_modules/@lenne.tech/nest-server/src/core/modules/system-setup/README.md` +- **GitHub:** https://github.com/lenneTech/nest-server/blob/develop/src/core/modules/system-setup/README.md diff --git a/src/core/modules/system-setup/README.md b/src/core/modules/system-setup/README.md new file mode 100644 index 0000000..da8dfa7 --- /dev/null +++ b/src/core/modules/system-setup/README.md @@ -0,0 +1,208 @@ +# System Setup Module + +Initial admin user creation for fresh deployments of @lenne.tech/nest-server. + +## TL;DR + +```typescript +// config.env.ts - enable system setup endpoints +systemSetup: {}, + +// Requires BetterAuth to be enabled +betterAuth: { + // ... +}, +``` + +**Quick Links:** [Integration Checklist](./INTEGRATION-CHECKLIST.md) | [Endpoints](#endpoints) | [Configuration](#configuration) | [Security](#security) + +--- + +## Table of Contents + +- [Purpose](#purpose) +- [Endpoints](#endpoints) +- [Configuration](#configuration) +- [Security](#security) +- [Frontend Integration](#frontend-integration) +- [Troubleshooting](#troubleshooting) + +--- + +## Purpose + +When a system is freshly deployed (zero users in the database), there is no way to create an initial admin user - especially when BetterAuth's `disableSignUp` is enabled. This module provides two public REST endpoints: + +1. **Status check** - Does the system need initial setup? +2. **Init** - Create the first admin user + +Once any user exists, the init endpoint is permanently locked (returns 403). + +--- + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/system-setup/status` | Check if system needs setup | +| POST | `/api/system-setup/init` | Create initial admin user | + +### GET /api/system-setup/status + +Returns the current setup status. + +**Response:** +```json +{ + "needsSetup": true, + "betterAuthEnabled": true +} +``` + +### POST /api/system-setup/init + +Creates the initial admin user. Only works when zero users exist. + +**Request Body:** +```json +{ + "email": "admin@example.com", + "password": "SecurePassword123!", + "name": "Admin" +} +``` + +| Field | Type | Required | Validation | +|-------|------|----------|------------| +| `email` | string | Yes | Valid email format | +| `password` | string | Yes | Minimum 8 characters | +| `name` | string | No | Defaults to email prefix | + +**Success Response (201):** +```json +{ + "success": true, + "email": "admin@example.com", + "message": "Initial admin user created successfully" +} +``` + +**Error Response (403):** +```json +{ + "message": "LTNS_0050: System setup not available - users already exist" +} +``` + +--- + +## Configuration + +Follows the ["Presence implies enabled"](../../../.claude/rules/configurable-features.md) pattern: + +| Config | Effect | +|--------|--------| +| `systemSetup: undefined` | Disabled (default, backward compatible) | +| `systemSetup: {}` | Enabled | +| `systemSetup: { enabled: false }` | Disabled explicitly | + +```typescript +// config.env.ts +{ + systemSetup: {}, + + betterAuth: { + emailAndPassword: { + disableSignUp: true, // System setup bypasses this + }, + }, +} +``` + +--- + +## Security + +1. **Zero-user guard** - Init only works when `countDocuments({}) === 0` +2. **Opt-in only** - Module not loaded unless `systemSetup` is configured +3. **Race condition protection** - MongoDB unique email index prevents duplicates +4. **Permanent lock** - Once any user exists, init returns 403 +5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled + +### How It Bypasses disableSignUp + +The service uses BetterAuth's `$context.internalAdapter.createUser()` directly, which is the same approach used by Better-Auth's own admin plugin. This bypasses the `disableSignUp` flag while still creating proper BetterAuth accounts. + +--- + +## Frontend Integration + +Typical frontend flow: + +```typescript +// 1. Check if setup is needed +const status = await fetch('/api/system-setup/status'); +const { needsSetup } = await status.json(); + +if (needsSetup) { + // 2. Show setup form and submit + const result = await fetch('/api/system-setup/init', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: 'admin@example.com', + password: 'SecurePassword123!', + name: 'Admin', + }), + }); + + // 3. Sign in with the created admin + // Use BetterAuth sign-in endpoint +} +``` + +--- + +## Troubleshooting + +### Init returns 403 "System setup not available" + +**Cause:** Users already exist in the database. + +**Solutions:** +1. Check `GET /api/system-setup/status` - `needsSetup` should be `true` +2. If this is a fresh deployment, verify the database is empty + +### Init returns 403 "System setup requires BetterAuth" + +**Cause:** BetterAuth is not configured or not enabled. + +**Solutions:** +1. Ensure `betterAuth` is configured in `config.env.ts` +2. Verify BetterAuth is running (check server startup logs) + +### Endpoints return 404 + +**Cause:** System setup module is not loaded. + +**Solutions:** +1. Add `systemSetup: {}` to `config.env.ts` +2. Verify the module is imported (check server startup logs for `CoreSystemSetupController`) + +--- + +## Error Codes + +| Code | Key | Description | +|------|-----|-------------| +| LTNS_0050 | `SYSTEM_SETUP_NOT_AVAILABLE` | Users already exist, setup locked | +| LTNS_0051 | `SYSTEM_SETUP_DISABLED` | System setup is disabled in config | +| LTNS_0052 | `SYSTEM_SETUP_BETTERAUTH_REQUIRED` | BetterAuth must be enabled | + +--- + +## Related Documentation + +- [Integration Checklist](./INTEGRATION-CHECKLIST.md) +- [BetterAuth Module](../better-auth/README.md) +- [Configurable Features Pattern](../../../.claude/rules/configurable-features.md) diff --git a/src/core/modules/system-setup/core-system-setup.controller.ts b/src/core/modules/system-setup/core-system-setup.controller.ts new file mode 100644 index 0000000..3e2ad43 --- /dev/null +++ b/src/core/modules/system-setup/core-system-setup.controller.ts @@ -0,0 +1,69 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiBody, ApiOperation, ApiProperty, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator'; + +import { Roles } from '../../common/decorators/roles.decorator'; +import { RoleEnum } from '../../common/enums/role.enum'; +import { CoreSystemSetupService, SystemSetupInitResult, SystemSetupStatus } from './core-system-setup.service'; + +/** + * DTO for system setup init request + */ +export class SystemSetupInitDto { + @ApiProperty({ description: 'Email address for the initial admin user', example: 'admin@example.com' }) + @IsEmail() + @IsNotEmpty() + email: string; + + @ApiProperty({ description: 'Name of the initial admin user', example: 'Admin', required: false }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: 'Password for the initial admin user', minLength: 8 }) + @IsNotEmpty() + @IsString() + @MinLength(8) + password: string; +} + +/** + * CoreSystemSetupController provides REST endpoints for initial system setup. + * + * These endpoints are public (S_EVERYONE) to allow first-time admin creation + * on a fresh system with zero users. The service enforces security by checking + * that no users exist before allowing creation. + */ +@ApiTags('System Setup') +@Controller('api/system-setup') +@Roles(RoleEnum.ADMIN) +export class CoreSystemSetupController { + constructor(protected readonly systemSetupService: CoreSystemSetupService) {} + + /** + * Check if the system needs initial setup + */ + @ApiOperation({ description: 'Returns whether the system needs initial admin setup', summary: 'Get setup status' }) + @ApiResponse({ description: 'Setup status retrieved', status: 200 }) + @Get('status') + @Roles(RoleEnum.S_EVERYONE) + async getSetupStatus(): Promise { + return this.systemSetupService.getSetupStatus(); + } + + /** + * Create the initial admin user (only works when zero users exist) + */ + @ApiBody({ type: SystemSetupInitDto }) + @ApiOperation({ + description: 'Creates the initial admin user. Only works when zero users exist in the database.', + summary: 'Create initial admin', + }) + @ApiResponse({ description: 'Initial admin created', status: 201 }) + @ApiResponse({ description: 'System setup not available - users already exist', status: 403 }) + @Post('init') + @Roles(RoleEnum.S_EVERYONE) + async createInitialAdmin(@Body() input: SystemSetupInitDto): Promise { + return this.systemSetupService.createInitialAdmin(input); + } +} diff --git a/src/core/modules/system-setup/core-system-setup.module.ts b/src/core/modules/system-setup/core-system-setup.module.ts new file mode 100644 index 0000000..f457b74 --- /dev/null +++ b/src/core/modules/system-setup/core-system-setup.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; + +import { CoreSystemSetupController } from './core-system-setup.controller'; +import { CoreSystemSetupService } from './core-system-setup.service'; + +/** + * CoreSystemSetupModule provides initial admin creation for fresh deployments. + * + * This module is conditionally imported in CoreModule when `systemSetup` is configured. + * It follows the "presence implies enabled" pattern: + * - `systemSetup: undefined` → Module not loaded (default, backward compatible) + * - `systemSetup: {}` → Module loaded + * - `systemSetup: { enabled: false }` → Module not loaded + */ +@Module({ + controllers: [CoreSystemSetupController], + providers: [CoreSystemSetupService], +}) +export class CoreSystemSetupModule {} diff --git a/src/core/modules/system-setup/core-system-setup.service.ts b/src/core/modules/system-setup/core-system-setup.service.ts new file mode 100644 index 0000000..2a7a864 --- /dev/null +++ b/src/core/modules/system-setup/core-system-setup.service.ts @@ -0,0 +1,160 @@ +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection } from 'mongoose'; + +import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper'; +import { CoreBetterAuthService } from '../better-auth/core-better-auth.service'; +import { ErrorCode } from '../error-code/error-codes'; + +/** + * Input for creating the initial admin user + */ +export interface SystemSetupInitInput { + email: string; + name?: string; + password: string; +} + +/** + * Response for successful init + */ +export interface SystemSetupInitResult { + email: string; + message: string; + success: boolean; +} + +/** + * Response for setup status check + */ +export interface SystemSetupStatus { + betterAuthEnabled: boolean; + needsSetup: boolean; +} + +/** + * CoreSystemSetupService provides initial admin creation for fresh deployments. + * + * This service allows creating the first admin user when the system has zero users. + * It bypasses BetterAuth's disableSignUp check by using the internal adapter directly, + * which is the same approach used by Better-Auth's own admin plugin. + * + * Security: + * - Only works when zero users exist in the database + * - Once any user exists, the init endpoint is permanently locked + * - Race conditions handled by MongoDB unique email index + */ +@Injectable() +export class CoreSystemSetupService { + private readonly logger = new Logger(CoreSystemSetupService.name); + + constructor( + @InjectConnection() private readonly connection: Connection, + private readonly betterAuthService: CoreBetterAuthService, + private readonly userMapper: CoreBetterAuthUserMapper, + ) {} + + /** + * Check if the system needs initial setup (zero users) + */ + async getSetupStatus(): Promise { + const userCount = await this.connection.collection('users').countDocuments({}); + return { + betterAuthEnabled: this.betterAuthService.isEnabled(), + needsSetup: userCount === 0, + }; + } + + /** + * Create the initial admin user when zero users exist. + * + * Uses BetterAuth's internalAdapter to bypass disableSignUp, + * then syncs to nest-server users collection with admin role. + */ + async createInitialAdmin(input: SystemSetupInitInput): Promise { + // Pre-check: only allow when zero users exist + const userCount = await this.connection.collection('users').countDocuments({}); + if (userCount > 0) { + throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE); + } + + // Ensure BetterAuth is enabled + if (!this.betterAuthService.isEnabled()) { + throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_BETTERAUTH_REQUIRED); + } + + const authInstance = this.betterAuthService.getInstance(); + if (!authInstance) { + throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_BETTERAUTH_REQUIRED); + } + + try { + // Access BetterAuth internal context (same pattern as core-better-auth-api.middleware.ts) + const context = await authInstance.$context; + + // Normalize password for IAM (SHA256 if plain text) + const normalizedPassword = this.userMapper.normalizePasswordForIam(input.password); + + // Create user via internalAdapter (bypasses disableSignUp) + const iamUser = await context.internalAdapter.createUser({ + email: input.email, + emailVerified: true, + name: input.name || input.email.split('@')[0], + }); + + if (!iamUser) { + throw new Error('Failed to create IAM user'); + } + + // Hash password and create credential account + const hashedPassword = await context.password.hash(normalizedPassword); + await context.internalAdapter.linkAccount({ + accountId: iamUser.id, + password: hashedPassword, + providerId: 'credential', + userId: iamUser.id, + }); + + // Sync to nest-server users collection + const syncedUser = await this.userMapper.linkOrCreateUser({ + email: iamUser.email, + emailVerified: true, + id: iamUser.id, + name: iamUser.name, + }); + + if (!syncedUser) { + throw new Error('Failed to sync user to nest-server collection'); + } + + // Set admin role directly + await this.connection + .collection('users') + .updateOne({ _id: syncedUser._id }, { $set: { roles: ['admin'], updatedAt: new Date() } }); + + // Sync password to Legacy Auth for backwards compatibility + await this.userMapper.syncPasswordToLegacy(iamUser.id, input.email, input.password); + + this.logger.log(`Initial admin user created: ${input.email}`); + + return { + email: input.email, + message: 'Initial admin user created successfully', + success: true, + }; + } catch (error) { + // Handle duplicate email (race condition via MongoDB unique index) + if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) { + throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE); + } + + // Re-throw known exceptions + if (error instanceof ForbiddenException) { + throw error; + } + + this.logger.error(`System setup failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE); + } + } +} diff --git a/src/index.ts b/src/index.ts index c1bd68b..f26ab8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -167,6 +167,14 @@ export * from './core/modules/health-check/core-health-check.service'; export * from './core/modules/migrate'; +// ===================================================================================================================== +// Core - Modules - SystemSetup +// ===================================================================================================================== + +export * from './core/modules/system-setup/core-system-setup.controller'; +export * from './core/modules/system-setup/core-system-setup.module'; +export * from './core/modules/system-setup/core-system-setup.service'; + // ===================================================================================================================== // Core - Modules - Tus // ===================================================================================================================== diff --git a/tests/stories/system-setup.e2e-spec.ts b/tests/stories/system-setup.e2e-spec.ts new file mode 100644 index 0000000..0ccc70a --- /dev/null +++ b/tests/stories/system-setup.e2e-spec.ts @@ -0,0 +1,242 @@ +/** + * Story: System Setup - Initial Admin Creation + * + * As a developer deploying a fresh system, + * I want to create an initial admin user via REST API, + * So that I can access the system even when signup is disabled. + * + * Test scenarios: + * - Status returns needsSetup: true when zero users + * - Init creates admin user successfully + * - Created admin has admin role + * - Created admin can sign in via BetterAuth + * - Status returns needsSetup: false after init + * - Init returns 403 when users already exist + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { PubSub } from 'graphql-subscriptions'; +import { MongoClient } from 'mongodb'; + +import { CoreBetterAuthService, HttpExceptionLogFilter, TestHelper } from '../../src'; +import envConfig from '../../src/config.env'; +import { ServerModule } from '../../src/server/server.module'; + +describe('Story: System Setup', () => { + let app; + let testHelper: TestHelper; + let betterAuthService: CoreBetterAuthService; + + // Database + let mongoClient: MongoClient; + let db; + + // Track created test data for cleanup + const testEmails: string[] = []; + + const SETUP_ADMIN_EMAIL = `setup-admin-${Date.now()}@test.com`; + const SETUP_ADMIN_PASSWORD = 'TestPassword123!'; + const SETUP_ADMIN_NAME = 'Setup Admin'; + + // =================================================================================================================== + // Setup & Teardown + // =================================================================================================================== + + beforeAll(async () => { + try { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ServerModule], + providers: [ + { + provide: 'PUB_SUB', + useValue: new PubSub(), + }, + ], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(envConfig.templates.path); + app.setViewEngine(envConfig.templates.engine); + await app.init(); + + testHelper = new TestHelper(app); + betterAuthService = moduleFixture.get(CoreBetterAuthService); + + mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + db = mongoClient.db(); + } catch (e) { + console.error('beforeAll Error', e); + throw e; + } + }); + + afterAll(async () => { + // Cleanup all test users and associated IAM data + if (db) { + for (const email of testEmails) { + try { + const user = await db.collection('users').findOne({ email }); + if (user) { + await db.collection('users').deleteOne({ _id: user._id }); + if (user.iamId) { + await db.collection('account').deleteMany({ userId: user.iamId }); + await db.collection('session').deleteMany({ userId: user.iamId }); + } + } + } catch { + // Ignore cleanup errors + } + } + } + + if (mongoClient) { + await mongoClient.close(); + } + if (app) { + await app.close(); + } + }); + + // =================================================================================================================== + // Helper: Clear all users to simulate fresh system + // =================================================================================================================== + + async function clearAllUsers() { + // BetterAuth shares the 'users' collection (modelName: 'users') + await db.collection('users').deleteMany({}); + await db.collection('account').deleteMany({}); + await db.collection('session').deleteMany({}); + } + + // =================================================================================================================== + // Tests + // =================================================================================================================== + + describe('GET /api/system-setup/status', () => { + it('should return needsSetup: true when zero users exist', async () => { + // Clear all users to simulate fresh deployment + await clearAllUsers(); + + const result = await testHelper.rest('/api/system-setup/status', { + method: 'GET', + statusCode: 200, + }); + + expect(result).toBeDefined(); + expect(result.needsSetup).toBe(true); + expect(result.betterAuthEnabled).toBe(betterAuthService.isEnabled()); + }); + + it('should return needsSetup: false when users exist', async () => { + // Ensure at least one user exists + await db.collection('users').insertOne({ + createdAt: new Date(), + email: 'existing@test.com', + roles: [], + }); + testEmails.push('existing@test.com'); + + const result = await testHelper.rest('/api/system-setup/status', { + method: 'GET', + statusCode: 200, + }); + + expect(result).toBeDefined(); + expect(result.needsSetup).toBe(false); + + // Cleanup the test user + await db.collection('users').deleteOne({ email: 'existing@test.com' }); + testEmails.pop(); + }); + }); + + describe('POST /api/system-setup/init', () => { + it('should create initial admin successfully when zero users exist', async () => { + // Clear all users to simulate fresh deployment + await clearAllUsers(); + testEmails.push(SETUP_ADMIN_EMAIL); + + const result = await testHelper.rest('/api/system-setup/init', { + method: 'POST', + payload: { + email: SETUP_ADMIN_EMAIL, + name: SETUP_ADMIN_NAME, + password: SETUP_ADMIN_PASSWORD, + }, + statusCode: 201, + }); + + expect(result).toBeDefined(); + expect(result.success).toBe(true); + expect(result.email).toBe(SETUP_ADMIN_EMAIL); + }); + + it('should have created user with admin role', async () => { + const user = await db.collection('users').findOne({ email: SETUP_ADMIN_EMAIL }); + + expect(user).toBeDefined(); + expect(user.roles).toContain('admin'); + expect(user.iamId).toBeDefined(); + }); + + it('should have created BetterAuth user and account', async () => { + const user = await db.collection('users').findOne({ email: SETUP_ADMIN_EMAIL }); + expect(user).toBeDefined(); + expect(user.iamId).toBeDefined(); + + // BetterAuth shares the 'users' collection (modelName: 'users') + // The account collection links via userId (can be ObjectId or string) + // Query by both _id and iamId to handle either format + const account = await db.collection('account').findOne({ + $or: [{ userId: user._id }, { userId: user.iamId }], + providerId: 'credential', + }); + expect(account).toBeDefined(); + expect(account.password).toBeDefined(); + }); + + it('should allow the created admin to sign in via BetterAuth', async () => { + if (!betterAuthService.isEnabled()) { + return; + } + + // Sign in via BetterAuth REST endpoint + const signInResult = await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { + email: SETUP_ADMIN_EMAIL, + password: SETUP_ADMIN_PASSWORD, + }, + statusCode: 200, + }); + + expect(signInResult).toBeDefined(); + expect(signInResult.token || signInResult.user).toBeDefined(); + }); + + it('should return needsSetup: false after init', async () => { + const result = await testHelper.rest('/api/system-setup/status', { + method: 'GET', + statusCode: 200, + }); + + expect(result).toBeDefined(); + expect(result.needsSetup).toBe(false); + }); + + it('should return 403 when users already exist', async () => { + const result = await testHelper.rest('/api/system-setup/init', { + method: 'POST', + payload: { + email: `setup-second-${Date.now()}@test.com`, + password: 'AnotherPassword123!', + }, + statusCode: 403, + }); + + expect(result).toBeDefined(); + expect(result.message).toContain('LTNS_0050'); + }); + }); +}); From 85f49fdca57d5c4d75c443f776e8cfa50171a4fe Mon Sep 17 00:00:00 2001 From: Pascal Klesse <54418919+pascal-klesse@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:10:55 +0100 Subject: [PATCH 2/3] Fix SystemSetup module DI error when BetterAuth is disabled Only load CoreSystemSetupModule when BetterAuth is also enabled, since it depends on CoreBetterAuthService internally. Co-Authored-By: Claude Opus 4.6 --- src/core.module.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/core.module.ts b/src/core.module.ts index 187cf9f..8da2eba 100755 --- a/src/core.module.ts +++ b/src/core.module.ts @@ -253,13 +253,6 @@ export class CoreModule implements NestModule { imports.push(CoreHealthCheckModule); } - // Add CoreSystemSetupModule based on configuration - // Follows "presence implies enabled" pattern - const systemSetupConfig = config.systemSetup; - if (systemSetupConfig !== undefined && systemSetupConfig !== null && systemSetupConfig?.enabled !== false) { - imports.push(CoreSystemSetupModule); - } - // Add CoreBetterAuthModule based on mode // IAM-only mode: BetterAuth is enabled by default (it's the only auth option) // Legacy mode: Only register if autoRegister is explicitly true @@ -310,6 +303,19 @@ export class CoreModule implements NestModule { } } + // Add CoreSystemSetupModule based on configuration + // Requires BetterAuth to be enabled (uses CoreBetterAuthService internally) + // Follows "presence implies enabled" pattern + const systemSetupConfig = config.systemSetup; + if ( + isBetterAuthEnabled && + systemSetupConfig !== undefined && + systemSetupConfig !== null && + systemSetupConfig?.enabled !== false + ) { + imports.push(CoreSystemSetupModule); + } + // Set exports const exports: any[] = [ConfigService, EmailService, TemplateService, MailjetService]; if (!process.env.VITEST) { From bb9263c53da09237808ef83887ef2891368cbc19 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Sat, 7 Feb 2026 16:37:34 +0100 Subject: [PATCH 3/3] Enable SystemSetup by default with BetterAuth, add initialAdmin auto-creation via config/ENV --- .claude/rules/configurable-features.md | 2 +- .env.example | 10 + package-lock.json | 4 +- package.json | 2 +- spectaql.yml | 2 +- src/config.env.ts | 2 - src/core.module.ts | 13 +- .../interfaces/server-options.interface.ts | 63 +++- .../system-setup/INTEGRATION-CHECKLIST.md | 55 +-- src/core/modules/system-setup/README.md | 109 ++++-- .../system-setup/core-system-setup.service.ts | 70 +++- tests/stories/auth-scenarios.e2e-spec.ts | 13 +- ...better-auth-autoregister-false.e2e-spec.ts | 19 +- ...etter-auth-module-registration.e2e-spec.ts | 19 +- .../bidirectional-auth-sync.e2e-spec.ts | 16 +- tests/stories/scenario-3-http410.e2e-spec.ts | 14 +- tests/stories/system-setup.e2e-spec.ts | 354 +++++++++++++++--- tests/stories/three-scenarios.e2e-spec.ts | 14 +- 18 files changed, 616 insertions(+), 165 deletions(-) diff --git a/.claude/rules/configurable-features.md b/.claude/rules/configurable-features.md index 9ceba7e..f82f3a9 100644 --- a/.claude/rules/configurable-features.md +++ b/.claude/rules/configurable-features.md @@ -203,7 +203,7 @@ This pattern is currently applied to: | BetterAuth 2FA Plugin | `betterAuth.twoFactor` | Boolean Shorthand | `appName: 'Nest Server'` | | BetterAuth Passkey Plugin | `betterAuth.passkey` | Boolean Shorthand | `rpName: 'Nest Server'` | | BetterAuth Disable Sign-Up | `betterAuth.emailAndPassword.disableSignUp` | Explicit Boolean | `false` (sign-up enabled) | -| System Setup | `systemSetup` | Presence Implies Enabled | (none) | +| System Setup | `systemSetup` | Enabled by Default (when BetterAuth active) | `initialAdmin: undefined` | ## Checklist for New Configurable Features diff --git a/.env.example b/.env.example index 4c1e68b..5bcca92 100644 --- a/.env.example +++ b/.env.example @@ -64,3 +64,13 @@ RATE_LIMIT_ENABLED=true RATE_LIMIT_MAX=10 RATE_LIMIT_WINDOW_SECONDS=60 RATE_LIMIT_MESSAGE="Too many requests, please try again later." + +# ============================================================================= +# System Setup - Initial Admin (for automated deployments) +# ============================================================================= +# Auto-creates the initial admin user on server start when zero users exist. +# Only takes effect on fresh deployments. Remove after first deployment. +# IMPORTANT: Use strong passwords and remove credentials from ENV after setup! +NSC__systemSetup__initialAdmin__email=admin@example.com +NSC__systemSetup__initialAdmin__password=YourSecurePassword123! +# NSC__systemSetup__initialAdmin__name=Admin diff --git a/package-lock.json b/package-lock.json index ed0011a..89cb386 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lenne.tech/nest-server", - "version": "11.13.4", + "version": "11.13.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lenne.tech/nest-server", - "version": "11.13.4", + "version": "11.13.5", "license": "MIT", "dependencies": { "@apollo/server": "5.4.0", diff --git a/package.json b/package.json index 4cd7f22..318a402 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.13.4", + "version": "11.13.5", "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).", "keywords": [ "node", diff --git a/spectaql.yml b/spectaql.yml index 05906cb..e5ab69d 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -11,7 +11,7 @@ servers: info: title: lT Nest Server description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases). - version: 11.13.4 + version: 11.13.5 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/src/config.env.ts b/src/config.env.ts index 1569c9a..0feae6d 100644 --- a/src/config.env.ts +++ b/src/config.env.ts @@ -144,7 +144,6 @@ const config: { [env: string]: IServerOptions } = { options: { prefix: '' }, path: join(__dirname, '..', 'public'), }, - systemSetup: {}, templates: { engine: 'ejs', path: join(__dirname, 'templates'), @@ -389,7 +388,6 @@ const config: { [env: string]: IServerOptions } = { options: { prefix: '' }, path: join(__dirname, '..', 'public'), }, - systemSetup: {}, templates: { engine: 'ejs', path: join(__dirname, 'templates'), diff --git a/src/core.module.ts b/src/core.module.ts index 8da2eba..6975261 100755 --- a/src/core.module.ts +++ b/src/core.module.ts @@ -303,16 +303,9 @@ export class CoreModule implements NestModule { } } - // Add CoreSystemSetupModule based on configuration - // Requires BetterAuth to be enabled (uses CoreBetterAuthService internally) - // Follows "presence implies enabled" pattern - const systemSetupConfig = config.systemSetup; - if ( - isBetterAuthEnabled && - systemSetupConfig !== undefined && - systemSetupConfig !== null && - systemSetupConfig?.enabled !== false - ) { + // Add CoreSystemSetupModule when BetterAuth is active + // Enabled by default - disable explicitly via systemSetup: { enabled: false } + if (isBetterAuthEnabled && config.systemSetup?.enabled !== false) { imports.push(CoreSystemSetupModule); } diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index 5952c0f..3454e7e 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -1435,22 +1435,28 @@ export interface IServerOptions { * When enabled, provides REST endpoints for creating the first admin user * on a fresh deployment (zero users in the database). * - * Follows the "presence implies enabled" pattern: - * - `undefined`: Disabled (default, backward compatible) - * - `{}`: Enabled with defaults - * - `{ enabled: false }`: Disabled explicitly + * Enabled by default when BetterAuth is active. Explicitly disable with: + * - `{ enabled: false }`: Disabled + * + * Auto-creation via config/ENV (no REST call needed): + * - `{ initialAdmin: { email: '...', password: '...' } }` + * - `NSC__systemSetup__initialAdmin__email` + `NSC__systemSetup__initialAdmin__password` * * @since 11.14.0 * * @example * ```typescript - * // Enable system setup (for fresh deployments) - * systemSetup: {}, + * // Enabled by default (no config needed when BetterAuth is active) * - * // Useful with disableSignUp to allow first admin creation - * systemSetup: {}, - * betterAuth: { - * emailAndPassword: { disableSignUp: true }, + * // Disable explicitly + * systemSetup: { enabled: false }, + * + * // Auto-create admin on server start (useful for Docker/CI) + * systemSetup: { + * initialAdmin: { + * email: process.env.INITIAL_ADMIN_EMAIL, + * password: process.env.INITIAL_ADMIN_PASSWORD, + * }, * }, * ``` */ @@ -1503,6 +1509,30 @@ export interface IServerOptions { tus?: boolean | ITusConfig; } +export interface ISystemSetup { + /** + * Whether system setup is enabled. + * @default true (when BetterAuth is enabled) + */ + enabled?: boolean; + + /** + * Pre-configured initial admin credentials for automatic creation on server start. + * + * When set, the service will automatically create the initial admin user + * during application bootstrap if zero users exist. This is useful for + * automated deployments (Docker, CI/CD) where no manual REST call is possible. + * + * Can be provided via environment variables: + * - `NSC__systemSetup__initialAdmin__email` + * - `NSC__systemSetup__initialAdmin__password` + * - `NSC__systemSetup__initialAdmin__name` (optional) + * + * Security: Same zero-user guard applies - only works when no users exist. + */ + initialAdmin?: ISystemSetupInitialAdmin; +} + /** * System setup configuration interface * @@ -1513,12 +1543,13 @@ export interface IServerOptions { * * @since 11.14.0 */ -export interface ISystemSetup { - /** - * Whether system setup is enabled. - * @default true (when config block is present) - */ - enabled?: boolean; +export interface ISystemSetupInitialAdmin { + /** Email address for the initial admin user */ + email: string; + /** Name of the initial admin user (defaults to email prefix) */ + name?: string; + /** Password for the initial admin user (minimum 8 characters) */ + password: string; } /** diff --git a/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md b/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md index 80cc51a..8ac7dfa 100644 --- a/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md +++ b/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md @@ -1,19 +1,19 @@ # System Setup Integration Checklist -**For enabling initial admin creation on fresh deployments in projects using `@lenne.tech/nest-server`.** +**For initial admin creation on fresh deployments in projects using `@lenne.tech/nest-server`.** -> **Note:** System setup is **disabled by default**. This checklist shows how to enable it. No custom code is needed unless you want to extend the controller. +> **Note:** System setup is **enabled by default** when BetterAuth is active. No explicit configuration is needed for the REST endpoints to work. --- ## Do You Need This Checklist? -| Scenario | Checklist Needed? | -|----------|-------------------| -| Fresh deployment needs initial admin creation | Yes - Step 1 | -| Using `disableSignUp: true` and need first admin | Yes - Step 1 | -| Custom setup logic (extra fields, notifications) | Yes - Steps 1 + 2 | -| Don't need initial admin creation | No | +| Scenario | Action Needed | +|----------|---------------| +| Fresh deployment needs initial admin creation | None - endpoints are available by default | +| Automated deployment (Docker/CI) needs auto-creation | Set ENV variables (Step 1) | +| Want to disable system setup | Set `systemSetup: { enabled: false }` in config | +| Custom setup logic (extra fields, notifications) | Step 2 (Custom Controller) | --- @@ -29,27 +29,31 @@ https://github.com/lenneTech/nest-server/tree/develop/src/core/modules/system-se --- -## Step 1: Enable in Configuration (Required) +## Step 1: Auto-Creation via ENV (Optional) -**Edit:** `src/config.env.ts` +For automated deployments where no manual REST call is possible: -Add `systemSetup: {}` to your environment configuration: - -```typescript -// config.env.ts -{ - // ... other config +```bash +# .env or Docker environment +NSC__systemSetup__initialAdmin__email=admin@example.com +NSC__systemSetup__initialAdmin__password=YourSecurePassword123! +NSC__systemSetup__initialAdmin__name=Admin # optional +``` - systemSetup: {}, +Or in `config.env.ts`: - // BetterAuth must also be enabled - betterAuth: { - // ... +```typescript +systemSetup: { + initialAdmin: { + email: process.env.INITIAL_ADMIN_EMAIL, + password: process.env.INITIAL_ADMIN_PASSWORD, }, -} +}, ``` -That's it. The module is auto-registered when the config is present. +The admin is created automatically on server start when zero users exist. + +**Security:** Remove credentials from ENV after the first successful deployment. --- @@ -70,7 +74,7 @@ export class SystemSetupController extends CoreSystemSetupController { } ``` -> **Note:** Unlike other modules, the SystemSetup module auto-registers its controller via CoreModule. To use a custom controller, you would need to disable auto-registration and register your own module. +> **Note:** The SystemSetup module auto-registers its controller via CoreModule. To use a custom controller, you would need to disable auto-registration and register your own module. --- @@ -89,10 +93,11 @@ export class SystemSetupController extends CoreSystemSetupController { | Mistake | Symptom | Fix | |---------|---------|-----| -| Missing `systemSetup` in config | 404 on `/api/system-setup/*` | Add `systemSetup: {}` to config.env.ts | -| BetterAuth not enabled | 403 "System setup requires BetterAuth" | Ensure `betterAuth` is configured | +| BetterAuth not enabled | 404 on endpoints or 403 on init | Ensure `betterAuth` is configured | | Calling init with existing users | 403 "System setup not available" | Init only works on empty database | | Password too short | 400 validation error | Password must be at least 8 characters | +| Missing ENV password | Auto-creation silently skipped | Set both `email` and `password` ENV vars | +| `systemSetup: { enabled: false }` in config | 404 on endpoints | Remove the explicit disable | --- diff --git a/src/core/modules/system-setup/README.md b/src/core/modules/system-setup/README.md index da8dfa7..368d021 100644 --- a/src/core/modules/system-setup/README.md +++ b/src/core/modules/system-setup/README.md @@ -4,14 +4,13 @@ Initial admin user creation for fresh deployments of @lenne.tech/nest-server. ## TL;DR -```typescript -// config.env.ts - enable system setup endpoints -systemSetup: {}, +System setup is **enabled by default** when BetterAuth is active. No configuration needed. -// Requires BetterAuth to be enabled -betterAuth: { - // ... -}, +For automated deployments (Docker, CI/CD), set initial admin credentials via ENV: + +```bash +NSC__systemSetup__initialAdmin__email=admin@example.com +NSC__systemSetup__initialAdmin__password=YourSecurePassword123! ``` **Quick Links:** [Integration Checklist](./INTEGRATION-CHECKLIST.md) | [Endpoints](#endpoints) | [Configuration](#configuration) | [Security](#security) @@ -23,6 +22,7 @@ betterAuth: { - [Purpose](#purpose) - [Endpoints](#endpoints) - [Configuration](#configuration) +- [Auto-Creation via Config/ENV](#auto-creation-via-configenv) - [Security](#security) - [Frontend Integration](#frontend-integration) - [Troubleshooting](#troubleshooting) @@ -31,12 +31,12 @@ betterAuth: { ## Purpose -When a system is freshly deployed (zero users in the database), there is no way to create an initial admin user - especially when BetterAuth's `disableSignUp` is enabled. This module provides two public REST endpoints: +When a system is freshly deployed (zero users in the database), there is no way to create an initial admin user - especially when BetterAuth's `disableSignUp` is enabled. This module provides: -1. **Status check** - Does the system need initial setup? -2. **Init** - Create the first admin user +1. **REST endpoints** - Manual admin creation via API call +2. **Auto-creation** - Automatic admin creation on server start via config/ENV -Once any user exists, the init endpoint is permanently locked (returns 403). +Once any user exists, the init endpoint is permanently locked (returns 403) and auto-creation is skipped. --- @@ -98,33 +98,83 @@ Creates the initial admin user. Only works when zero users exist. ## Configuration -Follows the ["Presence implies enabled"](../../../.claude/rules/configurable-features.md) pattern: +System setup is **enabled by default** when BetterAuth is active: | Config | Effect | |--------|--------| -| `systemSetup: undefined` | Disabled (default, backward compatible) | -| `systemSetup: {}` | Enabled | +| *(not set)* | Enabled (when BetterAuth is active) | | `systemSetup: { enabled: false }` | Disabled explicitly | +| `systemSetup: { initialAdmin: { ... } }` | Enabled with auto-creation | ```typescript // config.env.ts -{ - systemSetup: {}, - betterAuth: { - emailAndPassword: { - disableSignUp: true, // System setup bypasses this - }, +// Enabled by default - no config needed + +// Disable explicitly +systemSetup: { enabled: false }, + +// Enable with auto-creation (for automated deployments) +systemSetup: { + initialAdmin: { + email: process.env.INITIAL_ADMIN_EMAIL, + password: process.env.INITIAL_ADMIN_PASSWORD, }, -} +}, +``` + +--- + +## Auto-Creation via Config/ENV + +For automated deployments where no manual REST call is possible (Docker, CI/CD, Kubernetes), configure initial admin credentials via environment variables: + +### Via NSC Environment Variables + +```bash +NSC__systemSetup__initialAdmin__email=admin@example.com +NSC__systemSetup__initialAdmin__password=YourSecurePassword123! +NSC__systemSetup__initialAdmin__name=Admin # optional +``` + +### Via config.env.ts + +```typescript +systemSetup: { + initialAdmin: { + email: process.env.INITIAL_ADMIN_EMAIL, + password: process.env.INITIAL_ADMIN_PASSWORD, + name: 'Admin', + }, +}, +``` + +### Via NEST_SERVER_CONFIG JSON + +```bash +NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example.com", "password": "SecurePassword123!" } } }' ``` +### Behavior + +- The admin is created automatically during application bootstrap (`OnApplicationBootstrap`) +- Same zero-user guard applies: only works when no users exist +- If users already exist, auto-creation is silently skipped (no error) +- Race conditions between multiple instances are handled gracefully + +### Security Best Practices + +1. **Remove credentials after first deployment** - Once the admin exists, the ENV vars are unused +2. **Use secrets management** - Docker Secrets, Kubernetes Secrets, Vault, etc. +3. **Never commit credentials** - Use `.env` files (gitignored) or external secret stores +4. **Use strong passwords** - Minimum 8 characters, recommended 16+ + --- ## Security 1. **Zero-user guard** - Init only works when `countDocuments({}) === 0` -2. **Opt-in only** - Module not loaded unless `systemSetup` is configured +2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists 3. **Race condition protection** - MongoDB unique email index prevents duplicates 4. **Permanent lock** - Once any user exists, init returns 403 5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled @@ -181,13 +231,22 @@ if (needsSetup) { 1. Ensure `betterAuth` is configured in `config.env.ts` 2. Verify BetterAuth is running (check server startup logs) +### Auto-creation not working + +**Cause:** Missing or incomplete ENV variables. + +**Solutions:** +1. Verify both `email` and `password` are set +2. Check server logs for `Auto-created initial admin on startup` or warning messages +3. Ensure BetterAuth is fully initialized (check startup logs) + ### Endpoints return 404 -**Cause:** System setup module is not loaded. +**Cause:** System setup module is disabled. **Solutions:** -1. Add `systemSetup: {}` to `config.env.ts` -2. Verify the module is imported (check server startup logs for `CoreSystemSetupController`) +1. Check that BetterAuth is enabled (system setup requires it) +2. Ensure `systemSetup` is not set to `{ enabled: false }` --- diff --git a/src/core/modules/system-setup/core-system-setup.service.ts b/src/core/modules/system-setup/core-system-setup.service.ts index 2a7a864..b257a5d 100644 --- a/src/core/modules/system-setup/core-system-setup.service.ts +++ b/src/core/modules/system-setup/core-system-setup.service.ts @@ -1,7 +1,9 @@ -import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { ForbiddenException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { InjectConnection } from '@nestjs/mongoose'; +import { isEmail } from 'class-validator'; import { Connection } from 'mongoose'; +import { ConfigService } from '../../common/services/config.service'; import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.mapper'; import { CoreBetterAuthService } from '../better-auth/core-better-auth.service'; import { ErrorCode } from '../error-code/error-codes'; @@ -45,15 +47,79 @@ export interface SystemSetupStatus { * - Race conditions handled by MongoDB unique email index */ @Injectable() -export class CoreSystemSetupService { +export class CoreSystemSetupService implements OnApplicationBootstrap { private readonly logger = new Logger(CoreSystemSetupService.name); constructor( @InjectConnection() private readonly connection: Connection, private readonly betterAuthService: CoreBetterAuthService, private readonly userMapper: CoreBetterAuthUserMapper, + private readonly configService: ConfigService, ) {} + /** + * Automatically create the initial admin on server start if configured via + * `systemSetup.initialAdmin` in config or ENV variables. + * + * Uses OnApplicationBootstrap (not OnModuleInit) to ensure BetterAuth + * is fully initialized before attempting user creation. + */ + async onApplicationBootstrap(): Promise { + const initialAdmin = this.configService.configFastButReadOnly?.systemSetup?.initialAdmin; + + // No initialAdmin config at all → skip silently + if (!initialAdmin) { + return; + } + + // Partial credentials → warn and skip + if (!initialAdmin.email || !initialAdmin.password) { + const missing = [ + !initialAdmin.email && 'email', + !initialAdmin.password && 'password', + ].filter(Boolean).join(', '); + this.logger.warn(`Incomplete initialAdmin config - missing: ${missing}. Auto-creation skipped.`); + return; + } + + // Validate email format (same validator as @IsEmail() decorator) + if (!isEmail(initialAdmin.email)) { + this.logger.warn(`Invalid initialAdmin email format: "${initialAdmin.email}". Auto-creation skipped.`); + return; + } + + // Validate password is not empty/whitespace + if (!initialAdmin.password.trim()) { + this.logger.warn('Empty initialAdmin password. Auto-creation skipped.'); + return; + } + + const status = await this.getSetupStatus(); + if (!status.needsSetup) { + return; + } + + if (!status.betterAuthEnabled) { + this.logger.warn('Initial admin auto-creation skipped: BetterAuth not enabled'); + return; + } + + try { + const result = await this.createInitialAdmin({ + email: initialAdmin.email, + name: initialAdmin.name, + password: initialAdmin.password, + }); + this.logger.log(`Auto-created initial admin on startup: ${result.email}`); + } catch (error) { + if (error instanceof ForbiddenException) { + this.logger.log('Initial admin auto-creation skipped (users already exist)'); + } else { + this.logger.warn(`Initial admin auto-creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + } + /** * Check if the system needs initial setup (zero users) */ diff --git a/tests/stories/auth-scenarios.e2e-spec.ts b/tests/stories/auth-scenarios.e2e-spec.ts index 97a02e2..b76a4d3 100644 --- a/tests/stories/auth-scenarios.e2e-spec.ts +++ b/tests/stories/auth-scenarios.e2e-spec.ts @@ -94,12 +94,17 @@ describe('Story: Authentication Scenarios', () => { }); afterAll(async () => { - // Clean up test users + // Clean up test users (filtered by userId to avoid interfering with parallel tests) if (db) { for (const email of testEmails) { - await db.collection('users').deleteOne({ email }); - await db.collection('account').deleteMany({}); - await db.collection('session').deleteMany({}); + const user = await db.collection('users').findOne({ email }); + if (user) { + const userIds: any[] = [user._id, user._id.toString()]; + if (user.iamId) userIds.push(user.iamId); + await db.collection('users').deleteOne({ _id: user._id }); + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + } } for (const iamId of testIamUserIds) { await db.collection('users').deleteOne({ id: iamId }); diff --git a/tests/stories/better-auth-autoregister-false.e2e-spec.ts b/tests/stories/better-auth-autoregister-false.e2e-spec.ts index 22a5c8d..b70390b 100644 --- a/tests/stories/better-auth-autoregister-false.e2e-spec.ts +++ b/tests/stories/better-auth-autoregister-false.e2e-spec.ts @@ -133,9 +133,8 @@ describe('Story: BetterAuth autoRegister: false (Pattern 3)', () => { const port = (httpServer.address() as any).port; testHelper = new TestHelper(app, `ws://127.0.0.1:${port}/graphql`); - mongoClient = new MongoClient('mongodb://127.0.0.1:27017'); - await mongoClient.connect(); - db = mongoClient.db('nest-server-local'); + mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + db = mongoClient.db(); // Create a test user userEmail = generateTestEmail('autoregister-false'); @@ -150,11 +149,19 @@ describe('Story: BetterAuth autoRegister: false (Pattern 3)', () => { }, 60000); afterAll(async () => { - // Cleanup test data + // Cleanup test data (filtered by userId to avoid interfering with parallel tests) if (db) { + const testUsers = await db.collection('users').find({ email: { $regex: /^reg-test-autoregister-false/ } }).toArray(); + const userIds = testUsers.flatMap((u) => { + const ids: any[] = [u._id, u._id.toString()]; + if (u.iamId) ids.push(u.iamId); + return ids; + }); await db.collection('users').deleteMany({ email: { $regex: /^reg-test-autoregister-false/ } }); - await db.collection('session').deleteMany({}); - await db.collection('account').deleteMany({}); + if (userIds.length > 0) { + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + } } if (mongoClient) { await mongoClient.close(); diff --git a/tests/stories/better-auth-module-registration.e2e-spec.ts b/tests/stories/better-auth-module-registration.e2e-spec.ts index b638804..f8f7bea 100644 --- a/tests/stories/better-auth-module-registration.e2e-spec.ts +++ b/tests/stories/better-auth-module-registration.e2e-spec.ts @@ -228,9 +228,8 @@ describe('Story: BetterAuth Module Registration', () => { const port = (httpServer.address() as any).port; testHelper = new TestHelper(app, `ws://127.0.0.1:${port}/graphql`); - mongoClient = new MongoClient('mongodb://127.0.0.1:27017'); - await mongoClient.connect(); - db = mongoClient.db('nest-server-local'); + mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + db = mongoClient.db(); // Create a test user userEmail = generateTestEmail('config-pattern'); @@ -245,11 +244,19 @@ describe('Story: BetterAuth Module Registration', () => { }, 60000); afterAll(async () => { - // Cleanup test data + // Cleanup test data (filtered by userId to avoid interfering with parallel tests) if (db) { + const testUsers = await db.collection('users').find({ email: { $regex: /^reg-test-config-pattern/ } }).toArray(); + const userIds = testUsers.flatMap((u) => { + const ids: any[] = [u._id, u._id.toString()]; + if (u.iamId) ids.push(u.iamId); + return ids; + }); await db.collection('users').deleteMany({ email: { $regex: /^reg-test-config-pattern/ } }); - await db.collection('session').deleteMany({}); - await db.collection('account').deleteMany({}); + if (userIds.length > 0) { + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + } } if (mongoClient) { await mongoClient.close(); diff --git a/tests/stories/bidirectional-auth-sync.e2e-spec.ts b/tests/stories/bidirectional-auth-sync.e2e-spec.ts index 4af2883..062201f 100644 --- a/tests/stories/bidirectional-auth-sync.e2e-spec.ts +++ b/tests/stories/bidirectional-auth-sync.e2e-spec.ts @@ -96,15 +96,17 @@ describe('Story: Bidirectional Auth Sync', () => { }); afterAll(async () => { - // Clean up test users + // Clean up test users (filtered by userId to avoid interfering with parallel tests) if (db) { for (const email of testEmails) { - await db.collection('users').deleteOne({ email }); - // Better-Auth uses default collection names (no prefix) - // 'users' is shared with Legacy (configured via modelName) - // 'account' and 'session' use default names - await db.collection('account').deleteMany({}); - await db.collection('session').deleteMany({}); + const user = await db.collection('users').findOne({ email }); + if (user) { + const userIds: any[] = [user._id, user._id.toString()]; + if (user.iamId) userIds.push(user.iamId); + await db.collection('users').deleteOne({ _id: user._id }); + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + } } for (const iamId of testIamUserIds) { // Clean up by IAM user id diff --git a/tests/stories/scenario-3-http410.e2e-spec.ts b/tests/stories/scenario-3-http410.e2e-spec.ts index a07a020..8f1ca7b 100644 --- a/tests/stories/scenario-3-http410.e2e-spec.ts +++ b/tests/stories/scenario-3-http410.e2e-spec.ts @@ -116,11 +116,19 @@ describe('Story: Scenario 3 - HTTP 410 for Disabled Legacy Endpoints', () => { }); afterAll(async () => { - // Clean up test users + // Clean up test users (filtered by userId to avoid interfering with parallel tests) if (db && testEmails.length > 0) { + const testUsers = await db.collection('users').find({ email: { $in: testEmails } }).toArray(); + const userIds = testUsers.flatMap((u) => { + const ids: any[] = [u._id, u._id.toString()]; + if (u.iamId) ids.push(u.iamId); + return ids; + }); await db.collection('users').deleteMany({ email: { $in: testEmails } }); - await db.collection('account').deleteMany({}); - await db.collection('session').deleteMany({}); + if (userIds.length > 0) { + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + } } if (mongoClient) await mongoClient.close(); if (app) await app.close(); diff --git a/tests/stories/system-setup.e2e-spec.ts b/tests/stories/system-setup.e2e-spec.ts index 0ccc70a..22e8044 100644 --- a/tests/stories/system-setup.e2e-spec.ts +++ b/tests/stories/system-setup.e2e-spec.ts @@ -2,7 +2,7 @@ * Story: System Setup - Initial Admin Creation * * As a developer deploying a fresh system, - * I want to create an initial admin user via REST API, + * I want to create an initial admin user via REST API or via config/ENV, * So that I can access the system even when signup is disabled. * * Test scenarios: @@ -12,15 +12,43 @@ * - Created admin can sign in via BetterAuth * - Status returns needsSetup: false after init * - Init returns 403 when users already exist + * - Auto-creation via config creates admin on bootstrap + * - Auto-created admin can sign in + * - Auto-creation is skipped when users already exist + * + * ISOLATION: This test uses separate temporary MongoDB databases because it + * requires zero users to test the "fresh deployment" scenario. Using the shared + * e2e database would interfere with tests running in parallel. */ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; import { PubSub } from 'graphql-subscriptions'; import { MongoClient } from 'mongodb'; -import { CoreBetterAuthService, HttpExceptionLogFilter, TestHelper } from '../../src'; +import { CoreBetterAuthModule, CoreBetterAuthService, CoreModule, HttpExceptionLogFilter, TestHelper } from '../../src'; import envConfig from '../../src/config.env'; -import { ServerModule } from '../../src/server/server.module'; +import { Any } from '../../src/core/common/scalars/any.scalar'; +import { DateScalar } from '../../src/core/common/scalars/date.scalar'; +import { JSON as JSONScalar } from '../../src/core/common/scalars/json.scalar'; +import { CoreAuthService } from '../../src/core/modules/auth/services/core-auth.service'; +import { CronJobs } from '../../src/server/common/services/cron-jobs.service'; +import { AuthController } from '../../src/server/modules/auth/auth.controller'; +import { AuthModule } from '../../src/server/modules/auth/auth.module'; +import { BetterAuthModule } from '../../src/server/modules/better-auth/better-auth.module'; +import { FileModule } from '../../src/server/modules/file/file.module'; +import { ServerController } from '../../src/server/server.controller'; + +// Isolated database to avoid interfering with parallel tests +const SYSTEM_SETUP_DB = `nest-server-e2e-setup-${Date.now()}`; +const testConfig = { + ...envConfig, + mongoose: { + ...envConfig.mongoose, + uri: `mongodb://127.0.0.1/${SYSTEM_SETUP_DB}`, + }, +}; describe('Story: System Setup', () => { let app; @@ -31,9 +59,6 @@ describe('Story: System Setup', () => { let mongoClient: MongoClient; let db; - // Track created test data for cleanup - const testEmails: string[] = []; - const SETUP_ADMIN_EMAIL = `setup-admin-${Date.now()}@test.com`; const SETUP_ADMIN_PASSWORD = 'TestPassword123!'; const SETUP_ADMIN_NAME = 'Setup Admin'; @@ -44,26 +69,36 @@ describe('Story: System Setup', () => { beforeAll(async () => { try { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [ServerModule], - providers: [ - { - provide: 'PUB_SUB', - useValue: new PubSub(), - }, + CoreBetterAuthModule.reset(); + + @Module({ + controllers: [ServerController, AuthController], + exports: [CoreModule, AuthModule, BetterAuthModule, FileModule], + imports: [ + CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(testConfig.jwt), testConfig), + ScheduleModule.forRoot(), + AuthModule.forRoot(testConfig.jwt), + BetterAuthModule.forRoot({}), + FileModule, ], + providers: [Any, CronJobs, DateScalar, JSONScalar, { provide: 'PUB_SUB', useValue: new PubSub() }], + }) + class SystemSetupTestModule {} + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [SystemSetupTestModule], }).compile(); app = moduleFixture.createNestApplication(); app.useGlobalFilters(new HttpExceptionLogFilter()); - app.setBaseViewsDir(envConfig.templates.path); - app.setViewEngine(envConfig.templates.engine); + app.setBaseViewsDir(testConfig.templates.path); + app.setViewEngine(testConfig.templates.engine); await app.init(); testHelper = new TestHelper(app); betterAuthService = moduleFixture.get(CoreBetterAuthService); - mongoClient = await MongoClient.connect(envConfig.mongoose.uri); + mongoClient = await MongoClient.connect(testConfig.mongoose.uri); db = mongoClient.db(); } catch (e) { console.error('beforeAll Error', e); @@ -72,21 +107,12 @@ describe('Story: System Setup', () => { }); afterAll(async () => { - // Cleanup all test users and associated IAM data + // Drop the entire temporary database (no shared data to worry about) if (db) { - for (const email of testEmails) { - try { - const user = await db.collection('users').findOne({ email }); - if (user) { - await db.collection('users').deleteOne({ _id: user._id }); - if (user.iamId) { - await db.collection('account').deleteMany({ userId: user.iamId }); - await db.collection('session').deleteMany({ userId: user.iamId }); - } - } - } catch { - // Ignore cleanup errors - } + try { + await db.dropDatabase(); + } catch { + // Ignore cleanup errors } } @@ -96,28 +122,16 @@ describe('Story: System Setup', () => { if (app) { await app.close(); } + CoreBetterAuthModule.reset(); }); - // =================================================================================================================== - // Helper: Clear all users to simulate fresh system - // =================================================================================================================== - - async function clearAllUsers() { - // BetterAuth shares the 'users' collection (modelName: 'users') - await db.collection('users').deleteMany({}); - await db.collection('account').deleteMany({}); - await db.collection('session').deleteMany({}); - } - // =================================================================================================================== // Tests // =================================================================================================================== describe('GET /api/system-setup/status', () => { it('should return needsSetup: true when zero users exist', async () => { - // Clear all users to simulate fresh deployment - await clearAllUsers(); - + // Separate DB starts empty - no need to clear anything const result = await testHelper.rest('/api/system-setup/status', { method: 'GET', statusCode: 200, @@ -135,7 +149,6 @@ describe('Story: System Setup', () => { email: 'existing@test.com', roles: [], }); - testEmails.push('existing@test.com'); const result = await testHelper.rest('/api/system-setup/status', { method: 'GET', @@ -145,18 +158,14 @@ describe('Story: System Setup', () => { expect(result).toBeDefined(); expect(result.needsSetup).toBe(false); - // Cleanup the test user + // Cleanup the test user to restore empty DB state await db.collection('users').deleteOne({ email: 'existing@test.com' }); - testEmails.pop(); }); }); describe('POST /api/system-setup/init', () => { it('should create initial admin successfully when zero users exist', async () => { - // Clear all users to simulate fresh deployment - await clearAllUsers(); - testEmails.push(SETUP_ADMIN_EMAIL); - + // DB is empty again after previous test cleanup const result = await testHelper.rest('/api/system-setup/init', { method: 'POST', payload: { @@ -240,3 +249,246 @@ describe('Story: System Setup', () => { }); }); }); + +// ============================================================================= +// Auto-Creation via Config/ENV +// ============================================================================= + +describe('Story: System Setup - Auto-Creation via Config', () => { + const AUTO_DB = `nest-server-e2e-setup-auto-${Date.now()}`; + const AUTO_ADMIN_EMAIL = `auto-admin-${Date.now()}@test.com`; + const AUTO_ADMIN_PASSWORD = 'AutoPassword123!'; + const AUTO_ADMIN_NAME = 'Auto Admin'; + + let app; + let testHelper: TestHelper; + let mongoClient: MongoClient; + let db; + + const autoConfig = { + ...envConfig, + mongoose: { + ...envConfig.mongoose, + uri: `mongodb://127.0.0.1/${AUTO_DB}`, + }, + systemSetup: { + initialAdmin: { + email: AUTO_ADMIN_EMAIL, + name: AUTO_ADMIN_NAME, + password: AUTO_ADMIN_PASSWORD, + }, + }, + }; + + beforeAll(async () => { + try { + CoreBetterAuthModule.reset(); + + @Module({ + controllers: [ServerController, AuthController], + exports: [CoreModule, AuthModule, BetterAuthModule, FileModule], + imports: [ + CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(autoConfig.jwt), autoConfig), + ScheduleModule.forRoot(), + AuthModule.forRoot(autoConfig.jwt), + BetterAuthModule.forRoot({}), + FileModule, + ], + providers: [Any, CronJobs, DateScalar, JSONScalar, { provide: 'PUB_SUB', useValue: new PubSub() }], + }) + class AutoSetupTestModule {} + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AutoSetupTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(autoConfig.templates.path); + app.setViewEngine(autoConfig.templates.engine); + + // app.init() triggers OnApplicationBootstrap → auto-creation + await app.init(); + + testHelper = new TestHelper(app); + mongoClient = await MongoClient.connect(autoConfig.mongoose.uri); + db = mongoClient.db(); + } catch (e) { + console.error('beforeAll Error (auto-creation)', e); + throw e; + } + }); + + afterAll(async () => { + if (db) { + try { + await db.dropDatabase(); + } catch { + // Ignore cleanup errors + } + } + if (mongoClient) { + await mongoClient.close(); + } + if (app) { + await app.close(); + } + CoreBetterAuthModule.reset(); + }); + + it('should have auto-created the initial admin on bootstrap', async () => { + const user = await db.collection('users').findOne({ email: AUTO_ADMIN_EMAIL }); + + expect(user).toBeDefined(); + expect(user.roles).toContain('admin'); + expect(user.iamId).toBeDefined(); + expect(user.name).toBe(AUTO_ADMIN_NAME); + }); + + it('should report needsSetup: false after auto-creation', async () => { + const result = await testHelper.rest('/api/system-setup/status', { + method: 'GET', + statusCode: 200, + }); + + expect(result).toBeDefined(); + expect(result.needsSetup).toBe(false); + }); + + it('should have created BetterAuth account for auto-created admin', async () => { + const user = await db.collection('users').findOne({ email: AUTO_ADMIN_EMAIL }); + expect(user).toBeDefined(); + + const account = await db.collection('account').findOne({ + $or: [{ userId: user._id }, { userId: user.iamId }], + providerId: 'credential', + }); + expect(account).toBeDefined(); + expect(account.password).toBeDefined(); + }); + + it('should allow the auto-created admin to sign in via BetterAuth', async () => { + const signInResult = await testHelper.rest('/iam/sign-in/email', { + method: 'POST', + payload: { + email: AUTO_ADMIN_EMAIL, + password: AUTO_ADMIN_PASSWORD, + }, + statusCode: 200, + }); + + expect(signInResult).toBeDefined(); + expect(signInResult.token || signInResult.user).toBeDefined(); + }); + + it('should return 403 on manual init after auto-creation', async () => { + const result = await testHelper.rest('/api/system-setup/init', { + method: 'POST', + payload: { + email: `another-admin-${Date.now()}@test.com`, + password: 'AnotherPassword123!', + }, + statusCode: 403, + }); + + expect(result).toBeDefined(); + expect(result.message).toContain('LTNS_0050'); + }); +}); + +// ============================================================================= +// Auto-Creation skipped when users exist +// ============================================================================= + +describe('Story: System Setup - Auto-Creation skipped with existing users', () => { + const SKIP_DB = `nest-server-e2e-setup-skip-${Date.now()}`; + const SKIP_ADMIN_EMAIL = `skip-admin-${Date.now()}@test.com`; + + let app; + let mongoClient: MongoClient; + let db; + + const skipConfig = { + ...envConfig, + mongoose: { + ...envConfig.mongoose, + uri: `mongodb://127.0.0.1/${SKIP_DB}`, + }, + systemSetup: { + initialAdmin: { + email: SKIP_ADMIN_EMAIL, + password: 'SkipPassword123!', + }, + }, + }; + + beforeAll(async () => { + try { + // Pre-populate the database with a user BEFORE app bootstrap + mongoClient = await MongoClient.connect(skipConfig.mongoose.uri); + db = mongoClient.db(); + await db.collection('users').insertOne({ + createdAt: new Date(), + email: 'pre-existing@test.com', + roles: ['admin'], + }); + + CoreBetterAuthModule.reset(); + + @Module({ + controllers: [ServerController, AuthController], + exports: [CoreModule, AuthModule, BetterAuthModule, FileModule], + imports: [ + CoreModule.forRoot(CoreAuthService, AuthModule.forRoot(skipConfig.jwt), skipConfig), + ScheduleModule.forRoot(), + AuthModule.forRoot(skipConfig.jwt), + BetterAuthModule.forRoot({}), + FileModule, + ], + providers: [Any, CronJobs, DateScalar, JSONScalar, { provide: 'PUB_SUB', useValue: new PubSub() }], + }) + class SkipSetupTestModule {} + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [SkipSetupTestModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalFilters(new HttpExceptionLogFilter()); + app.setBaseViewsDir(skipConfig.templates.path); + app.setViewEngine(skipConfig.templates.engine); + await app.init(); + } catch (e) { + console.error('beforeAll Error (skip auto-creation)', e); + throw e; + } + }); + + afterAll(async () => { + if (db) { + try { + await db.dropDatabase(); + } catch { + // Ignore cleanup errors + } + } + if (mongoClient) { + await mongoClient.close(); + } + if (app) { + await app.close(); + } + CoreBetterAuthModule.reset(); + }); + + it('should NOT have auto-created the admin when users already exist', async () => { + const autoCreatedUser = await db.collection('users').findOne({ email: SKIP_ADMIN_EMAIL }); + expect(autoCreatedUser).toBeNull(); + }); + + it('should still have the pre-existing user', async () => { + const existingUser = await db.collection('users').findOne({ email: 'pre-existing@test.com' }); + expect(existingUser).toBeDefined(); + expect(existingUser.roles).toContain('admin'); + }); +}); diff --git a/tests/stories/three-scenarios.e2e-spec.ts b/tests/stories/three-scenarios.e2e-spec.ts index 09f3e27..3552aad 100644 --- a/tests/stories/three-scenarios.e2e-spec.ts +++ b/tests/stories/three-scenarios.e2e-spec.ts @@ -75,11 +75,19 @@ describe('Story: Three Authentication Scenarios', () => { }); afterAll(async () => { - // Clean up test users + // Clean up test users (filtered by userId to avoid interfering with parallel tests) if (db && testEmails.length > 0) { + const testUsers = await db.collection('users').find({ email: { $in: testEmails } }).toArray(); + const userIds = testUsers.flatMap((u) => { + const ids: any[] = [u._id, u._id.toString()]; + if (u.iamId) ids.push(u.iamId); + return ids; + }); await db.collection('users').deleteMany({ email: { $in: testEmails } }); - await db.collection('account').deleteMany({}); - await db.collection('session').deleteMany({}); + if (userIds.length > 0) { + await db.collection('account').deleteMany({ userId: { $in: userIds } }); + await db.collection('session').deleteMany({ userId: { $in: userIds } }); + } } if (mongoClient) await mongoClient.close(); if (app) await app.close();