From 9329318df5d5e7ff8a9c76ae000a6514737f410c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:07:56 +0000 Subject: [PATCH] Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze) Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 37 +++- server/services/finance/enableBanking.js | 203 ++++++++++++++++++ test/financeEnableBanking.test.js | 257 +++++++++++++++++++++++ test/support/fakeEnableBanking.js | 176 ++++++++++++++++ 4 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 server/services/finance/enableBanking.js create mode 100644 test/financeEnableBanking.test.js create mode 100644 test/support/fakeEnableBanking.js diff --git a/server/config.js b/server/config.js index 4d5b1b1..82766f1 100644 --- a/server/config.js +++ b/server/config.js @@ -98,6 +98,30 @@ const newsSetup = { priceNickname: 'News-Einrichtung 30 €', } +// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut +const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '') + +const ENABLE_BANKING_API_BASE = 'https://api.enablebanking.com' +const enableBankingAppId = (process.env.ENABLE_BANKING_APP_ID || '').trim() +const enableBankingKeyPath = (process.env.ENABLE_BANKING_KEY_PATH || '').trim() + +/** + * Basisadresse der Enable-Banking-API. ENABLE_BANKING_API_BASE (nur fuer Tests: Nachbau) gilt nur fuer + * 127.0.0.1 oder localhost; jeder andere Wert wird ignoriert, damit ein verirrter Wert den signierten + * Zugriff (JWT) nie an einen fremden Server schickt. + */ +export function resolveEnableBankingApiBase(value) { + const raw = String(value || '').trim() + if (!raw) return ENABLE_BANKING_API_BASE + try { + const url = new URL(raw) + if (['http:', 'https:'].includes(url.protocol) && ['127.0.0.1', 'localhost'].includes(url.hostname)) return url.origin + } catch { + // keine gueltige Adresse: Standard + } + return ENABLE_BANKING_API_BASE +} + export const config = { port: Number(process.env.PORT) || 3000, sessionSecret: process.env.SESSION_SECRET || '', @@ -133,7 +157,7 @@ export const config = { publicBaseUrl: (process.env.PORTAL_PUBLIC_URL || 'https://project.webklar.com').replace(/\/$/, ''), // Ticketsystem (WOMS), z. B. fuer den Link "Ticket oeffnen" in der Push-Zeitmail - womsBaseUrl: (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, ''), + womsBaseUrl, // Push-Zeitmail: signierte Links zum Eintragen der Zeit eines Git-Pushes (ohne Login) pushTime: { @@ -172,6 +196,17 @@ export const config = { 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet (Kleinunternehmerregelung). WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler', }, + // Finom-Geschaeftskonto lesend ueber Enable Banking (PSD2). Der private Schluessel liegt als Datei auf dem + // Server (nie im Repo); der Client liest ihn bei jedem Aufruf, ein Schluesseltausch wirkt ohne Neustart. + enableBanking: { + appId: enableBankingAppId, + keyPath: enableBankingKeyPath, + apiBase: resolveEnableBankingApiBase(process.env.ENABLE_BANKING_API_BASE), + redirectUrl: `${womsBaseUrl}/finanzen/bank-callback`, + aspsp: { name: 'Finom', country: 'DE' }, + enabled: Boolean(enableBankingAppId && enableBankingKeyPath), + }, + abo: { // Bei jeder Textaenderung an Bedingungen oder Bestellseite hochziehen (wird mit der Zustimmung gespeichert) termsVersion: '2026-09-30-v5', diff --git a/server/services/finance/enableBanking.js b/server/services/finance/enableBanking.js new file mode 100644 index 0000000..0c4eac2 --- /dev/null +++ b/server/services/finance/enableBanking.js @@ -0,0 +1,203 @@ +import crypto from 'node:crypto' +import { readFileSync } from 'node:fs' +import { config } from '../../config.js' + +/** + * Client fuer die Enable-Banking-API (PSD2-Lesezugriff auf das Finom-Geschaeftskonto). + * + * Jeder Aufruf traegt ein frisch signiertes JWT (RS256, kid = App-ID). Der private Schluessel wird bei + * jedem JWT von der Platte gelesen, ein Schluesseltausch wirkt also ohne Neustart. Betraege sind intern + * immer ganze Cent (Ganzzahl). Weder der Schluessel noch Sitzungs- oder Konto-IDs landen in Fehlermeldungen + * oder Logs: Meldungen nennen nur den Vorgang, den HTTP-Status und den Fehlercode der API. + */ + +// JWT-Laufzeit (Sekunden) und Zeitlimit je Anfrage (Millisekunden) +const JWT_TTL_SECONDS = 3600 +const REQUEST_TIMEOUT_MS = 30_000 +// Obergrenze fuer das Blaettern durch Umsatzseiten (Schutz vor einer endlosen continuation_key-Kette) +const MAX_TRANSACTION_PAGES = 20 + +/** Fehler der Bank-Schnittstelle. status = HTTP-Status (0 = keine Antwort), code = Fehlercode der API oder null. */ +export class EnableBankingError extends Error { + constructor(message, { status = 0, code = null } = {}) { + super(message) + this.name = 'EnableBankingError' + this.status = status + this.code = code + } +} + +const base64url = (value) => Buffer.from(value).toString('base64url') + +/** + * Signiertes JWT fuer die API. now in Millisekunden (nur fuer Tests ueberschreiben). + * Header { typ: 'JWT', alg: 'RS256', kid: App-ID }, Claims iss/aud/iat/exp mit einer Stunde Laufzeit. + */ +export function createJwt({ now = Date.now() } = {}) { + const { appId, keyPath, enabled } = config.enableBanking + if (!enabled) { + throw new EnableBankingError('Die Bankanbindung ist noch nicht eingerichtet (ENABLE_BANKING_APP_ID/ENABLE_BANKING_KEY_PATH fehlen).', { + status: 503, + code: 'NOT_CONFIGURED', + }) + } + const iat = Math.floor(now / 1000) + const header = base64url(JSON.stringify({ typ: 'JWT', alg: 'RS256', kid: appId })) + const payload = base64url(JSON.stringify({ iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat, exp: iat + JWT_TTL_SECONDS })) + let signature + try { + // Absichtlich ohne Fehlerdetails weitergeben: Pfad und Schluesselinhalt sollen nirgends auftauchen + const key = crypto.createPrivateKey(readFileSync(keyPath)) + signature = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), key).toString('base64url') + } catch { + throw new EnableBankingError('Der Schlüssel für die Bankanbindung konnte nicht gelesen werden.', { status: 500, code: 'KEY_UNREADABLE' }) + } + return `${header}.${payload}.${signature}` +} + +/** + * Ein API-Aufruf. label beschreibt den Vorgang fuer die Fehlermeldung (ohne IDs). psu = { ip, userAgent } + * wird als Psu-Ip-Address / Psu-User-Agent mitgeschickt, wenn der Nutzer den Abruf selbst ausgeloest hat. + */ +async function request(method, path, { label, query = null, body = null, psu = null } = {}) { + const url = new URL(`${config.enableBanking.apiBase}${path}`) + for (const [key, value] of Object.entries(query || {})) { + if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, value) + } + const headers = { Authorization: `Bearer ${createJwt()}`, Accept: 'application/json' } + if (body) headers['Content-Type'] = 'application/json' + if (psu?.ip) headers['Psu-Ip-Address'] = psu.ip + if (psu?.userAgent) headers['Psu-User-Agent'] = psu.userAgent + + let res + let text + try { + res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + // Der Bearer-Token geht nie an eine andere Adresse + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + text = await res.text() + } catch (err) { + // Die Originalmeldung nennt die Adresse (mit Sitzungs-/Konto-ID) und bleibt deshalb draussen + throw new EnableBankingError(`Bankanbindung: ${label} nicht erreichbar.`, { + status: 0, + code: err?.name === 'TimeoutError' ? 'TIMEOUT' : 'NETWORK', + }) + } + + let data = null + try { + data = text ? JSON.parse(text) : null + } catch { + data = null + } + if (!res.ok) { + const code = data && typeof data === 'object' ? (data.code ?? data.error ?? null) : null + throw new EnableBankingError(`Bankanbindung: ${label} fehlgeschlagen (HTTP ${res.status}${code === null ? '' : `, ${code}`}).`, { + status: res.status, + code, + }) + } + if (data === null || typeof data !== 'object') { + throw new EnableBankingError(`Bankanbindung: ${label} lieferte eine unlesbare Antwort.`, { status: res.status, code: null }) + } + return data +} + +/** + * Dezimalbetrag als Zeichenkette ("108.93", "-0.5") in ganze Cent. Ganzzahl- und Nachkommateil werden + * getrennt gelesen, es gibt keine Gleitkommarechnung und keine stille Rundung: mehr als zwei von 0 + * verschiedene Nachkommastellen oder alles, was kein Dezimalbetrag ist, wirft einen Fehler. + */ +export function toCents(amount) { + const text = typeof amount === 'number' && Number.isFinite(amount) ? String(amount) : amount + const match = typeof text === 'string' ? text.trim().match(/^([+-]?)(\d+)(?:\.(\d+))?$/) : null + if (!match) throw new Error('Ungültiger Betrag von der Bank.') + const [, sign, whole, fraction = ''] = match + if (/[^0]/.test(fraction.slice(2))) throw new Error('Ungültiger Betrag von der Bank (mehr als zwei Nachkommastellen).') + const cents = Number(whole) * 100 + Number(fraction.slice(0, 2).padEnd(2, '0')) + if (!Number.isSafeInteger(cents)) throw new Error('Ungültiger Betrag von der Bank (zu groß).') + if (cents === 0) return 0 + return sign === '-' ? -cents : cents +} + +/** + * Freigabe starten (POST /auth): liefert die Bank-Adresse, zu der der Nutzer geschickt wird, und die + * Freigabe-ID. state kommt unveraendert an die Rueckkehradresse zurueck; validUntil (ISO) ist das Ende der Freigabe. + */ +export async function startAuth({ state, validUntil }) { + const { aspsp, redirectUrl } = config.enableBanking + const data = await request('POST', '/auth', { + label: 'Freigabe starten', + body: { + access: { valid_until: validUntil }, + aspsp: { name: aspsp.name, country: aspsp.country }, + state, + redirect_url: redirectUrl, + psu_type: 'business', + }, + }) + return { url: data.url, authorizationId: data.authorization_id } +} + +/** Aus dem Code der Rueckkehradresse eine Sitzung mit den freigegebenen Konten machen (POST /sessions). */ +export async function createSession(code) { + const data = await request('POST', '/sessions', { label: 'Sitzung anlegen', body: { code } }) + return { + sessionId: data.session_id, + validUntil: data.access?.valid_until ?? null, + accounts: (data.accounts || []).map((account) => ({ + uid: account.uid, + identificationHash: account.identification_hash ?? null, + iban: account.account_id?.iban ?? null, + name: account.name ?? null, + currency: account.currency ?? null, + })), + } +} + +/** Zustand einer Sitzung (GET /sessions/:id): status (z. B. AUTHORIZED, EXPIRED), Ende der Freigabe, Konto-IDs. */ +export async function getSession(sessionId) { + const data = await request('GET', `/sessions/${encodeURIComponent(sessionId)}`, { label: 'Sitzung abrufen' }) + return { + status: data.status, + validUntil: data.access?.valid_until ?? null, + // Die API nennt hier nur die IDs; Konto-Objekte werden trotzdem toleriert + accountUids: (data.accounts || []).map((account) => (typeof account === 'string' ? account : account.uid)), + } +} + +/** Salden eines Kontos (GET /accounts/:uid/balances) in ganzen Cent. */ +export async function getBalances(accountUid, { psu = null } = {}) { + const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/balances`, { label: 'Salden abrufen', psu }) + return (data.balances || []).map((balance) => ({ + type: balance.balance_type, + amountCents: toCents(balance.balance_amount?.amount), + currency: balance.balance_amount?.currency ?? null, + date: balance.reference_date ?? null, + })) +} + +/** + * Umsaetze eines Kontos ab dateFrom (YYYY-MM-DD), alle Seiten (continuation_key), hoechstens 20 Aufrufe. + * Liefert die Rohobjekte von Enable Banking; die Aufbereitung uebernimmt der Aufrufer. + */ +export async function getTransactions(accountUid, { dateFrom = null, psu = null } = {}) { + const all = [] + let continuationKey = null + for (let page = 0; page < MAX_TRANSACTION_PAGES; page += 1) { + const data = await request('GET', `/accounts/${encodeURIComponent(accountUid)}/transactions`, { + label: 'Umsätze abrufen', + query: { date_from: dateFrom, continuation_key: continuationKey }, + psu, + }) + all.push(...(data.transactions || [])) + continuationKey = data.continuation_key || null + if (!continuationKey) break + } + return all +} diff --git a/test/financeEnableBanking.test.js b/test/financeEnableBanking.test.js new file mode 100644 index 0000000..143497f --- /dev/null +++ b/test/financeEnableBanking.test.js @@ -0,0 +1,257 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { startFakeEnableBanking } from './support/fakeEnableBanking.js' + +// Schluesselpaar erzeugen, privaten Schluessel als Datei ablegen und den Nachbau mit dem oeffentlichen +// starten; die Umgebung muss stehen, BEVOR config.js geladen wird +const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) +const pem = (key, type) => key.export({ type, format: 'pem' }) +const keyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'enable-banking-test-')) +const keyPath = path.join(keyDir, 'private.pem') +fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8')) + +const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' }) +Object.assign(process.env, { + ENABLE_BANKING_APP_ID: 'app-test', + ENABLE_BANKING_KEY_PATH: keyPath, + ENABLE_BANKING_API_BASE: fake.url, +}) +const { config, resolveEnableBankingApiBase } = await import('../server/config.js') +const { createJwt, startAuth, createSession, getSession, getBalances, getTransactions, toCents, EnableBankingError } = await import( + '../server/services/finance/enableBanking.js' +) + +after(async () => { + await fake.close() + fs.rmSync(keyDir, { recursive: true, force: true }) +}) + +const SECRET_SESSION_ID = 'sess-geheim-4711' + +test('config.enableBanking: Werte aus der Umgebung, Finom, Rueckkehradresse', () => { + assert.deepEqual(config.enableBanking, { + appId: 'app-test', + keyPath, + apiBase: fake.url, + redirectUrl: 'https://ticket.webklar.com/finanzen/bank-callback', + aspsp: { name: 'Finom', country: 'DE' }, + enabled: true, + }) +}) + +test('createJwt: RS256, kid = App-ID, Claims', () => { + const [h, p, s] = createJwt({ now: 1_790_000_000_000 }).split('.') + const header = JSON.parse(Buffer.from(h, 'base64url')) + const payload = JSON.parse(Buffer.from(p, 'base64url')) + assert.deepEqual(header, { typ: 'JWT', alg: 'RS256', kid: 'app-test' }) + assert.deepEqual(payload, { iss: 'enablebanking.com', aud: 'api.enablebanking.com', iat: 1_790_000_000, exp: 1_790_003_600 }) + assert.ok(crypto.verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(s, 'base64url'))) +}) + +test('createJwt liest den Schluessel bei jedem Aufruf (Tausch ohne Neustart)', async () => { + const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }) + fs.writeFileSync(keyPath, pem(other.privateKey, 'pkcs8')) + try { + const [h, p, s] = createJwt().split('.') + assert.ok(crypto.verify('sha256', Buffer.from(`${h}.${p}`), other.publicKey, Buffer.from(s, 'base64url'))) + assert.ok(!crypto.verify('sha256', Buffer.from(`${h}.${p}`), publicKey, Buffer.from(s, 'base64url'))) + // Der Nachbau prueft die Signatur wirklich: mit dem fremden Schluessel gibt es 401 + await assert.rejects( + () => getBalances('u1'), + (err) => err instanceof EnableBankingError && err.status === 401 + ) + } finally { + fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8')) + } +}) + +test('createJwt: unlesbarer Schluessel -> EnableBankingError ohne Pfad oder Inhalt in der Meldung', () => { + const original = fs.readFileSync(keyPath) + fs.writeFileSync(keyPath, '-----BEGIN PRIVATE KEY-----\nGEHEIMER-INHALT\n-----END PRIVATE KEY-----\n') + try { + assert.throws( + () => createJwt(), + (err) => err instanceof EnableBankingError && !err.message.includes('GEHEIMER') && !err.message.includes(keyPath) + ) + } finally { + fs.writeFileSync(keyPath, original) + } +}) + +test('startAuth schickt Finom, business, Redirect und valid_until', async () => { + const r = await startAuth({ state: 'abc', validUntil: '2027-03-29T00:00:00.000Z' }) + assert.match(r.url, /^https?:\/\//) + assert.equal(r.authorizationId, 'auth-1') + const call = fake.calls.at(-1) + assert.equal(call.method, 'POST') + assert.equal(call.path, '/auth') + assert.deepEqual(call.body, { + access: { valid_until: '2027-03-29T00:00:00.000Z' }, + aspsp: { name: 'Finom', country: 'DE' }, + state: 'abc', + redirect_url: 'https://ticket.webklar.com/finanzen/bank-callback', + psu_type: 'business', + }) +}) + +test('createSession liefert Sitzung und Konten', async () => { + const s = await createSession('ok') + assert.equal(s.sessionId, 's1') + assert.equal(s.validUntil, '2027-03-29T00:00:00.000Z') + assert.deepEqual(s.accounts, [{ uid: 'u1', identificationHash: 'h1', iban: 'DE00000000000000009313', name: 'Webklar', currency: 'EUR' }]) + assert.deepEqual(fake.calls.at(-1).body, { code: 'ok' }) +}) + +test('createSession: abgelehnter Code -> EnableBankingError mit status 422', async () => { + await assert.rejects( + () => createSession('falsch'), + (err) => err instanceof EnableBankingError && err.status === 422 + ) +}) + +test('getSession liefert Status, Gueltigkeit und Konto-IDs', async () => { + const s = await getSession('s1') + assert.deepEqual(s, { status: 'AUTHORIZED', validUntil: '2027-03-29T00:00:00.000Z', accountUids: ['u1'] }) + assert.equal(fake.calls.at(-1).path, '/sessions/s1') +}) + +test('getBalances rechnet in Cent', async () => { + fake.balances.set('u1', [ + { balance_type: 'ITAV', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }, + { balance_type: 'CLBD', balance_amount: { amount: '0.5', currency: 'EUR' } }, + { balance_type: 'ITBD', balance_amount: { amount: '-12.30', currency: 'EUR' }, reference_date: '2026-09-30' }, + ]) + const balances = await getBalances('u1') + assert.deepEqual(balances, [ + { type: 'ITAV', amountCents: 10893, currency: 'EUR', date: '2026-10-01' }, + { type: 'CLBD', amountCents: 50, currency: 'EUR', date: null }, + { type: 'ITBD', amountCents: -1230, currency: 'EUR', date: '2026-09-30' }, + ]) +}) + +test('toCents: Ganz- und Nachkommateil getrennt, ohne Rundung', () => { + assert.equal(toCents('108.93'), 10893) + assert.equal(toCents('0.5'), 50) + assert.equal(toCents('5'), 500) + assert.equal(toCents('0.07'), 7) + assert.equal(toCents('-12.30'), -1230) + assert.equal(toCents('+3.10'), 310) + assert.equal(toCents('10.500'), 1050) + assert.equal(toCents('1234567.89'), 123456789) + // 19.99 * 100 ergibt in Gleitkomma 1998.9999999999998; hier exakt + assert.equal(toCents('19.99'), 1999) + assert.equal(toCents('-0.00'), 0) + assert.ok(Object.is(toCents('-0.00'), 0)) + assert.equal(toCents(42.5), 4250) + // mehr als zwei echte Nachkommastellen oder Muell: nie still runden + for (const bad of ['10.505', '', 'abc', '1,50', '1.', '.5', '--1', null, undefined, '1e3', '9007199254740993.00']) { + assert.throws(() => toCents(bad), (err) => err instanceof Error, `toCents(${JSON.stringify(bad)}) muss scheitern`) + } +}) + +test('getTransactions holt alle Seiten (continuation_key), hoechstens 20', async () => { + const tx = (i) => ({ entry_reference: `e${i}`, transaction_id: `t${i}`, transaction_amount: { amount: '1.00', currency: 'EUR' }, status: 'BOOK' }) + fake.transactions.set('u1', [0, 1, 2, 3, 4].map(tx)) + fake.pageSize = 2 + const start = fake.calls.length + const all = await getTransactions('u1', { dateFrom: '2026-09-01' }) + assert.deepEqual(all.map((t) => t.entry_reference), ['e0', 'e1', 'e2', 'e3', 'e4']) + const calls = fake.calls.slice(start) + assert.equal(calls.length, 3) + assert.deepEqual(calls.map((c) => c.query), [ + { date_from: '2026-09-01' }, + { date_from: '2026-09-01', continuation_key: '2' }, + { date_from: '2026-09-01', continuation_key: '4' }, + ]) + + // ohne dateFrom kein date_from; endlos viele Seiten -> nach 20 Aufrufen Schluss + fake.transactions.set('u1', Array.from({ length: 100 }, (_, i) => tx(i))) + const before = fake.calls.length + const capped = await getTransactions('u1') + assert.equal(fake.calls.length - before, 20) + assert.equal(capped.length, 40) + assert.deepEqual(fake.calls[before].query, {}) + fake.pageSize = 100 +}) + +test('PSU-Kopfzeilen nur wenn psu uebergeben', async () => { + fake.transactions.set('u1', []) + await getBalances('u1') + assert.equal(fake.calls.at(-1).headers['psu-ip-address'], undefined) + assert.equal(fake.calls.at(-1).headers['psu-user-agent'], undefined) + + await getBalances('u1', { psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } }) + assert.equal(fake.calls.at(-1).headers['psu-ip-address'], '203.0.113.7') + assert.equal(fake.calls.at(-1).headers['psu-user-agent'], 'Mozilla/5.0 Test') + + await getTransactions('u1', { dateFrom: '2026-09-01', psu: { ip: '203.0.113.8', userAgent: 'Agent/2' } }) + assert.equal(fake.calls.at(-1).headers['psu-ip-address'], '203.0.113.8') + assert.equal(fake.calls.at(-1).headers['psu-user-agent'], 'Agent/2') +}) + +test('Fehler -> EnableBankingError mit status und code, ohne Sitzungs-ID in der Meldung', async () => { + fake.failNext({ path: /\/accounts\//, status: 401, body: { code: 'EXPIRED_SESSION' } }) + await assert.rejects( + () => getBalances('u1', { psu: { ip: '203.0.113.7', userAgent: 'x' } }), + (err) => { + assert.ok(err instanceof EnableBankingError) + assert.equal(err.name, 'EnableBankingError') + assert.equal(err.status, 401) + assert.equal(err.code, 'EXPIRED_SESSION') + assert.ok(!err.message.includes('u1')) + return true + } + ) + + // Sitzungs-ID steht im Pfad, darf aber weder in Meldung noch im Stack-Text der Meldung landen + fake.failNext({ path: `/sessions/${SECRET_SESSION_ID}`, status: 404, body: { error: 'SESSION_NOT_FOUND', message: `Sitzung ${SECRET_SESSION_ID} unbekannt` } }) + await assert.rejects( + () => getSession(SECRET_SESSION_ID), + (err) => { + assert.ok(err instanceof EnableBankingError) + assert.equal(err.status, 404) + assert.equal(err.code, 'SESSION_NOT_FOUND') + assert.ok(!err.message.includes(SECRET_SESSION_ID)) + assert.ok(!JSON.stringify(err).includes(SECRET_SESSION_ID)) + return true + } + ) + + // Fehlerantwort ohne code/error: code bleibt null + fake.failNext({ status: 500, body: {} }) + await assert.rejects( + () => getBalances('u1'), + (err) => err instanceof EnableBankingError && err.status === 500 && err.code === null + ) +}) + +test('Verbindungsabbruch -> EnableBankingError (status 0), ohne Sitzungs-ID in der Meldung', async () => { + fake.failNext({ destroy: true }) + await assert.rejects( + () => getSession(SECRET_SESSION_ID), + (err) => err instanceof EnableBankingError && err.status === 0 && err.code === 'NETWORK' && !err.message.includes(SECRET_SESSION_ID) + ) +}) + +test('ENABLE_BANKING_API_BASE ausserhalb von 127.0.0.1 wird ignoriert', () => { + const standard = 'https://api.enablebanking.com' + assert.equal(resolveEnableBankingApiBase(''), standard) + assert.equal(resolveEnableBankingApiBase(undefined), standard) + assert.equal(resolveEnableBankingApiBase('http://127.0.0.1:12345'), 'http://127.0.0.1:12345') + assert.equal(resolveEnableBankingApiBase('http://localhost:12345/'), 'http://localhost:12345') + for (const fremd of [ + 'https://api.example.com', + 'http://127.0.0.1.evil.example', + 'http://127.0.0.1@evil.example', + 'http://evil.example/127.0.0.1', + 'http://[::1]:80', + 'ftp://127.0.0.1', + 'keine adresse', + ]) { + assert.equal(resolveEnableBankingApiBase(fremd), standard, fremd) + } +}) diff --git a/test/support/fakeEnableBanking.js b/test/support/fakeEnableBanking.js new file mode 100644 index 0000000..be723fd --- /dev/null +++ b/test/support/fakeEnableBanking.js @@ -0,0 +1,176 @@ +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)) + }, + } +}