Files
mpm/apps/platform-backend/src/auth/auth.service.spec.ts
2026-10-08 21:13:57 +02:00

272 lines
9.4 KiB
TypeScript

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['security']> = {}): 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> = {}): 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<UserRecord | null> {
return this.findByUsernameResult;
}
async updateLoginSuccess(userId: string): Promise<void> {
this.updateLoginSuccessCalls.push(userId);
}
async updateLoginFailure(userId: string): Promise<void> {
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<void> {}
}
/** Mock des AuditService. */
class MockAuditService {
public records: Array<{ userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }> = [];
async record(entry: { userId: string | null; username: string; action: string; details?: Record<string, unknown>; ipAddress?: string | null }): Promise<void> {
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);
});
});
});