258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
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)
|
|
}
|
|
})
|