Files
ClaudeandClaude Sonnet 5.5 cba14ed0f0 Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl)
startBankAuth/completeBankAuth in bankSync.js. Die Rueckkehr prueft und speichert die
Sitzung unter der Sperre der Abrufe (ein laufender Abruf kann den neuen Zugang nicht
mehr ueberschreiben) und ruft danach einmal ab. Nachbau: holdNext haelt einen Aufruf an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 15:41:54 +00:00

198 lines
8.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
// 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))
},
}
}