feat: Phase 8 – Modul-SDK (createModuleServer, Config, Logger, Platform-Client)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user