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

339 lines
12 KiB
TypeScript

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> = {}): 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> = {}): 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<ModuleRecord[]> {
return this.modules;
}
async findById(id: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.id === id) ?? null;
}
async findByModuleId(moduleId: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.moduleId === moduleId) ?? null;
}
async findBySlug(slug: string): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.slug === slug) ?? null;
}
async findByPort(port: number): Promise<ModuleRecord | null> {
return this.modules.find((module) => module.internalPort === port) ?? null;
}
async create(manifest: ModuleManifest, directory: string): Promise<ModuleRecord> {
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<void> {
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<void> {
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<void> {
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<ModuleManifest> {
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<void> {
this.removed.push(moduleId);
}
}
/** Mock des ModuleProcessManager. */
class MockProcessManager {
public started: string[] = [];
public stopped: string[] = [];
async start(module: ModuleRecord): Promise<void> {
this.started.push(module.moduleId);
}
async stop(moduleId: string): Promise<void> {
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<void> {
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);
});
});
});