Files
Webklar-Kundenbereich/test/support/fakeEnableBanking.js
T

177 lines
7.7 KiB
JavaScript

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
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)
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 }
},
close: () => {
server.closeAllConnections?.()
return new Promise((resolve) => server.close(resolve))
},
}
}