import crypto from 'node:crypto' import http from 'node:http' /** * Enable-Banking-Nachbau fuer Tests (Finom ueber PSD2). Der Client spricht ihn ueber * config.enableBanking.apiBase (ENABLE_BANKING_API_BASE auf 127.0.0.1) an. Die Antwortformen entsprechen * einem echten Aufruf vom 01.10.2026: * POST /auth -> { url, authorization_id, psu_id_hash } * POST /sessions { code } -> { session_id, accounts: [Konto-Objekte], access, aspsp, psu_type } * GET /sessions/:id -> { status, accounts: [uid], access: { valid_until }, ... } * GET /accounts/:uid/balances -> { balances: [...] } * GET /accounts/:uid/transactions -> { transactions: [...], continuation_key: String|null } * * Jeder Aufruf muss ein gueltiges JWT tragen (RS256 gegen publicKeyPem, Claims iss/aud/iat/exp, optional * kid = appId), sonst 401. Aufrufe landen in calls als { method, path, query, headers, body } (Kopfzeilen klein * geschrieben, Body als geparstes JSON oder null). * * Vorbelegt: Konto u1 (accounts), Sitzung s1 mit u1 (sessions) und der Code 'ok' (codes -> 's1'). Umsaetze je * Konto in transactions (uid -> Objekte), Salden in balances (uid -> Objekte). Die Umsaetze werden seitenweise * ausgeliefert (pageSize); continuation_key ist der Index der naechsten Seite als Zeichenkette. Nach date_from * wird nicht gefiltert, der Wert steht nur in calls. */ export async function startFakeEnableBanking({ publicKeyPem, appId = null }) { const calls = [] const accounts = new Map([ ['u1', { uid: 'u1', identification_hash: 'h1', account_id: { iban: 'DE00000000000000009313' }, name: 'Webklar', currency: 'EUR' }], ]) const sessions = new Map([ [ 's1', { session_id: 's1', status: 'AUTHORIZED', accounts: ['u1'], access: { valid_until: '2027-03-29T00:00:00.000Z', balances: true, transactions: true }, aspsp: { name: 'Finom', country: 'DE' }, psu_type: 'business', }, ], ]) const codes = new Map([['ok', 's1']]) const transactions = new Map() const balances = new Map() let pageSize = 100 let authSeq = 0 // Eingeplanter Fehler (failNext): der naechste passende Aufruf scheitert einmalig let pendingFailure = null // Angehaltener Aufruf (holdNext): der naechste passende Aufruf wartet, bis release() gerufen wird let pendingHold = null const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname) // JWT wie Enable Banking: RS256, typ JWT, Claims iss/aud/iat/exp (Laufzeit 1 Stunde) const jwtError = (header) => { const m = String(header || '').match(/^Bearer (\S+)$/) if (!m) return 'Authorization-Kopfzeile fehlt' const parts = m[1].split('.') if (parts.length !== 3) return 'kein JWT' try { const head = JSON.parse(Buffer.from(parts[0], 'base64url')) const claims = JSON.parse(Buffer.from(parts[1], 'base64url')) if (head.typ !== 'JWT' || head.alg !== 'RS256') return 'typ/alg falsch' if (!head.kid || (appId && head.kid !== appId)) return 'kid falsch' if (claims.iss !== 'enablebanking.com' || claims.aud !== 'api.enablebanking.com') return 'iss/aud falsch' const now = Math.floor(Date.now() / 1000) if (!Number.isInteger(claims.iat) || claims.iat > now + 60) return 'iat falsch' if (claims.exp !== claims.iat + 3600 || claims.exp <= now) return 'exp falsch' const valid = crypto.verify('sha256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKeyPem, Buffer.from(parts[2], 'base64url')) return valid ? null : 'Signatur falsch' } catch { return 'JWT nicht lesbar' } } const readBody = (req) => new Promise((resolve) => { let raw = '' req.on('data', (chunk) => (raw += chunk)) req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : null) } catch { resolve(null) } }) }) const notFound = (what) => [404, { code: 404, error: 'NOT_FOUND', message: `${what} nicht gefunden` }] const invalid = (message) => [422, { code: 422, error: 'WRONG_REQUEST_PARAMETERS', message }] const route = (method, pathname, query, body, baseUrl) => { let m if (method === 'POST' && pathname === '/auth') { if (!body?.access?.valid_until || !body?.aspsp?.name || !body?.aspsp?.country || !body?.state || !body?.redirect_url || !body?.psu_type) { return invalid('access.valid_until, aspsp, state, redirect_url und psu_type sind Pflicht') } authSeq += 1 return [200, { url: `${baseUrl}/fake-bank/auth?state=${encodeURIComponent(body.state)}`, authorization_id: `auth-${authSeq}`, psu_id_hash: 'psu-hash-1' }] } if (method === 'POST' && pathname === '/sessions') { const session = sessions.get(codes.get(body?.code)) if (!session) return invalid('Code ungueltig') return [200, { ...session, accounts: session.accounts.map((uid) => accounts.get(uid)).filter(Boolean) }] } if (method === 'GET' && (m = pathname.match(/^\/sessions\/([^/]+)$/))) { const session = sessions.get(decodeURIComponent(m[1])) return session ? [200, session] : notFound('Sitzung') } if (method === 'GET' && (m = pathname.match(/^\/accounts\/([^/]+)\/balances$/))) { const uid = decodeURIComponent(m[1]) return accounts.has(uid) ? [200, { balances: balances.get(uid) || [] }] : notFound('Konto') } if (method === 'GET' && (m = pathname.match(/^\/accounts\/([^/]+)\/transactions$/))) { const uid = decodeURIComponent(m[1]) if (!accounts.has(uid)) return notFound('Konto') const all = transactions.get(uid) || [] const start = query.has('continuation_key') ? Number(query.get('continuation_key')) : 0 if (!Number.isInteger(start) || start < 0) return invalid('continuation_key ungueltig') const end = start + pageSize return [200, { transactions: all.slice(start, end), continuation_key: end < all.length ? String(end) : null }] } return notFound('Route') } const server = http.createServer(async (req, res) => { const url = new URL(req.url, 'http://127.0.0.1') const body = await readBody(req) calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), headers: { ...req.headers }, body }) const authError = jwtError(req.headers.authorization) if (!authError && pendingHold && (!pendingHold.path || matchesPath(pendingHold.path, url.pathname))) { const hold = pendingHold pendingHold = null hold.arrive() await hold.gate } let result if (authError) { result = [401, { code: 401, error: 'UNAUTHORIZED', message: `JWT abgelehnt: ${authError}` }] } else if (pendingFailure && (!pendingFailure.path || matchesPath(pendingFailure.path, url.pathname))) { const failure = pendingFailure pendingFailure = null if (failure.destroy) { req.socket.destroy() return } result = [failure.status, failure.body] } else { result = route(req.method, url.pathname, url.searchParams, body, `http://127.0.0.1:${server.address().port}`) } res.writeHead(result[0], { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result[1])) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) return { url: `http://127.0.0.1:${server.address().port}`, calls, sessions, accounts, codes, transactions, balances, /** Umsaetze je Seite (Standard 100). */ get pageSize() { return pageSize }, set pageSize(value) { pageSize = value }, /** * Der naechste Aufruf mit gueltigem JWT (path als String oder RegExp, ohne path jeder) scheitert einmalig mit * status und body; mit destroy bricht stattdessen die Verbindung ab (Netzwerkfehler). Der Aufruf steht trotzdem * in calls. */ failNext: ({ path = null, status = 500, body = { error: 'FAKE_ERROR' }, destroy = false }) => { pendingFailure = { path, status, body, destroy } }, /** * Der naechste Aufruf mit gueltigem JWT (path wie bei failNext) bleibt stehen, bis release() gerufen wird * (ein langsamer Bankabruf); erst dann laeuft er weiter (auch ein mit failNext eingeplanter Fehler greift erst * dann). reached erfuellt sich, sobald der Aufruf angekommen ist. Liefert { reached, release }. */ holdNext: ({ path = null } = {}) => { let release let arrive const gate = new Promise((resolve) => (release = resolve)) const reached = new Promise((resolve) => (arrive = resolve)) pendingHold = { path, gate, arrive } return { reached, release } }, close: () => { server.closeAllConnections?.() return new Promise((resolve) => server.close(resolve)) }, } }