/** * 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, requestPath: string, headers: Record = {}, ): 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); }); }); });