feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)
This commit is contained in:
270
apps/platform-backend/src/auth/auth.service.spec.ts
Normal file
270
apps/platform-backend/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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' },
|
||||
};
|
||||
}
|
||||
|
||||
/** 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: Array<{ userId: string; attempts: number; shouldLock: boolean; lockoutMinutes: number }> = [];
|
||||
|
||||
async findByUsername(): Promise<UserRecord | null> {
|
||||
return this.findByUsernameResult;
|
||||
}
|
||||
|
||||
async updateLoginSuccess(userId: string): Promise<void> {
|
||||
this.updateLoginSuccessCalls.push(userId);
|
||||
}
|
||||
|
||||
async updateLoginFailure(userId: string, attempts: number, shouldLock: boolean, lockoutMinutes: number): Promise<void> {
|
||||
this.updateLoginFailureCalls.push({ userId, attempts, shouldLock, lockoutMinutes });
|
||||
}
|
||||
}
|
||||
|
||||
/** 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([
|
||||
{ userId: 'user-1', attempts: 1, shouldLock: false, lockoutMinutes: 15 },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_FAILED);
|
||||
});
|
||||
|
||||
it('sperrt das Konto nach Erreichen der maximalen Fehlversuche', 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([
|
||||
{ userId: 'user-1', attempts: 3, shouldLock: true, lockoutMinutes: 15 },
|
||||
]);
|
||||
expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.LOGIN_LOCKED);
|
||||
});
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
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_LOCKED' });
|
||||
});
|
||||
|
||||
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 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');
|
||||
|
||||
// Nach Reset ist ein neuer Login sofort wieder möglich.
|
||||
const secondResult = await authService.login({
|
||||
username: 'max',
|
||||
password: 'Sicheres-Passwort-1',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
expect(secondResult.user.username).toBe('max');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user