363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
/**
|
|
* 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,
|
|
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', () => {
|
|
it('extrahiert die Identität aus korrekten Gateway-Headern', () => {
|
|
const identity = extractIdentity({
|
|
[GATEWAY_HEADERS.userId]: 'user-123',
|
|
[GATEWAY_HEADERS.username]: 'max',
|
|
[GATEWAY_HEADERS.displayName]: 'Max Mustermann',
|
|
[GATEWAY_HEADERS.role]: 'USER',
|
|
});
|
|
|
|
expect(identity).toEqual({
|
|
userId: 'user-123',
|
|
username: 'max',
|
|
displayName: 'Max Mustermann',
|
|
role: 'USER',
|
|
});
|
|
});
|
|
|
|
it('ist case-insensitive bei Header-Namen', () => {
|
|
const identity = extractIdentity({
|
|
'X-User-Id': 'user-123',
|
|
'X-User-Username': 'max',
|
|
'X-User-Role': 'ADMIN',
|
|
});
|
|
|
|
expect(identity).not.toBeNull();
|
|
expect(identity?.role).toBe('ADMIN');
|
|
});
|
|
|
|
it('gibt null zurück, wenn Header fehlen (kein Gateway-Request)', () => {
|
|
expect(extractIdentity({})).toBeNull();
|
|
expect(extractIdentity({ 'x-user-id': 'user-123' })).toBeNull();
|
|
});
|
|
|
|
it('gibt null bei ungültiger Rolle zurück', () => {
|
|
const identity = extractIdentity({
|
|
[GATEWAY_HEADERS.userId]: 'user-123',
|
|
[GATEWAY_HEADERS.username]: 'max',
|
|
[GATEWAY_HEADERS.role]: 'SUPERADMIN',
|
|
});
|
|
expect(identity).toBeNull();
|
|
});
|
|
|
|
it('verwendet den Benutzernamen als Fallback für den Anzeigenamen', () => {
|
|
const identity = extractIdentity({
|
|
[GATEWAY_HEADERS.userId]: 'user-123',
|
|
[GATEWAY_HEADERS.username]: 'max',
|
|
[GATEWAY_HEADERS.role]: 'USER',
|
|
});
|
|
expect(identity?.displayName).toBe('max');
|
|
});
|
|
});
|
|
|
|
describe('healthResponse', () => {
|
|
it('erzeugt eine vertragskonforme /health-Antwort', () => {
|
|
expect(healthResponse('demo', '1.0.0')).toEqual({
|
|
moduleId: 'demo',
|
|
version: '1.0.0',
|
|
status: 'healthy',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('meResponse', () => {
|
|
it('erzeugt eine vertragskonforme /api/me-Antwort', () => {
|
|
const identity = {
|
|
userId: 'user-123',
|
|
username: 'max',
|
|
displayName: 'Max Mustermann',
|
|
role: 'USER' as const,
|
|
};
|
|
|
|
expect(meResponse(identity, 'demo', ['demo.read'])).toEqual({
|
|
user: {
|
|
id: 'user-123',
|
|
username: 'max',
|
|
displayName: 'Max Mustermann',
|
|
platformRole: 'USER',
|
|
},
|
|
module: { id: 'demo' },
|
|
permissions: ['demo.read'],
|
|
});
|
|
});
|
|
|
|
it('verwendet leere Permissions als Default', () => {
|
|
const identity = {
|
|
userId: 'user-123',
|
|
username: 'max',
|
|
displayName: 'Max',
|
|
role: 'ADMIN' as const,
|
|
};
|
|
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);
|
|
});
|
|
});
|
|
}); |