339 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|