177 lines
7.7 KiB
JavaScript
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))
|
|
},
|
|
}
|
|
}
|