import { UnauthorizedException } from '@nestjs/common'; import type { AppConfig } from '../config/config.tokens'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import { PasswordHasher } from '../users/password-hasher'; import { UserRepository } from '../users/user.repository'; import type { AuthUser, UserRecord } from '../users/user.types'; import { AuthService } from './auth.service'; import { RateLimiterService } from './rate-limiter.service'; import { SessionService, type SessionData } from './session.service'; /** Erzeugt eine Test-Konfiguration mit überschreibbaren Werten. */ function createConfig(overrides: Partial = {}): AppConfig { return { nodeEnv: 'test', port: 3000, database: { url: 'postgresql://test' }, security: { sessionTtlMinutes: 120, cookieSecure: false, behindProxy: false, loginMaxAttempts: 3, loginLockoutMinutes: 15, loginRateLimitAttempts: 10, loginRateLimitWindowMinutes: 5, ...overrides, }, adminSeed: { username: 'admin', email: 'admin@example.com', password: 'password-123' }, runtime: { modulesDir: '/data/modules', logsDir: '/data/logs', moduleConfigurationEncryptionKey: '' }, marketplace: { publicUrl: 'http://127.0.0.1:8081', tokenEncryptionKey: '', providers: {} }, }; } /** Erzeugt einen Benutzer-Datensatz für Tests. */ function createUserRecord(overrides: Partial = {}): UserRecord { return { id: 'user-1', username: 'max', email: 'max@example.com', passwordHash: 'not-a-real-hash', displayName: 'Max Mustermann', role: 'USER', isActive: true, failedLoginAttempts: 0, lockedUntil: null, lastLoginAt: null, createdAt: new Date(), updatedAt: new Date(), ...overrides, }; } /** Mock des UserRepository. */ class MockUserRepository { public findByUsernameResult: UserRecord | null = null; public updateLoginSuccessCalls: string[] = []; public updateLoginFailureCalls: string[] = []; async findByUsername(): Promise { return this.findByUsernameResult; } async updateLoginSuccess(userId: string): Promise { this.updateLoginSuccessCalls.push(userId); } async updateLoginFailure(userId: string): Promise { this.updateLoginFailureCalls.push(userId); } } /** Mock des SessionService. */ class MockSessionService { public createResult: { token: string; data: SessionData } = { token: 'session-token', data: { id: 'session-1', userId: 'user-1', csrfToken: 'csrf-token', expiresAt: new Date(Date.now() + 60_000), }, }; async create(): Promise<{ token: string; data: SessionData }> { return this.createResult; } async delete(): Promise {} } /** Mock des AuditService. */ class MockAuditService { public records: Array<{ userId: string | null; username: string; action: string; details?: Record; ipAddress?: string | null }> = []; async record(entry: { userId: string | null; username: string; action: string; details?: Record; ipAddress?: string | null }): Promise { this.records.push(entry); } } describe('AuthService', () => { let userRepository: MockUserRepository; let passwordHasher: PasswordHasher; let sessionService: MockSessionService; let auditService: MockAuditService; let rateLimiter: RateLimiterService; let authService: AuthService; let config: AppConfig; beforeEach(() => { userRepository = new MockUserRepository(); passwordHasher = new PasswordHasher(); sessionService = new MockSessionService(); auditService = new MockAuditService(); rateLimiter = new RateLimiterService(); config = createConfig(); authService = new AuthService( userRepository as unknown as UserRepository, passwordHasher, sessionService as unknown as SessionService, rateLimiter, auditService as unknown as AuditService, config, ); }); describe('login', () => { it('meldet einen Benutzer mit korrekten Zugangsdaten an', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash }); const result = await authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1', }); expect(result.user.username).toBe('max'); expect(result.sessionToken).toBe('session-token'); expect(userRepository.updateLoginSuccessCalls).toEqual(['user-1']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_SUCCESS); }); it('lehnt unbekannte Benutzer mit generischer Meldung ab', async () => { userRepository.findByUsernameResult = null; await expect( authService.login({ username: 'ghost', password: 'wrong', ipAddress: '127.0.0.1' }), ).rejects.toThrow(UnauthorizedException); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED); expect(auditService.records.at(-1)?.details).toEqual({ reason: 'UNKNOWN_USER' }); }); it('lehnt falsche Passwörter ab und zählt Fehlversuche', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash }); await expect( authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }), ).rejects.toThrow(UnauthorizedException); expect(userRepository.updateLoginFailureCalls).toEqual([ 'user-1', ]); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED); }); it('verhindert Loginversuche nicht durch Kontosperren', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash, failedLoginAttempts: 2, }); await expect( authService.login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }), ).rejects.toThrow(UnauthorizedException); expect(userRepository.updateLoginFailureCalls).toEqual([ 'user-1', ]); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED); }); it('lehnt gesperrte Benutzer ab', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash, lockedUntil: new Date(Date.now() + 60_000), }); const result = await authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1', }); expect(result.user.username).toBe('max'); }); it('lehnt deaktivierte Benutzer ab', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash, isActive: false, }); await expect( authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }), ).rejects.toThrow(UnauthorizedException); expect(auditService.records.at(-1)?.details).toEqual({ reason: 'ACCOUNT_INACTIVE' }); }); it('blockiert Requests nach Überschreitung des Rate Limits', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash }); // Limit: 10 Versuche / 5 Minuten (Default-Konfiguration) for (let attempt = 0; attempt < 10; attempt += 1) { await authService .login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }) .catch(() => undefined); } await expect( authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1' }), ).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.'); expect(auditService.records.at(-1)?.details).toEqual({ reason: 'RATE_LIMITED' }); }); it('setzt das Rate-Limit-Fenster nach erfolgreichem Login nicht zurück', async () => { const passwordHash = await passwordHasher.hash('Sicheres-Passwort-1'); userRepository.findByUsernameResult = createUserRecord({ passwordHash }); for (let attempt = 0; attempt < 9; attempt += 1) { await authService .login({ username: 'max', password: 'falsch', ipAddress: '127.0.0.1' }) .catch(() => undefined); } const result = await authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1', }); expect(result.user.username).toBe('max'); await expect(authService.login({ username: 'max', password: 'Sicheres-Passwort-1', ipAddress: '127.0.0.1', })).rejects.toThrow('Zu viele Anmeldeversuche. Bitte später erneut versuchen.'); }); }); describe('logout', () => { it('löscht die Session und schreibt ein Audit-Log', async () => { const user: AuthUser = { id: 'user-1', username: 'max', email: 'max@example.com', displayName: 'Max Mustermann', role: 'USER', }; await authService.logout('session-1', user, '127.0.0.1'); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGOUT); }); }); });