From cd4c59febd2a54202bd93f5010fa162a285fa13a Mon Sep 17 00:00:00 2001 From: Pascal Klesse <54418919+pascal-klesse@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:41:04 +0100 Subject: [PATCH] 11.13.5: Add CoreSystemSetupModule for initial admin creation on fresh deployments * 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 * 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 * Enable SystemSetup by default with BetterAuth, add initialAdmin auto-creation via config/ENV --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Kai Haase --- .claude/rules/architecture.md | 6 +- .claude/rules/configurable-features.md | 1 + .env.example | 10 + CLAUDE.md | 2 +- package-lock.json | 4 +- package.json | 2 +- spectaql.yml | 2 +- src/core.module.ts | 23 +- .../interfaces/server-options.interface.ts | 76 +++ src/core/modules/error-code/error-codes.ts | 31 ++ .../system-setup/INTEGRATION-CHECKLIST.md | 107 ++++ src/core/modules/system-setup/README.md | 267 ++++++++++ .../core-system-setup.controller.ts | 69 +++ .../system-setup/core-system-setup.module.ts | 19 + .../system-setup/core-system-setup.service.ts | 226 ++++++++ src/index.ts | 8 + 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 | 494 ++++++++++++++++++ tests/stories/three-scenarios.e2e-spec.ts | 14 +- 23 files changed, 1398 insertions(+), 44 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..f82f3a9 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` | 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/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/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/core.module.ts b/src/core.module.ts index 0e6e342..6975261 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( @@ -261,16 +262,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 @@ -304,6 +303,12 @@ export class CoreModule implements NestModule { } } + // Add CoreSystemSetupModule when BetterAuth is active + // Enabled by default - disable explicitly via systemSetup: { enabled: false } + if (isBetterAuthEnabled && config.systemSetup?.enabled !== false) { + imports.push(CoreSystemSetupModule); + } + // Set exports const exports: any[] = [ConfigService, EmailService, TemplateService, MailjetService]; if (!process.env.VITEST) { diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index 6e8fc00..3454e7e 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -1429,6 +1429,39 @@ 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). + * + * 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 + * // Enabled by default (no config needed when BetterAuth is active) + * + * // 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, + * }, + * }, + * ``` + */ + systemSetup?: ISystemSetup; + /** * Templates */ @@ -1476,6 +1509,49 @@ 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 + * + * Follows the "presence implies enabled" pattern: + * - `undefined`: Disabled (default, backward compatible) + * - `{}`: Enabled with defaults + * - `{ enabled: false }`: Disabled explicitly + * + * @since 11.14.0 + */ +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; +} + /** * 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..8ac7dfa --- /dev/null +++ b/src/core/modules/system-setup/INTEGRATION-CHECKLIST.md @@ -0,0 +1,107 @@ +# System Setup Integration Checklist + +**For initial admin creation on fresh deployments in projects using `@lenne.tech/nest-server`.** + +> **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 | 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) | + +--- + +## 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: Auto-Creation via ENV (Optional) + +For automated deployments where no manual REST call is possible: + +```bash +# .env or Docker environment +NSC__systemSetup__initialAdmin__email=admin@example.com +NSC__systemSetup__initialAdmin__password=YourSecurePassword123! +NSC__systemSetup__initialAdmin__name=Admin # optional +``` + +Or in `config.env.ts`: + +```typescript +systemSetup: { + initialAdmin: { + email: process.env.INITIAL_ADMIN_EMAIL, + password: process.env.INITIAL_ADMIN_PASSWORD, + }, +}, +``` + +The admin is created automatically on server start when zero users exist. + +**Security:** Remove credentials from ENV after the first successful deployment. + +--- + +## 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:** 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 | +|---------|---------|-----| +| 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 | + +--- + +## 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..368d021 --- /dev/null +++ b/src/core/modules/system-setup/README.md @@ -0,0 +1,267 @@ +# System Setup Module + +Initial admin user creation for fresh deployments of @lenne.tech/nest-server. + +## TL;DR + +System setup is **enabled by default** when BetterAuth is active. No configuration needed. + +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) + +--- + +## Table of Contents + +- [Purpose](#purpose) +- [Endpoints](#endpoints) +- [Configuration](#configuration) +- [Auto-Creation via Config/ENV](#auto-creation-via-configenv) +- [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: + +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) and auto-creation is skipped. + +--- + +## 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 + +System setup is **enabled by default** when BetterAuth is active: + +| Config | Effect | +|--------|--------| +| *(not set)* | Enabled (when BetterAuth is active) | +| `systemSetup: { enabled: false }` | Disabled explicitly | +| `systemSetup: { initialAdmin: { ... } }` | Enabled with auto-creation | + +```typescript +// config.env.ts + +// 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. **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 + +### 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) + +### 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 disabled. + +**Solutions:** +1. Check that BetterAuth is enabled (system setup requires it) +2. Ensure `systemSetup` is not set to `{ enabled: false }` + +--- + +## 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..b257a5d --- /dev/null +++ b/src/core/modules/system-setup/core-system-setup.service.ts @@ -0,0 +1,226 @@ +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'; + +/** + * 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 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) + */ + 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/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 new file mode 100644 index 0000000..22e8044 --- /dev/null +++ b/tests/stories/system-setup.e2e-spec.ts @@ -0,0 +1,494 @@ +/** + * Story: System Setup - Initial Admin Creation + * + * As a developer deploying a fresh system, + * 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: + * - 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 + * - 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 { CoreBetterAuthModule, CoreBetterAuthService, CoreModule, HttpExceptionLogFilter, TestHelper } from '../../src'; +import envConfig from '../../src/config.env'; +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; + let testHelper: TestHelper; + let betterAuthService: CoreBetterAuthService; + + // Database + let mongoClient: MongoClient; + let db; + + 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 { + 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(testConfig.templates.path); + app.setViewEngine(testConfig.templates.engine); + await app.init(); + + testHelper = new TestHelper(app); + betterAuthService = moduleFixture.get(CoreBetterAuthService); + + mongoClient = await MongoClient.connect(testConfig.mongoose.uri); + db = mongoClient.db(); + } catch (e) { + console.error('beforeAll Error', e); + throw e; + } + }); + + afterAll(async () => { + // Drop the entire temporary database (no shared data to worry about) + if (db) { + try { + await db.dropDatabase(); + } catch { + // Ignore cleanup errors + } + } + + if (mongoClient) { + await mongoClient.close(); + } + if (app) { + await app.close(); + } + CoreBetterAuthModule.reset(); + }); + + // =================================================================================================================== + // Tests + // =================================================================================================================== + + describe('GET /api/system-setup/status', () => { + it('should return needsSetup: true when zero users exist', async () => { + // Separate DB starts empty - no need to clear anything + 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: [], + }); + + 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 to restore empty DB state + await db.collection('users').deleteOne({ email: 'existing@test.com' }); + }); + }); + + describe('POST /api/system-setup/init', () => { + it('should create initial admin successfully when zero users exist', async () => { + // DB is empty again after previous test cleanup + 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'); + }); + }); +}); + +// ============================================================================= +// 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();