feat: Phase 8 – Modul-SDK (createModuleServer, Config, Logger, Platform-Client)

This commit is contained in:
MPM Dev
2026-10-07 16:29:40 +02:00
parent 0abe7b7abc
commit e6219e8cf9
9 changed files with 1081 additions and 107 deletions

View File

@@ -1,13 +1,46 @@
/**
* SDK-Tests (Phase 6): Vertrags-Helper des Modul-SDKs.
* SDK-Tests (Phase 8): Vollständige Abdeckung des Modul-SDKs.
* Das SDK ist CommonJS und wird direkt mit Node getestet.
*/
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const {
GATEWAY_HEADERS,
extractIdentity,
healthResponse,
manifestResponse,
meResponse,
} = require('../../../modules/demo/backend/platform-module-sdk');
loadManifest,
loadModuleConfig,
createLogger,
createModuleServer,
} = require('../../../packages/platform-module-sdk');
/** Schreibt ein Test-Manifest in ein temporäres Verzeichnis. */
function writeTestManifest(overrides = {}): string {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mpm-sdk-'));
const manifestPath = path.join(directory, 'module.json');
fs.writeFileSync(
manifestPath,
JSON.stringify({
id: 'testmodul',
name: 'Test-Modul',
version: '1.0.0',
slug: 'test-modul',
description: 'Test',
author: 'MPM',
runtime: 'node',
entrypoint: 'server.js',
port: 41001,
healthcheck: '/health',
apiVersion: 'v1',
...overrides,
}),
'utf8',
);
return manifestPath;
}
describe('platform-module-sdk', () => {
describe('extractIdentity', () => {
@@ -103,4 +136,228 @@ describe('platform-module-sdk', () => {
expect(meResponse(identity, 'demo').permissions).toEqual([]);
});
});
describe('manifestResponse', () => {
it('erzeugt eine vertragskonforme /api/manifest-Antwort', () => {
const manifest = loadManifest(writeTestManifest());
expect(manifestResponse(manifest)).toEqual({
moduleId: 'testmodul',
name: 'Test-Modul',
version: '1.0.0',
slug: 'test-modul',
apiVersion: 'v1',
status: 'healthy',
});
});
});
describe('loadManifest', () => {
it('lädt ein gültiges Manifest und friert es ein', () => {
const manifest = loadManifest(writeTestManifest());
expect(manifest.id).toBe('testmodul');
expect(Object.isFrozen(manifest)).toBe(true);
});
it('wirft bei fehlender Datei', () => {
expect(() => loadManifest('/nicht/existent/module.json')).toThrow(
/nicht lesbar/,
);
});
it('wirft bei unvollständigem Manifest', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mpm-sdk-'));
const manifestPath = path.join(directory, 'module.json');
fs.writeFileSync(manifestPath, JSON.stringify({ id: 'x' }), 'utf8');
expect(() => loadManifest(manifestPath)).toThrow(/unvollständig/);
});
it('wirft bei nicht unterstützter Runtime', () => {
expect(() => loadManifest(writeTestManifest({ runtime: 'python' }))).toThrow(
/Runtime/,
);
});
it('wirft bei nicht unterstützter apiVersion', () => {
expect(() => loadManifest(writeTestManifest({ apiVersion: 'v2' }))).toThrow(
/apiVersion/,
);
});
});
describe('loadModuleConfig', () => {
it('liest PORT und Defaults', () => {
const config = loadModuleConfig({ PORT: '41042', NODE_ENV: 'production' });
expect(config.port).toBe(41042);
expect(config.nodeEnv).toBe('production');
expect(config.platformBaseUrl).toBe('http://127.0.0.1:3000');
expect(config.serviceToken).toBeNull();
});
it('liest PLATFORM_INTERNAL_URL und MODULE_SERVICE_TOKEN', () => {
const config = loadModuleConfig({
PORT: '41001',
PLATFORM_INTERNAL_URL: 'http://plattform:3000',
MODULE_SERVICE_TOKEN: 'test-token',
});
expect(config.platformBaseUrl).toBe('http://plattform:3000');
expect(config.serviceToken).toBe('test-token');
});
});
describe('createLogger', () => {
it('schreibt strukturierte JSON-Logs auf stdout', () => {
const logLines: string[] = [];
const originalLog = console.log;
console.log = (line: string) => logLines.push(line);
const logger = createLogger({ moduleId: 'testmodul' });
logger.info('Testmeldung', { benutzer: 'max' });
console.log = originalLog;
expect(logLines).toHaveLength(1);
const entry = JSON.parse(logLines[0]);
expect(entry.level).toBe('info');
expect(entry.moduleId).toBe('testmodul');
expect(entry.message).toBe('Testmeldung');
expect(entry.data).toEqual({ benutzer: 'max' });
});
it('schwärzt sensible Schlüssel', () => {
const logLines: string[] = [];
const originalError = console.error;
console.error = (line: string) => logLines.push(line);
const logger = createLogger({ moduleId: 'testmodul' });
logger.warn('Login-Versuch', { password: 'geheim', username: 'max' });
console.error = originalError;
const entry = JSON.parse(logLines[0]);
expect(entry.data.password).toBe('[geschwärzt]');
expect(entry.data.username).toBe('max');
});
it('filtert Meldungen unterhalb des Minimal-Levels', () => {
const logLines: string[] = [];
const originalLog = console.log;
console.log = (line: string) => logLines.push(line);
const logger = createLogger({ moduleId: 'testmodul', level: 'warn' });
logger.debug('unsichtbar');
logger.info('auch unsichtbar');
console.log = originalLog;
expect(logLines).toHaveLength(0);
});
});
describe('createModuleServer', () => {
const manifest = loadManifest(writeTestManifest());
const identityHeaders = {
[GATEWAY_HEADERS.userId]: 'user-1',
[GATEWAY_HEADERS.username]: 'max',
[GATEWAY_HEADERS.displayName]: 'Max',
[GATEWAY_HEADERS.role]: 'USER',
};
function request(
server: ReturnType<typeof createModuleServer>,
requestPath: string,
headers: Record<string, string> = {},
): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
server.listen(0, '127.0.0.1', () => {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const httpRequest = require('node:http');
const req = httpRequest.get(
{ host: '127.0.0.1', port, path: requestPath, headers },
(res: import('node:http').IncomingMessage) => {
let body = '';
res.on('data', (chunk: string) => {
body += chunk;
});
res.on('end', () => {
server.close();
resolve({ status: res.statusCode ?? 0, body });
});
},
);
req.on('error', (error: Error) => {
server.close();
reject(error);
});
});
});
}
it('beantwortet /health nach Vertrag', async () => {
const server = createModuleServer({ manifest });
const response = await request(server, '/health');
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({
moduleId: 'testmodul',
version: '1.0.0',
status: 'healthy',
});
});
it('beantwortet /api/manifest nach Vertrag', async () => {
const server = createModuleServer({ manifest });
const response = await request(server, '/api/manifest');
expect(response.status).toBe(200);
expect(JSON.parse(response.body).moduleId).toBe('testmodul');
});
it('beantwortet /api/me mit Identität', async () => {
const server = createModuleServer({ manifest, permissions: ['test.read'] });
const response = await request(server, '/api/me', identityHeaders);
expect(response.status).toBe(200);
const body = JSON.parse(response.body);
expect(body.user.username).toBe('max');
expect(body.permissions).toEqual(['test.read']);
});
it('antwortet 401 auf /api/me ohne Identität', async () => {
const server = createModuleServer({ manifest });
const response = await request(server, '/api/me');
expect(response.status).toBe(401);
});
it('leitet fachliche Routen an den routes-Handler weiter', async () => {
const server = createModuleServer({
manifest,
routes: (
_req: unknown,
res: import('node:http').ServerResponse,
context: { path: string; identity: { username: string } },
) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ path: context.path, user: context.identity.username }));
},
});
const response = await request(server, '/aufgaben', identityHeaders);
expect(response.status).toBe(200);
expect(JSON.parse(response.body)).toEqual({ path: '/aufgaben', user: 'max' });
});
it('blockiert fachliche Routen ohne Identität (identityRequired)', async () => {
const server = createModuleServer({
manifest,
routes: (_req: unknown, res: import('node:http').ServerResponse) => {
res.writeHead(200);
res.end('ok');
},
});
const response = await request(server, '/aufgaben');
expect(response.status).toBe(401);
});
it('antwortet 404 ohne routes-Handler', async () => {
const server = createModuleServer({ manifest });
const response = await request(server, '/andere', identityHeaders);
expect(response.status).toBe(404);
});
});
});