import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; import { AUDIT_ACTIONS, AuditService } from '../audit/audit.service'; import type { AppConfig } from '../config/config.tokens'; import type { ActingUser } from '../users/users.service'; import { ModuleHealthChecker } from './module-health-checker'; import { ModuleInstaller } from './module-installer'; import { ModuleProcessManager } from './module-process-manager'; import { ModuleRepository } from './module.repository'; import type { ModuleManifest, ModuleRecord } from './manifest.types'; import { ModulesService } from './modules.service'; /** Gültiges Test-Manifest. */ function createManifest(overrides: Partial = {}): ModuleManifest { return { id: 'calendar', name: 'Kalender', version: '1.0.0', slug: 'kalender-tool', description: 'Kalenderverwaltung', author: 'MPM', runtime: 'node', entrypoint: 'server.js', port: 41001, healthcheck: '/health', apiVersion: 'v1', configuration: [], ...overrides, }; } /** Erzeugt einen Modul-Datensatz für Tests. */ function createModuleRecord(overrides: Partial = {}): ModuleRecord { const manifest = createManifest(); return { id: 'module-1', moduleId: manifest.id, name: manifest.name, slug: manifest.slug, version: manifest.version, description: manifest.description, author: manifest.author, path: '/data/modules/calendar', status: 'STOPPED', internalPort: manifest.port, healthcheckUrl: manifest.healthcheck, enabled: true, createdAt: new Date(), updatedAt: new Date(), configuration: [], configurationReady: true, ...overrides, }; } const ACTOR: ActingUser = { id: 'admin-1', username: 'admin' }; /** Mock des ModuleRepository. */ class MockModuleRepository { public modules: ModuleRecord[] = []; public statusUpdates: Array<{ id: string; status: ModuleRecord['status'] }> = []; public enabledUpdates: Array<{ id: string; enabled: boolean }> = []; public deletedIds: string[] = []; async list(): Promise { return this.modules; } async findById(id: string): Promise { return this.modules.find((module) => module.id === id) ?? null; } async findByModuleId(moduleId: string): Promise { return this.modules.find((module) => module.moduleId === moduleId) ?? null; } async findBySlug(slug: string): Promise { return this.modules.find((module) => module.slug === slug) ?? null; } async findByPort(port: number): Promise { return this.modules.find((module) => module.internalPort === port) ?? null; } async create(manifest: ModuleManifest, directory: string): Promise { const module = createModuleRecord({ id: 'new-module', moduleId: manifest.id, name: manifest.name, slug: manifest.slug, version: manifest.version, path: directory, internalPort: manifest.port, healthcheckUrl: manifest.healthcheck, }); this.modules.push(module); return module; } async updateStatus(id: string, status: ModuleRecord['status']): Promise { this.statusUpdates.push({ id, status }); const module = this.modules.find((m) => m.id === id); if (module) { (module as { status: ModuleRecord['status'] }).status = status; } } async updateEnabled(id: string, enabled: boolean): Promise { this.enabledUpdates.push({ id, enabled }); const module = this.modules.find((m) => m.id === id); if (module) { (module as { enabled: boolean }).enabled = enabled; } } async delete(id: string): Promise { this.deletedIds.push(id); this.modules = this.modules.filter((m) => m.id !== id); } } /** Mock des ModuleInstaller. */ class MockModuleInstaller { public validateResult: ModuleManifest = createManifest(); public installed: Array<{ moduleId: string }> = []; public removed: string[] = []; async validatePackage(): Promise { return this.validateResult; } async install( _buffer: Buffer, manifest: ModuleManifest, _modulesDir: string, ): Promise<{ directory: string; manifest: ModuleManifest }> { this.installed.push({ moduleId: manifest.id }); return { directory: `/data/modules/${manifest.id}`, manifest }; } async remove(_modulesDir: string, moduleId: string): Promise { this.removed.push(moduleId); } } /** Mock des ModuleProcessManager. */ class MockProcessManager { public started: string[] = []; public stopped: string[] = []; async start(module: ModuleRecord): Promise { this.started.push(module.moduleId); } async stop(moduleId: string): Promise { this.stopped.push(moduleId); } isRunning(moduleId: string): boolean { return this.started.includes(moduleId) && !this.stopped.includes(moduleId); } } /** Mock des ModuleHealthChecker. */ class MockHealthChecker { public healthy = true; async check(): Promise<{ healthy: boolean; latencyMs: number; detail: string }> { return { healthy: this.healthy, latencyMs: 5, detail: this.healthy ? 'healthy' : 'unreachable' }; } } /** Mock des AuditService. */ class MockAuditService { public records: Array<{ action: string }> = []; async record(entry: { action: string }): Promise { this.records.push(entry); } } /** Test-Konfiguration. */ const TEST_CONFIG: AppConfig = { nodeEnv: 'test', port: 3000, database: { url: 'postgresql://test' }, security: { sessionTtlMinutes: 120, cookieSecure: false, behindProxy: false, loginMaxAttempts: 5, loginLockoutMinutes: 15, loginRateLimitAttempts: 10, loginRateLimitWindowMinutes: 5, }, 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: {} }, }; describe('ModulesService', () => { let repository: MockModuleRepository; let installer: MockModuleInstaller; let processManager: MockProcessManager; let healthChecker: MockHealthChecker; let auditService: MockAuditService; let modulesService: ModulesService; beforeEach(() => { repository = new MockModuleRepository(); installer = new MockModuleInstaller(); processManager = new MockProcessManager(); healthChecker = new MockHealthChecker(); auditService = new MockAuditService(); modulesService = new ModulesService( repository as unknown as ModuleRepository, installer as unknown as ModuleInstaller, processManager as unknown as ModuleProcessManager, healthChecker as unknown as ModuleHealthChecker, auditService as unknown as AuditService, { resolvedValues: async () => ({}), state: async () => ({ ready: true, fields: [] }), save: async () => ({ state: { ready: true, fields: [] }, changedKeys: [] }) } as never, TEST_CONFIG, ); }); describe('install', () => { it('installiert ein neues Modul und schreibt Audit', async () => { const module = await modulesService.install(Buffer.from('zip'), ACTOR, null); expect(module.moduleId).toBe('calendar'); expect(installer.installed).toEqual([{ moduleId: 'calendar' }]); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_INSTALLED); }); it('lehnt doppelte Modul-IDs ab', async () => { repository.modules = [createModuleRecord()]; await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( ConflictException, ); }); it('lehnt doppelte Slugs ab', async () => { repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'kalender-tool' })]; await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( ConflictException, ); }); it('lehnt belegte Ports ab', async () => { repository.modules = [createModuleRecord({ moduleId: 'anderes', slug: 'anderer-slug' })]; await expect(modulesService.install(Buffer.from('zip'), ACTOR, null)).rejects.toThrow( ConflictException, ); }); }); describe('start', () => { it('startet ein gestopptes Modul (STARTING → RUNNING)', async () => { repository.modules = [createModuleRecord({ status: 'STOPPED' })]; const module = await modulesService.start('module-1', ACTOR, null); expect(module.status).toBe('RUNNING'); expect(processManager.started).toEqual(['calendar']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED); }); it('setzt ERROR bei fehlgeschlagenem Healthcheck', async () => { repository.modules = [createModuleRecord({ status: 'STOPPED' })]; healthChecker.healthy = false; await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow( BadRequestException, ); expect(repository.statusUpdates).toContainEqual({ id: 'module-1', status: 'ERROR' }); expect(processManager.stopped).toEqual(['calendar']); }); it('lehnt Start deaktivierter Module ab', async () => { repository.modules = [createModuleRecord({ enabled: false })]; await expect(modulesService.start('module-1', ACTOR, null)).rejects.toThrow( BadRequestException, ); }); it('wirft NotFoundException bei unbekannter ID', async () => { await expect(modulesService.start('unbekannt', ACTOR, null)).rejects.toThrow( NotFoundException, ); }); }); describe('stop', () => { it('stoppt ein laufendes Modul (STOPPING → STOPPED)', async () => { repository.modules = [createModuleRecord({ status: 'RUNNING' })]; const module = await modulesService.stop('module-1', ACTOR, null); expect(module.status).toBe('STOPPED'); expect(processManager.stopped).toEqual(['calendar']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STOPPED); }); }); describe('restart', () => { it('stoppt und startet ein laufendes Modul', async () => { repository.modules = [createModuleRecord({ status: 'RUNNING' })]; const module = await modulesService.restart('module-1', ACTOR, null); expect(module.status).toBe('RUNNING'); expect(processManager.stopped).toEqual(['calendar']); expect(processManager.started).toEqual(['calendar']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_STARTED); }); }); describe('setEnabled', () => { it('deaktiviert ein laufendes Modul (stoppt es zuerst)', async () => { repository.modules = [createModuleRecord({ status: 'RUNNING' })]; const module = await modulesService.setEnabled('module-1', false, ACTOR, null); expect(module.enabled).toBe(false); expect(module.status).toBe('DISABLED'); expect(processManager.stopped).toEqual(['calendar']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_DISABLED); }); it('aktiviert ein deaktiviertes Modul', async () => { repository.modules = [createModuleRecord({ enabled: false, status: 'DISABLED' })]; const module = await modulesService.setEnabled('module-1', true, ACTOR, null); expect(module.enabled).toBe(true); expect(module.status).toBe('STOPPED'); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_ENABLED); }); }); describe('remove', () => { it('stoppt, löscht Registry-Eintrag und Dateien', async () => { repository.modules = [createModuleRecord({ status: 'RUNNING' })]; await modulesService.remove('module-1', ACTOR, null); expect(processManager.stopped).toEqual(['calendar']); expect(repository.deletedIds).toEqual(['module-1']); expect(installer.removed).toEqual(['calendar']); expect(auditService.records.at(-1)?.action).toBe(AUDIT_ACTIONS.MODULE_REMOVED); }); }); });