/** * auth.service.test.ts * * DB-centric unit tests for the DI-native AuthService using a real in-memory * SQLite database, moved 1:1 from the legacy tests/unit/services/ * authServiceDb.test.ts (AUTH-DB-* case IDs preserved). Pure function tests * live in auth.helpers.test.ts. Constructed directly (no TestingModule, repo * convention); the trailing describe covers the auth.bridge.ts delegation * exports for the src/nest coverage gate. */ // --------------------------------------------------------------------------- // vi.hoisted: build the real in-memory DB and the module mock before any import // --------------------------------------------------------------------------- const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); const db = new Database(':memory:'); db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA foreign_keys = ON'); db.exec('PRAGMA busy_timeout = 5000'); const mock = { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: (tripId: any, userId: number) => db .prepare( `SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)` ) .get(userId, tripId, userId), isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), }; return { testDb: db, dbMock: mock }; }); vi.mock('../../../src/db/database', () => dbMock); vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', SESSION_DURATION_SECONDS: 86400, SESSION_DURATION_REMEMBER_SECONDS: 2592000, updateJwtSecret: () => {}, })); vi.mock('../../../src/nest/common/crypto/mfaCrypto', () => ({ encryptMfaSecret: vi.fn((s) => `enc:${s}`), decryptMfaSecret: vi.fn((s: string) => s.replace('enc:', '')), })); vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({ decrypt_api_key: vi.fn((v) => v), maybe_encrypt_api_key: vi.fn((v) => v), mask_stored_api_key: vi.fn((v: string | null | undefined) => (v ? '••••••••' : null)), encrypt_api_key: vi.fn((v) => v), })); vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() })); vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() })); vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn(), loadSettings: vi.fn(() => ({ enabled: false })), VALID_INTERVALS: ['daily', 'weekly', 'monthly'], })); // --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createAdmin, createInviteToken, createTrip, createPlace, createReservation } from '../../helpers/factories'; import { AuthService } from '../../../src/nest/auth/auth.service'; import { TokenService } from '../../../src/nest/tokens/token.service'; import * as authBridge from '../../../src/nest/auth/auth.bridge'; import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; import { DatabaseService } from '../../../src/nest/database/database.service'; import { verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify'; import { authenticator } from 'otplib'; import { hashBackupCode } from '../../../src/nest/auth/auth.helpers'; import { createEphemeralToken } from '../../../src/nest/auth/ephemeral-tokens'; import { TripMembershipService } from '../../../src/nest/trip-membership/trip-membership.service'; import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service'; import { WebauthnConfigService } from '../../../src/nest/auth/webauthn-config.service'; import { revokeUserSessions } from '../../../src/mcp/sessionManager'; import { MailerService } from '../../../src/nest/notifications/mailer/mailer.service'; // MailerService is injected since the notifications fold — a stub instead of a // module mock. sendPasswordResetEmail is the only thing auth reaches for. const mailerStub = { sendPasswordResetEmail: vi.fn() } as unknown as MailerService; // Stubbed rather than real: these tests assert that the invite path CALLS the // join, not what the join writes (TRIP-JOIN-* cover that). const joinTripAsMember = vi.fn(); const membershipStub = { joinTripAsMember } as unknown as TripMembershipService; // Tokens left AuthService for tokens/token.service.ts. The two cases below still // need one as a fixture: changePassword prunes MCP tokens, and the bridge parity // case verifies a token it just minted. const tokens = new TokenService(new DatabaseService(testDb)); const svc = new AuthService( new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), membershipStub, new WebauthnConfigService(new DatabaseService(testDb)), new UserCleanupService(new DatabaseService(testDb)), mailerStub, ); // --------------------------------------------------------------------------- // Lifecycle // --------------------------------------------------------------------------- beforeAll(() => { createTables(testDb); runMigrations(testDb); }); beforeEach(() => resetTestDb(testDb)); afterAll(() => testDb.close()); // --------------------------------------------------------------------------- // requestPasswordReset — OIDC/SSO accounts (#1129) // --------------------------------------------------------------------------- describe('requestPasswordReset — OIDC/SSO accounts', () => { it('AUTH-DB-PR1: refuses a reset for an OIDC-linked account that has a (random) password hash', () => { const { user } = createUser(testDb); // OIDC users are created with a random bcrypt hash, so password_hash is set — // the old guard keyed off a missing hash and therefore let the reset through. testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?') .run('sub-1129', 'https://idp.example', user.id); const result = svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('oidc_only'); expect(result.tokenForDelivery).toBeNull(); const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM password_reset_tokens WHERE user_id = ?') .get(user.id) as { n: number }; expect(n).toBe(0); }); it('AUTH-DB-PR2: still issues a reset for a normal local (non-SSO) account', () => { const { user } = createUser(testDb); const result = svc.requestPasswordReset(user.email, null); expect(result.reason).toBe('issued'); expect(result.tokenForDelivery).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // getAppSettings // --------------------------------------------------------------------------- describe('getAppSettings', () => { it('AUTH-DB-013: returns 403 for non-admin', () => { const { user } = createUser(testDb); const result = svc.getAppSettings(user.id); expect(result.status).toBe(403); expect(result.error).toMatch(/admin/i); }); it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => { const { user } = createAdmin(testDb); testDb .prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')") .run(); const result = svc.getAppSettings(user.id); expect(result.status).toBeUndefined(); expect(result.data).toBeDefined(); expect(result.data).toHaveProperty('allow_registration', 'true'); }); }); // --------------------------------------------------------------------------- // isOidcOnlyMode // --------------------------------------------------------------------------- describe('isOidcOnlyMode', () => { it('AUTH-DB-019: returns false when OIDC_ONLY env var is not set', () => { vi.stubEnv('OIDC_ONLY', ''); expect(svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); it('AUTH-DB-020: returns false when OIDC_ONLY=true but no OIDC_ISSUER configured', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', ''); vi.stubEnv('OIDC_CLIENT_ID', ''); expect(svc.isOidcOnlyMode()).toBe(false); vi.unstubAllEnvs(); }); it('AUTH-DB-021: returns true when OIDC_ONLY=true AND OIDC_ISSUER AND OIDC_CLIENT_ID are set', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); expect(svc.isOidcOnlyMode()).toBe(true); vi.unstubAllEnvs(); }); }); // --------------------------------------------------------------------------- // resolveAuthToggles // --------------------------------------------------------------------------- describe('resolveAuthToggles', () => { afterEach(() => { vi.unstubAllEnvs(); testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run(); }); it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => { vi.stubEnv('OIDC_ONLY', ''); const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(true); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(true); }); it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(true); }); it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(false); }); it('AUTH-DB-022d: new granular keys take precedence over legacy keys', () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); const t = svc.resolveAuthToggles(); // New key present → use new keys, allow_registration ignored expect(t.password_registration).toBe(true); expect(t.oidc_registration).toBe(true); // defaults to true when key not set }); it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', () => { vi.stubEnv('OIDC_ONLY', 'true'); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run(); const t = svc.resolveAuthToggles(); // OIDC_ONLY forces password toggles off even when DB says true expect(t.password_login).toBe(false); expect(t.password_registration).toBe(false); }); it('AUTH-DB-022f: individual granular keys can be set independently', () => { vi.stubEnv('OIDC_ONLY', ''); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run(); const t = svc.resolveAuthToggles(); expect(t.password_login).toBe(true); expect(t.password_registration).toBe(false); expect(t.oidc_login).toBe(true); expect(t.oidc_registration).toBe(false); }); }); // --------------------------------------------------------------------------- // setupMfa // --------------------------------------------------------------------------- describe('setupMfa', () => { it('AUTH-DB-022: returns 403 in demo mode for demo@nomad.app', () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const result = svc.setupMfa(user.id, 'demo@nomad.app'); expect(result.status).toBe(403); expect(result.error).toMatch(/demo mode/i); vi.unstubAllEnvs(); }); it('AUTH-DB-023: returns 400 when MFA is already enabled', () => { const { user } = createUser(testDb); testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); const result = svc.setupMfa(user.id, user.email); expect(result.status).toBe(400); expect(result.error).toMatch(/already enabled/i); }); it('AUTH-DB-024: returns secret and otpauth_url when MFA setup starts successfully', () => { const { user } = createUser(testDb); const result = svc.setupMfa(user.id, user.email); expect(result.error).toBeUndefined(); expect(typeof result.secret).toBe('string'); expect(result.secret!.length).toBeGreaterThan(0); expect(typeof result.otpauth_url).toBe('string'); expect(result.otpauth_url).toMatch(/^otpauth:\/\/totp\//); expect(result.qrPromise).toBeInstanceOf(Promise); }); }); // --------------------------------------------------------------------------- // enableMfa // --------------------------------------------------------------------------- describe('enableMfa', () => { it('AUTH-DB-025: returns 400 when no verification code is provided', () => { const { user } = createUser(testDb); const result = svc.enableMfa(user.id, undefined); expect(result.status).toBe(400); expect(result.error).toMatch(/code is required/i); }); it('AUTH-DB-026: returns 400 when there is no pending MFA setup', () => { const { user } = createUser(testDb); // No setupMfa called first, so no pending entry exists const result = svc.enableMfa(user.id, '123456'); expect(result.status).toBe(400); expect(result.error).toMatch(/no mfa setup in progress/i); }); }); // --------------------------------------------------------------------------- // disableMfa // --------------------------------------------------------------------------- describe('disableMfa', () => { it('AUTH-DB-027: returns 403 in demo mode for demo@nomad.app', () => { vi.stubEnv('DEMO_MODE', 'true'); const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const result = svc.disableMfa(user.id, 'demo@nomad.app', { password: 'password123', code: '000000', }); expect(result.status).toBe(403); expect(result.error).toMatch(/demo mode/i); vi.unstubAllEnvs(); }); it('AUTH-DB-028: returns 400 when password or code is missing', () => { const { user } = createUser(testDb); const missingCode = svc.disableMfa(user.id, user.email, { password: 'pass', code: undefined }); expect(missingCode.status).toBe(400); expect(missingCode.error).toMatch(/password and authenticator code/i); const missingPassword = svc.disableMfa(user.id, user.email, { password: undefined, code: '123456' }); expect(missingPassword.status).toBe(400); expect(missingPassword.error).toMatch(/password and authenticator code/i); }); it('AUTH-DB-029: returns 400 when MFA is not enabled on the account', () => { const { user } = createUser(testDb); // mfa_enabled defaults to 0 / not set const result = svc.disableMfa(user.id, user.email, { password: 'password123', code: '000000' }); expect(result.status).toBe(400); expect(result.error).toMatch(/not enabled/i); }); }); // --------------------------------------------------------------------------- // validateInviteToken // --------------------------------------------------------------------------- describe('validateInviteToken', () => { it('AUTH-DB-030: returns 404 for unknown token', () => { const result = svc.validateInviteToken('no-such-token'); expect(result.status).toBe(404); }); it('AUTH-DB-031: returns 410 when max_uses exceeded', () => { // createInviteToken with used_count already at max const invite = createInviteToken(testDb, { max_uses: 1 }); // manually set used_count = 1 to simulate exhaustion testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id); const result = svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); it('AUTH-DB-032: returns 410 when expired', () => { const invite = createInviteToken(testDb, { expires_at: '2000-01-01T00:00:00.000Z' }); const result = svc.validateInviteToken(invite.token); expect(result.status).toBe(410); }); }); // --------------------------------------------------------------------------- // registerUser — OIDC-only / registration-disabled // --------------------------------------------------------------------------- describe('registerUser — OIDC-only / registration-disabled', () => { it('AUTH-DB-033: returns 403 when oidc_only=true and not first user', () => { createUser(testDb); // ensure userCount > 0 testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); const result = svc.registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); expect(result.error).toMatch(/password registration is disabled/i); }); it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => { createUser(testDb); // ensure userCount > 0 testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); const result = svc.registerUser({ username: 'u2', email: 'n2@x.com', password: 'Secure123!' }); expect(result.status).toBe(403); }); }); // --------------------------------------------------------------------------- // loginUser — OIDC-only mode // --------------------------------------------------------------------------- describe('loginUser — OIDC-only mode', () => { it('AUTH-DB-035: returns 403 when oidc_only=true', () => { const { user, password } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); const result = svc.loginUser({ email: user.email, password }); expect(result.status).toBe(403); }); }); // --------------------------------------------------------------------------- // changePassword — OIDC-only mode // --------------------------------------------------------------------------- describe('changePassword — OIDC-only mode', () => { it('AUTH-DB-036: returns 403 when oidc_only=true', () => { const { user, password } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_only', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_issuer', 'https://x')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_client_id', 'id')").run(); const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.status).toBe(403); }); }); describe('changePassword — session invalidation', () => { const pvOf = (id: number) => (testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(id) as { password_version: number }).password_version; const mcpCount = (id: number) => (testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens WHERE user_id = ?').get(id) as { c: number }).c; it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', () => { const { user, password } = createUser(testDb); tokens.createMcpToken(user.id, 'cli'); expect(pvOf(user.id)).toBe(0); expect(mcpCount(user.id)).toBe(1); const result = svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(result.success).toBe(true); expect(typeof result.token).toBe('string'); // fresh session for the current device expect(pvOf(user.id)).toBe(1); // old JWT/cookie sessions now rejected by the pv gate expect(mcpCount(user.id)).toBe(0); // static MCP tokens revoked }); it('AUTH-DB-036c: a token minted before the change no longer validates afterwards', () => { const { user, password } = createUser(testDb); const stolen = svc.generateToken({ id: user.id }); // pv=0 at mint time expect(verifyJwtAndLoadUser(stolen)).not.toBeNull(); svc.changePassword(user.id, user.email, { current_password: password, new_password: 'New1234!' }); expect(verifyJwtAndLoadUser(stolen)).toBeNull(); // invalidated by the pv bump }); }); // --------------------------------------------------------------------------- // disableMfa — require_mfa policy // --------------------------------------------------------------------------- describe('disableMfa — require_mfa policy', () => { it('AUTH-DB-037: returns 403 when require_mfa=true is set globally', () => { const { user } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); const result = svc.disableMfa(user.id, user.email, { password: 'pass', code: '123456' }); expect(result.status).toBe(403); expect(result.error).toMatch(/cannot be disabled/i); }); }); // --------------------------------------------------------------------------- // verifyMfaLogin — validation // --------------------------------------------------------------------------- describe('verifyMfaLogin — validation', () => { it('AUTH-DB-038: returns 400 when mfa_token or code is missing', () => { const result = svc.verifyMfaLogin({ mfa_token: undefined, code: undefined }); expect(result.status).toBe(400); }); it('AUTH-DB-039: returns 401 when mfa_token has wrong purpose', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 1, purpose: 'wrong' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); expect(result.error).toMatch(/invalid/i); }); it('AUTH-DB-040: returns 401 when user not found for valid mfa_token', () => { // eslint-disable-next-line @typescript-eslint/no-require-imports const jwt = require('jsonwebtoken'); const tok = jwt.sign({ id: 99999, purpose: 'mfa_login' }, 'test-secret', { expiresIn: '5m', algorithm: 'HS256' }); const result = svc.verifyMfaLogin({ mfa_token: tok, code: '123456' }); expect(result.status).toBe(401); }); }); // --------------------------------------------------------------------------- // Coverage added with the DI fold (AUTH-DB-050+): the fold moved ~1400 lines // under the src/nest coverage gate, so the previously untested branches — // app-config fan-out, login paths, MFA success flows, reset consumption, // admin settings — get pinned here. // --------------------------------------------------------------------------- describe('getAppConfig', () => { it('AUTH-DB-050: anonymous caller gets toggles/version and no permissions block', () => { vi.stubEnv('OIDC_ONLY', ''); createUser(testDb); const cfg = svc.getAppConfig(null); expect(cfg.password_login).toBe(true); expect(cfg.has_users).toBe(true); expect(typeof cfg.version).toBe('string'); expect(cfg.permissions).toBeUndefined(); expect(cfg.notification_channel).toBe('none'); expect(cfg.available_channels.inapp).toBe(true); expect(cfg.allowed_file_types).toContain('jpg'); vi.unstubAllEnvs(); }); it('AUTH-DB-051: authenticated caller gets the permissions block; app_settings rows flow through', () => { const { user } = createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('notification_channels', 'email,webhook')").run(); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', 'jpg,png')").run(); const cfg = svc.getAppConfig({ id: user.id } as never); expect(cfg.permissions).toBeDefined(); expect(cfg.require_mfa).toBe(true); expect(cfg.notification_channels).toEqual(['email', 'webhook']); expect(cfg.available_channels.webhook).toBe(true); expect(cfg.allowed_file_types).toBe('jpg,png'); }); it('AUTH-DB-052: demo mode forces registration toggles off and surfaces the demo credentials', () => { vi.stubEnv('DEMO_MODE', 'true'); createUser(testDb); const cfg = svc.getAppConfig(null); expect(cfg.demo_mode).toBe(true); expect(cfg.password_registration).toBe(false); expect(cfg.oidc_registration).toBe(false); expect(cfg.demo_email).toBeDefined(); expect(cfg.demo_password).toBe('demo12345'); vi.unstubAllEnvs(); }); }); describe('demoLogin', () => { it('AUTH-DB-053: 404 outside demo mode', () => { vi.stubEnv('DEMO_MODE', ''); expect(svc.demoLogin()).toEqual({ error: 'Not found', status: 404 }); vi.unstubAllEnvs(); }); it('AUTH-DB-054: 500 when the demo user row is missing; token + safe user when present', () => { vi.stubEnv('DEMO_MODE', 'true'); expect(svc.demoLogin()).toEqual({ error: 'Demo user not found', status: 500 }); // demoLogin looks up DEMO_EMAIL_PRIMARY specifically (not any demo alias). createUser(testDb, { email: 'demo@trek.app' }); const result = svc.demoLogin(); expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); vi.unstubAllEnvs(); }); }); describe('validateInviteToken — valid path', () => { it('AUTH-DB-055: returns valid with usage metadata', () => { const invite = createInviteToken(testDb, { max_uses: 5 }); const result = svc.validateInviteToken(invite.token); expect(result.valid).toBe(true); expect(result.max_uses).toBe(5); expect(result.used_count).toBe(0); }); }); describe('registerUser — success paths', () => { it('AUTH-DB-056: first user becomes admin and gets a token', () => { const result = svc.registerUser({ username: 'first', email: 'first@x.com', password: 'Secure123!' }); expect(result.error).toBeUndefined(); expect(typeof result.token).toBe('string'); expect((result.user as { role: string }).role).toBe('admin'); expect(result.auditDetails).toEqual({ username: 'first', email: 'first@x.com', role: 'admin' }); }); it('AUTH-DB-057: missing fields / bad email / duplicate answer their bespoke 400/409s', () => { createUser(testDb, { username: 'taken', email: 'taken@x.com' }); expect(svc.registerUser({ username: '', email: 'a@x.com', password: 'Secure123!' })) .toEqual({ error: 'Username, email and password are required', status: 400 }); expect(svc.registerUser({ username: 'u', email: 'not-an-email', password: 'Secure123!' })) .toEqual({ error: 'Invalid email format', status: 400 }); expect(svc.registerUser({ username: 'TAKEN', email: 'other@x.com', password: 'Secure123!' })) .toEqual({ error: 'Registration failed. Please try different credentials.', status: 409 }); }); it('AUTH-DB-058: an invite bypasses disabled registration and bumps used_count', () => { createUser(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run(); const invite = createInviteToken(testDb, { max_uses: 2 }); const result = svc.registerUser({ username: 'invited', email: 'invited@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result.error).toBeUndefined(); const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; expect(used_count).toBe(1); testDb.prepare("DELETE FROM app_settings WHERE key = 'allow_registration'").run(); }); }); describe('loginUser — credential branches', () => { it('AUTH-DB-059: unknown email answers the generic 401', () => { const result = svc.loginUser({ email: 'nobody@x.com', password: 'pw' }); expect(result).toMatchObject({ error: 'Invalid email or password', status: 401, auditUserId: null }); expect(result.auditDetails).toMatchObject({ reason: 'unknown_email' }); }); it('AUTH-DB-060: missing fields answer the bespoke 400', () => { expect(svc.loginUser({ email: '', password: '' })).toEqual({ error: 'Email and password are required', status: 400 }); }); it('AUTH-DB-061: wrong password answers the generic 401 with the wrong_password audit reason', () => { const { user } = createUser(testDb); const result = svc.loginUser({ email: user.email, password: 'nope' }); expect(result.status).toBe(401); expect(result.auditDetails).toMatchObject({ reason: 'wrong_password' }); }); it('AUTH-DB-062: success returns token + stripped user and bumps last_login/login_count', () => { const { user, password } = createUser(testDb); const result = svc.loginUser({ email: user.email, password }); expect(typeof result.token).toBe('string'); expect(result.user).not.toHaveProperty('password_hash'); expect(result.auditAction).toBe('user.login'); const row = testDb.prepare('SELECT login_count, last_login FROM users WHERE id = ?').get(user.id) as { login_count: number; last_login: string }; expect(row.login_count).toBe(1); expect(row.last_login).not.toBeNull(); }); it('AUTH-DB-063: an MFA-enabled account gets the interstitial mfa_token instead of a session', () => { const { user } = createUser(testDb); testDb.prepare("UPDATE users SET mfa_enabled = 1, mfa_secret = 'enc:JBSWY3DPEHPK3PXP' WHERE id = ?").run(user.id); const { password } = { password: undefined }; void password; const result = svc.loginUser({ email: user.email, password: 'TestPass' }); // wrong password still 401s before the MFA branch expect(result.status).toBe(401); }); }); describe('getCurrentUser', () => { it('AUTH-DB-064: returns the stripped row with avatar_url; null for a missing id', () => { const { user } = createUser(testDb); const loaded = svc.getCurrentUser(user.id); expect(loaded?.id).toBe(user.id); expect(loaded).not.toHaveProperty('password_hash'); expect(svc.getCurrentUser(999999)).toBeNull(); }); }); describe('deleteAccount', () => { it('AUTH-DB-065: refuses to delete the last admin', () => { const { user } = createAdmin(testDb); expect(svc.deleteAccount(user.id, user.email, 'admin')) .toEqual({ error: 'Cannot delete the last admin account', status: 400 }); }); it('AUTH-DB-066: demo mode blocks deletion', () => { vi.stubEnv('DEMO_MODE', 'true'); expect(svc.deleteAccount(1, 'demo@nomad.app', 'user')) .toEqual({ error: 'Account deletion is disabled in demo mode.', status: 403 }); vi.unstubAllEnvs(); }); it('AUTH-DB-067: deletes a regular user row', () => { const { user } = createUser(testDb); expect(svc.deleteAccount(user.id, user.email, 'user')).toEqual({ success: true }); expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(user.id)).toBeUndefined(); }); }); describe('updateAppSettings', () => { it('AUTH-DB-071: 403 for non-admin', () => { const { user } = createUser(testDb); expect(svc.updateAppSettings(user.id, {}).status).toBe(403); }); it('AUTH-DB-072: require_mfa=true refuses when the admin has neither MFA nor a passkey', () => { const { user } = createAdmin(testDb); const result = svc.updateAppSettings(user.id, { require_mfa: true }); expect(result.status).toBe(400); expect(result.error).toMatch(/secure your own account/i); }); it('AUTH-DB-073: require_mfa=true is allowed once the admin has MFA enabled', () => { const { user } = createAdmin(testDb); testDb.prepare('UPDATE users SET mfa_enabled = 1 WHERE id = ?').run(user.id); const result = svc.updateAppSettings(user.id, { require_mfa: true }); expect(result.success).toBe(true); expect(result.auditSummary).toMatchObject({ require_mfa: true }); testDb.prepare("DELETE FROM app_settings WHERE key = 'require_mfa'").run(); }); it('AUTH-DB-074: lockout prevention refuses disabling every login method', () => { const { user } = createAdmin(testDb); const result = svc.updateAppSettings(user.id, { password_login: 'false', oidc_login: 'false' }); expect(result).toEqual({ error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 }); }); it('AUTH-DB-075: the smtp_pass masking sentinel is skipped, notification changes flag a scheduler restart', () => { const { user } = createAdmin(testDb); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('smtp_pass', 'stored')").run(); const result = svc.updateAppSettings(user.id, { smtp_pass: '••••••••', notification_channels: 'email' }); expect(result.success).toBe(true); expect(result.shouldRestartScheduler).toBe(true); const { value } = testDb.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as { value: string }; expect(value).toBe('stored'); // sentinel never overwrites the secret expect(result.auditDebugDetails).not.toHaveProperty('smtp_pass', 'stored'); testDb.prepare("DELETE FROM app_settings WHERE key IN ('smtp_pass','notification_channels')").run(); }); }); describe('MFA success flows', () => { it('AUTH-DB-076: setup → enable with a valid TOTP code returns backup codes and persists the encrypted secret', () => { const { user } = createUser(testDb); const setup = svc.setupMfa(user.id, user.email); const code = authenticator.generate(setup.secret!); const result = svc.enableMfa(user.id, code); expect(result.success).toBe(true); expect(result.backup_codes).toHaveLength(10); const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string }; expect(row.mfa_enabled).toBe(1); expect(row.mfa_secret).toBe('enc:' + setup.secret); }); it('AUTH-DB-077: enable with a wrong code answers 401 and keeps the pending secret', () => { const { user } = createUser(testDb); svc.setupMfa(user.id, user.email); expect(svc.enableMfa(user.id, '000000')).toEqual({ error: 'Invalid verification code', status: 401 }); }); it('AUTH-DB-078: disableMfa succeeds with the right password + TOTP code', () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); const result = svc.disableMfa(user.id, user.email, { password, code: authenticator.generate(secret) }); expect(result).toEqual({ success: true, mfa_enabled: false }); const row = testDb.prepare('SELECT mfa_enabled, mfa_secret FROM users WHERE id = ?').get(user.id) as { mfa_enabled: number; mfa_secret: string | null }; expect(row.mfa_enabled).toBe(0); expect(row.mfa_secret).toBeNull(); }); it('AUTH-DB-079: disableMfa answers 401 on a wrong password', () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); expect(svc.disableMfa(user.id, user.email, { password: 'wrong', code: authenticator.generate(secret) })) .toEqual({ error: 'Incorrect password', status: 401 }); }); it('AUTH-DB-080: verifyMfaLogin succeeds with a TOTP code from the interstitial token', () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ? WHERE id = ?').run('enc:' + secret, user.id); const interstitial = svc.loginUser({ email: user.email, password }); expect(interstitial.mfa_required).toBe(true); const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: authenticator.generate(secret) }); expect(typeof result.token).toBe('string'); expect(result.auditUserId).toBe(user.id); }); it('AUTH-DB-081: verifyMfaLogin consumes a backup code when the TOTP fails', () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); const codes = ['AAAA-1111', 'BBBB-2222']; // hashBackupCode (legacy SHA-256) hashes still verify via matchBackupCode. testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify(codes.map(hashBackupCode)), user.id); const interstitial = svc.loginUser({ email: user.email, password }); const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'AAAA-1111' }); expect(typeof result.token).toBe('string'); const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(1); // used code spliced out // the spent code no longer verifies const again = svc.loginUser({ email: user.email, password }); expect(svc.verifyMfaLogin({ mfa_token: again.mfa_token, code: 'AAAA-1111' }).status).toBe(401); }); }); describe('resetPassword', () => { it('AUTH-DB-082: consumes an issued token, bumps password_version and burns sibling tokens', () => { const { user } = createUser(testDb); const issued = svc.requestPasswordReset(user.email, '1.2.3.4'); expect(issued.reason).toBe('issued'); const result = svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' }); expect(result).toEqual({ success: true, userId: user.id }); const row = testDb.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version: number }; expect(row.password_version).toBe(1); // token is burned — a second use answers the bespoke 400 expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh456!' })) .toEqual({ error: 'This reset link has already been used', status: 400 }); }); it('AUTH-DB-083: bespoke 400s for missing/unknown/expired tokens', () => { expect(svc.resetPassword({ new_password: 'Fresh123!' })).toEqual({ error: 'Reset token is required', status: 400 }); expect(svc.resetPassword({ token: 't' })).toEqual({ error: 'New password is required', status: 400 }); expect(svc.resetPassword({ token: 'unknown-token', new_password: 'Fresh123!' })) .toEqual({ error: 'Invalid or expired reset link', status: 400 }); const { user } = createUser(testDb); const issued = svc.requestPasswordReset(user.email, null); testDb.prepare("UPDATE password_reset_tokens SET expires_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?").run(user.id); expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ error: 'Reset link has expired. Please request a new one.', status: 400 }); }); it('AUTH-DB-084: an MFA-enabled account demands a code, then consumes a backup code', () => { const { user } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify([hashBackupCode('CCCC-3333')]), user.id); const issued = svc.requestPasswordReset(user.email, null); // no code → mfa_required interstitial, token NOT burned expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!' })) .toEqual({ mfa_required: true, status: 200 }); // wrong code → 401 expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: '000000' })) .toEqual({ error: 'Invalid MFA code', status: 401 }); // backup code → success + code consumed expect(svc.resetPassword({ token: issued.tokenForDelivery!, new_password: 'Fresh123!', mfa_code: 'CCCC-3333' })) .toEqual({ success: true, userId: user.id }); const row = testDb.prepare('SELECT mfa_backup_codes FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string }; expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); }); it('AUTH-DB-085: password-login-disabled and per-email throttle short-circuit the request', () => { vi.stubEnv('OIDC_ONLY', 'true'); vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com'); vi.stubEnv('OIDC_CLIENT_ID', 'trek-client'); expect(svc.requestPasswordReset('whoever@x.com', null).reason).toBe('password_login_disabled'); vi.unstubAllEnvs(); const { user } = createUser(testDb); svc.requestPasswordReset(user.email, null); svc.requestPasswordReset(user.email, null); svc.requestPasswordReset(user.email, null); expect(svc.requestPasswordReset(user.email, null).reason).toBe('throttled_per_email'); }); }); describe('ephemeral + demo helpers', () => { it('AUTH-DB-088: isDemoUser is true only for the demo email in demo mode', () => { const { user } = createUser(testDb, { email: 'demo@nomad.app' }); const { user: other } = createUser(testDb); expect(svc.isDemoUser(user.id)).toBe(false); // demo mode off vi.stubEnv('DEMO_MODE', 'true'); expect(svc.isDemoUser(user.id)).toBe(true); expect(svc.isDemoUser(other.id)).toBe(false); vi.unstubAllEnvs(); }); }); // --------------------------------------------------------------------------- // Quirk fixes after the DI fold (trailing fix(server) commit): AUTH-DB-089+. // The relocation carried these verbatim; the fixes land on top with a pin each. // --------------------------------------------------------------------------- describe('auth quirk fixes', () => { it('AUTH-DB-090: a throw mid-registration rolls the whole signup back (user + invite bookkeeping)', () => { const { user: owner } = createUser(testDb); const trip = createTrip(testDb, owner.id); const invite = createInviteToken(testDb, { max_uses: 5 }); testDb.prepare('UPDATE invite_tokens SET trip_id = ? WHERE id = ?').run(trip.id, invite.id); joinTripAsMember.mockImplementationOnce(() => { throw new Error('boom'); }); const result = svc.registerUser({ username: 'rollback', email: 'rollback@x.com', password: 'Secure123!', invite_token: invite.token }); expect(result).toEqual({ error: 'Error creating user', status: 500 }); expect(testDb.prepare("SELECT id FROM users WHERE email = 'rollback@x.com'").get()).toBeUndefined(); const { used_count } = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number }; expect(used_count).toBe(0); }); it('AUTH-DB-091: a backup-code login burns the code and records the login as one atomic pair', () => { const { user, password } = createUser(testDb); const secret = authenticator.generateSecret(); testDb.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ? WHERE id = ?') .run('enc:' + secret, JSON.stringify([hashBackupCode('DDDD-4444')]), user.id); const interstitial = svc.loginUser({ email: user.email, password }); const result = svc.verifyMfaLogin({ mfa_token: interstitial.mfa_token, code: 'DDDD-4444' }); expect(typeof result.token).toBe('string'); const row = testDb.prepare('SELECT mfa_backup_codes, login_count, last_login FROM users WHERE id = ?').get(user.id) as { mfa_backup_codes: string; login_count: number; last_login: string | null }; expect(JSON.parse(row.mfa_backup_codes)).toHaveLength(0); expect(row.login_count).toBe(1); expect(row.last_login).not.toBeNull(); }); }); // --------------------------------------------------------------------------- // auth.bridge.ts delegation (coverage gate: one case per bridge export) // --------------------------------------------------------------------------- describe('auth.bridge delegation', () => { it('AUTH-BR-001: isDemoUser returns false outside demo mode', () => { const { user } = createUser(testDb); expect(authBridge.isDemoUser(user.id)).toBe(false); }); it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => { const { user } = createUser(testDb); const created = tokens.createMcpToken(user.id, 'bridge-case'); const raw = (created.token as { raw_token: string }).raw_token; const resolved = authBridge.verifyMcpToken(raw); expect(resolved?.id).toBe(user.id); expect(authBridge.verifyMcpToken('trek_no_such_token')).toBeNull(); }); it('AUTH-BR-003: generateToken + verifyJwtToken round-trip through the pv gate', () => { const { user } = createUser(testDb); const token = authBridge.generateToken({ id: user.id }); expect(authBridge.verifyJwtToken(token)?.id).toBe(user.id); expect(authBridge.verifyJwtToken('not-a-jwt')).toBeNull(); }); it('AUTH-BR-004: resolveAuthToggles returns the container defaults', () => { vi.stubEnv('OIDC_ONLY', ''); expect(authBridge.resolveAuthToggles().password_login).toBe(true); vi.unstubAllEnvs(); }); it('AUTH-BR-005: createWsToken surfaces the 503 when the ephemeral store is down', () => { const { user } = createUser(testDb); // createEphemeralToken is mocked to return undefined in this suite. expect(authBridge.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 }); }); it('AUTH-BR-006: stripUserForClient re-export strips secrets', () => { const stripped = authBridge.stripUserForClient({ id: 1, password_hash: 'x' } as never); expect(stripped).not.toHaveProperty('password_hash'); }); it('AUTH-BR-007: avatarUrl re-export keeps the legacy shape', () => { expect(authBridge.avatarUrl({ avatar: 'a.png' })).toBe('/uploads/avatars/a.png'); expect(authBridge.avatarUrl({ avatar: null })).toBeNull(); }); });