feat: Phase 3 – Modul-System (Manifest, ZIP-Installation, Lifecycle, Prozess-Manager)
This commit is contained in:
333
apps/platform-backend/src/modules/modules.service.spec.ts
Normal file
333
apps/platform-backend/src/modules/modules.service.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
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',
|
||||
...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(),
|
||||
...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' },
|
||||
};
|
||||
|
||||
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,
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user