Finanzen: Enable-Banking-Client (JWT, Freigabe, Sitzung, Salden, Umsaetze)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:41:54 +00:00
1 parent 53b50011e8
commit 9329318df5
4 files changed
+672 -1

No files matched your search

+257
View File
@@ -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)
}
})
+176
View File
@@ -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))
},
}
}