Files
Webklar-Kundenbereich/test/financeRoutesBank.test.js

436 lines
18 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 { startAppwriteMock } from './support/appwriteMock.js'
import { startFakeStripe } from './support/fakeStripe.js'
import { startFakeEnableBanking } from './support/fakeEnableBanking.js'
import { useTestEnv } from './support/testEnv.js'
import { schemaKeys } from './support/schema.js'
// Nachbauten starten und die Umgebung setzen, BEVOR config.js geladen wird (Muster wie financeBankAuth.test.js)
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(), 'bank-routes-test-'))
const keyPath = path.join(keyDir, 'private.pem')
fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8'))
const SESSION = 'sess-geheim-4711'
const ACCOUNT = 'konto-4711'
const NEW_SESSION = 'sess-neu-geheim-4712'
const NEW_ACCOUNT = 'konto-neu-1'
const IBAN = 'DE00000000000000009313'
const BANK_GONE = 'Die Bank hat die Freigabe nicht bestätigt. Bitte erneut verbinden.'
const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' })
const aw = await startAppwriteMock({
schema: { bankConnections: schemaKeys('bankConnections'), bankTransactions: schemaKeys('bankTransactions') },
})
const st = await startFakeStripe()
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
Object.assign(process.env, { ENABLE_BANKING_APP_ID: 'app-test', ENABLE_BANKING_KEY_PATH: keyPath, ENABLE_BANKING_API_BASE: fake.url })
const { config } = await import('../server/config.js')
const { startTestApp, get, post, rawGet } = await import('./support/testApp.js')
const { default: financeRoutes } = await import('../server/routes/admin/finanzen.js')
const store = await import('../server/services/finance/bankStore.js')
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
const app = await startTestApp({ '/api/admin/finanzen': financeRoutes })
after(async () => {
await Promise.all([app.close(), fake.close(), aw.close(), st.close()])
fs.rmSync(keyDir, { recursive: true, force: true })
})
const NOW = Date.now()
const MINUTE = 60_000
const minutesAgo = (minutes) => new Date(NOW - minutes * MINUTE).toISOString()
const admin = { headers: { Authorization: 'Bearer jwt-admin' } }
const base = '/api/admin/finanzen'
const connection = () => aw.docs(config.collections.bankConnections).get('finom')
const txDocs = () => aw.docs(config.collections.bankTransactions)
const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix))
const FULL_KEYS = [
'status',
'ibanMasked',
'validUntil',
'lastSyncAt',
'lastSyncOk',
'lastError',
'balanceBookedCents',
'balanceAvailableCents',
'balanceDate',
'latestBookingDate',
]
/**
* Ausgangszustand: aktiver Zugang mit Konto ACCOUNT, zwei Buchungen und Salden beim Nachbau; der Nachbau kennt
* ausserdem den Code 'neu' -> Sitzung NEW_SESSION (Konto NEW_ACCOUNT mit derselben IBAN).
*/
async function reset({ connected = true, stored = {} } = {}) {
for (const id of txDocs().keys()) await store.deleteTransaction(id)
await deleteDocument(config.collections.bankConnections, 'finom').catch(() => {})
fake.calls.length = 0
fake.accounts.clear()
fake.sessions.clear()
fake.codes.clear()
fake.transactions.clear()
fake.balances.clear()
for (const uid of [ACCOUNT, NEW_ACCOUNT]) {
fake.accounts.set(uid, { uid, identification_hash: 'h-4711', account_id: { iban: IBAN }, name: 'Webklar', currency: 'EUR' })
}
fake.sessions.set('s-neu', {
session_id: NEW_SESSION,
status: 'AUTHORIZED',
accounts: [NEW_ACCOUNT],
access: { valid_until: '2027-03-29T00:00:00.000Z', balances: true, transactions: true },
aspsp: { name: 'Finom', country: 'DE' },
psu_type: 'business',
})
fake.codes.set('neu', 's-neu')
fake.transactions.set(ACCOUNT, [
{
entry_reference: 'r1',
status: 'BOOK',
booking_date: '2026-09-29',
transaction_amount: { amount: '4.49', currency: 'EUR' },
credit_debit_indicator: 'DBIT',
creditor: { name: 'PORKBUN.COM' },
},
])
fake.balances.set(ACCOUNT, [{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }])
if (!connected) return
await store.saveConnection({
status: 'aktiv',
sessionId: SESSION,
accountUid: ACCOUNT,
accountIdHash: 'h-4711',
iban: IBAN,
ibanMasked: 'DE00…9313',
validUntil: '2027-03-29T00:00:00.000Z',
...stored,
})
}
/** Freigabe starten und den gespeicherten state lesen (wie die Seite nach "Verbinden"). */
async function begin() {
const { body } = await post(`${base}/bank/verbinden`, {}, admin)
assert.match(body.url, /fake-bank\/auth\?state=/)
return connection().authState
}
// ---------- Zugriff ----------
test('ohne JWT 401, mit Mitarbeiter-JWT ohne Admin-Label 403, bei jeder Route', async () => {
await reset()
const routes = [
['GET', '/bank'],
['POST', '/bank/aktualisieren'],
['POST', '/bank/verbinden'],
['POST', '/bank/rueckkehr'],
['GET', '/buchungen'],
]
for (const [method, route] of routes) {
const call = (headers) => (method === 'GET' ? rawGet(base + route, { headers }) : post(base + route, {}, { headers }))
assert.equal((await call({})).status, 401, `${method} ${route} ohne JWT`)
assert.equal((await call({ Authorization: 'Bearer jwt-unbekannt' })).status, 401, `${method} ${route} mit falschem JWT`)
assert.equal((await call({ Authorization: 'Bearer jwt-staff' })).status, 403, `${method} ${route} ohne Admin-Label`)
}
assert.equal(fake.calls.length, 0)
})
// ---------- GET /bank ----------
test('GET /bank: ohne Verbindung { verbindung: null }', async () => {
await reset({ connected: false })
assert.deepEqual(await get(`${base}/bank`, admin), { verbindung: null })
})
test('GET /bank: genau die Anzeigefelder, nie Sitzung, IBAN, state oder Konto-Kennungen', async () => {
await reset({
stored: {
lastSyncAt: '2026-10-01T09:00:00.000Z',
lastSyncOk: true,
lastError: '',
lastManualSyncAt: '2026-10-01T08:00:00.000Z',
balanceBookedCents: 10893,
balanceAvailableCents: 10893,
balanceDate: '2026-10-01',
latestBookingDate: '2026-09-29',
authState: 'state-geheim-abc',
authStartedAt: '2026-10-01T08:30:00.000Z',
warnedThresholds: '30',
},
})
const res = await rawGet(`${base}/bank`, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['verbindung'])
assert.deepEqual(res.body.verbindung, {
status: 'aktiv',
ibanMasked: 'DE00…9313',
validUntil: '2027-03-29T00:00:00.000Z',
lastSyncAt: '2026-10-01T09:00:00.000Z',
lastSyncOk: true,
lastError: '',
balanceBookedCents: 10893,
balanceAvailableCents: 10893,
balanceDate: '2026-10-01',
latestBookingDate: '2026-09-29',
})
const text = JSON.stringify(res.body)
for (const secret of [SESSION, ACCOUNT, IBAN, 'h-4711', 'state-geheim-abc', 'sessionId', 'iban"', 'authState', 'accountUid', 'accountIdHash', 'authStartedAt']) {
assert.ok(!text.includes(secret), `Antwort enthält ${secret}`)
}
})
test('GET /bank: noch nie abgerufen -> alle Anzeigefelder vorhanden, fehlende null', async () => {
await reset()
const { verbindung } = await get(`${base}/bank`, admin)
assert.deepEqual(Object.keys(verbindung).sort(), [...FULL_KEYS].sort())
assert.equal(verbindung.status, 'aktiv')
assert.equal(verbindung.lastSyncAt, null)
assert.equal(verbindung.balanceBookedCents, null)
})
// ---------- POST /bank/aktualisieren ----------
test('aktualisieren: zweimal hintereinander -> 200, dann 429 ohne zweiten Bankabruf', async () => {
await reset()
const first = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(first.status, 200)
assert.deepEqual(first.body, { ok: true, neu: 1, aktualisiert: 0, geloescht: 0 })
assert.equal(txDocs().size, 1)
assert.ok(Date.now() - Date.parse(connection().lastManualSyncAt) < 10_000)
const bankCalls = fake.calls.length
const second = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(second.status, 429)
assert.equal(second.body.error, 'Bitte in 10 Minuten erneut versuchen.')
assert.equal(fake.calls.length, bankCalls)
})
test('aktualisieren: die Bank bekommt IP und Browserkennung des Nutzers (Abruf durch einen Menschen)', async () => {
await reset()
const res = await post(`${base}/bank/aktualisieren`, {}, { headers: { ...admin.headers, 'User-Agent': 'WOMS-Test/1.0', 'X-Forwarded-For': '203.0.113.7' } })
assert.equal(res.status, 200)
const [balances] = callsTo('/balances')
assert.equal(balances.headers['psu-user-agent'], 'WOMS-Test/1.0')
// trust proxy 1: die Adresse aus X-Forwarded-For
assert.equal(balances.headers['psu-ip-address'], '203.0.113.7')
})
test('aktualisieren: Wartezeit zählt Minuten nach oben; nach 10 Minuten geht es wieder', async () => {
await reset({ stored: { lastManualSyncAt: minutesAgo(3) } })
const blocked = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(blocked.status, 429)
assert.equal(blocked.body.error, 'Bitte in 7 Minuten erneut versuchen.')
assert.equal(connection().lastManualSyncAt, minutesAgo(3), 'ein abgelehnter Aufruf verlängert die Sperre nicht')
await store.saveConnection({ lastManualSyncAt: new Date(NOW - 9.5 * MINUTE).toISOString() })
const almost = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(almost.status, 429)
assert.equal(almost.body.error, 'Bitte in 1 Minute erneut versuchen.')
await store.saveConnection({ lastManualSyncAt: minutesAgo(10.5) })
assert.equal((await post(`${base}/bank/aktualisieren`, {}, admin)).status, 200)
})
test('aktualisieren: zwei gleichzeitige Klicks -> ein Abruf, der andere 429', async () => {
await reset()
const [a, b] = await Promise.all([post(`${base}/bank/aktualisieren`, {}, admin), post(`${base}/bank/aktualisieren`, {}, admin)])
assert.deepEqual([a.status, b.status].sort(), [200, 429])
assert.equal(callsTo('/balances').length, 1)
})
test('aktualisieren: Fehler der Bank kommen als Ergebnis (200, ok false), ohne Sitzungs-ID', async () => {
await reset()
fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } })
const res = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(res.status, 200)
assert.equal(res.body.ok, false)
assert.equal(typeof res.body.error, 'string')
assert.ok(!JSON.stringify(res.body).includes(SESSION))
})
test('aktualisieren: ohne Bankzugang -> ok false, es wird keine Verbindung angelegt', async () => {
await reset({ connected: false })
const res = await post(`${base}/bank/aktualisieren`, {}, admin)
assert.equal(res.status, 200)
assert.equal(res.body.ok, false)
assert.equal(res.body.error, 'Kein aktiver Bankzugang')
assert.equal(connection(), undefined)
})
// ---------- POST /bank/verbinden ----------
test('verbinden: liefert { url } der Bank und merkt sich state', async () => {
await reset()
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['url'])
assert.match(res.body.url, /^http:\/\/127\.0\.0\.1:\d+\/fake-bank\/auth\?state=[a-f0-9]{24}$/)
assert.ok(res.body.url.endsWith(connection().authState))
// der laufende Zugang bleibt unberührt
assert.equal(connection().sessionId, SESSION)
assert.equal(connection().status, 'aktiv')
})
test('verbinden: scheitert die Bank -> 502 mit fester Meldung, ohne Einzelheiten', async () => {
await reset()
fake.failNext({ path: '/auth', status: 500 })
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
assert.equal(connection().authState, undefined)
})
test('verbinden: antwortet die Bank mit 401, kommt trotzdem 502 (nie 401 durchreichen)', async () => {
await reset()
fake.failNext({ path: '/auth', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const res = await post(`${base}/bank/verbinden`, {}, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
})
// ---------- POST /bank/rueckkehr ----------
test('rueckkehr: falscher state -> 400 mit Meldung, Zugang bleibt', async () => {
await reset()
await begin()
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state: 'falsch' }, admin)
assert.equal(res.status, 400)
assert.deepEqual(res.body, { error: 'Diese Rückkehr passt zu keiner gestarteten Freigabe.' })
assert.equal(connection().sessionId, SESSION)
assert.equal(callsTo('/sessions').length, 0)
})
test('rueckkehr: ohne Body / ohne state -> 400', async () => {
await reset()
await begin()
assert.equal((await post(`${base}/bank/rueckkehr`, {}, admin)).status, 400)
assert.equal((await post(`${base}/bank/rueckkehr`, { code: 'neu' }, admin)).status, 400)
})
test('rueckkehr: Finom meldet einen Fehler -> 400 mit Meldung', async () => {
await reset()
const state = await begin()
const res = await post(`${base}/bank/rueckkehr`, { state, error: 'access_denied' }, admin)
assert.equal(res.status, 400)
assert.equal(res.body.error, 'Finom hat die Freigabe nicht erteilt (access_denied).')
})
test('rueckkehr: abgelaufene Freigabe -> 400', async () => {
await reset()
const state = await begin()
await store.saveConnection({ authStartedAt: minutesAgo(45) })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 400)
assert.equal(res.body.error, 'Die Freigabe ist abgelaufen. Bitte neu starten.')
})
test('rueckkehr: richtiger state -> { ok: true, validUntil }, Sitzung gespeichert und nie in der Antwort', async () => {
await reset()
const state = await begin()
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 200)
assert.deepEqual(res.body, { ok: true, validUntil: '2027-03-29T00:00:00.000Z' })
const text = JSON.stringify(res.body)
assert.ok(!text.includes(NEW_SESSION) && !text.includes(SESSION))
assert.equal(connection().sessionId, NEW_SESSION)
assert.equal(connection().accountUid, NEW_ACCOUNT)
assert.equal(connection().status, 'aktiv')
assert.equal(connection().authState, '')
// die Rückkehr hat einmal abgerufen
assert.equal(callsTo('/balances').length, 1)
assert.equal(txDocs().size, 0, 'Nachbau kennt für das neue Konto keine Buchungen')
})
test('rueckkehr: scheitert der Codetausch bei der Bank (401) -> 502 mit fester Meldung, nie 401, keine Sitzungs-ID', async () => {
await reset()
const state = await begin()
fake.failNext({ path: '/sessions', status: 401, body: { code: 401, error: 'UNAUTHORIZED' } })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
assert.ok(!JSON.stringify(res.body).includes(SESSION))
// der bisherige Zugang bleibt, der state gilt weiter (neuer Versuch möglich)
assert.equal(connection().sessionId, SESSION)
assert.equal(connection().authState, state)
})
test('rueckkehr: lehnt die Bank den Code mit 400 ab, ist das für die Seite ebenfalls 502 (nur die eigene Prüfung gibt 400)', async () => {
await reset()
const state = await begin()
fake.failNext({ path: '/sessions', status: 400, body: { code: 400, error: 'INVALID_CODE' } })
const res = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(res.status, 502)
assert.deepEqual(res.body, { error: BANK_GONE })
})
test('rueckkehr: jeder andere Fehler (Bank 500, Appwrite gestört) -> 502 mit fester Meldung', async () => {
await reset()
let state = await begin()
fake.failNext({ path: '/sessions', status: 500 })
const bank = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(bank.status, 502)
assert.deepEqual(bank.body, { error: BANK_GONE })
state = connection().authState
aw.failNext({ collection: config.collections.bankConnections, method: 'GET', status: 500, message: 'Appwrite-Geheimnis' })
const appwrite = await post(`${base}/bank/rueckkehr`, { code: 'neu', state }, admin)
assert.equal(appwrite.status, 502)
assert.deepEqual(appwrite.body, { error: BANK_GONE })
assert.ok(!JSON.stringify(appwrite.body).includes('Appwrite-Geheimnis'))
})
// ---------- GET /buchungen ----------
test('buchungen: neueste zuerst, nur Felder der Buchung und $id', async () => {
await reset()
const row = (id, bookingDate, amountCents) => ({
$id: id,
accountUid: ACCOUNT,
status: 'BOOK',
bookingDate,
valueDate: bookingDate,
amountCents,
currency: 'EUR',
counterpartyName: 'PORKBUN.COM',
counterpartyIban: '',
remittance: '',
mcc: '4816',
entryReference: id,
category: '',
matchedInvoiceId: '',
matchedInvoiceNumber: '',
matchedPayoutId: '',
fixedCostId: '',
note: '',
syncedAt: '2026-10-01T09:00:00.000Z',
})
aw.seed(config.collections.bankTransactions, row('b-mitte', '2026-09-15', -449))
aw.seed(config.collections.bankTransactions, row('b-alt', '2026-08-05', 16879))
aw.seed(config.collections.bankTransactions, row('b-neu', '2026-09-29', -50))
const res = await rawGet(`${base}/buchungen`, admin)
assert.equal(res.status, 200)
assert.deepEqual(Object.keys(res.body), ['buchungen'])
assert.deepEqual(res.body.buchungen.map((b) => b.$id), ['b-neu', 'b-mitte', 'b-alt'])
assert.deepEqual(res.body.buchungen.map((b) => b.amountCents), [-50, -449, 16879])
for (const buchung of res.body.buchungen) {
assert.deepEqual(Object.keys(buchung).filter((key) => key.startsWith('$')), ['$id'])
}
assert.equal(res.body.buchungen[0].counterpartyName, 'PORKBUN.COM')
assert.equal(res.body.buchungen[0].category, '')
})
test('buchungen: leer -> { buchungen: [] }', async () => {
await reset()
assert.deepEqual(await get(`${base}/buchungen`, admin), { buchungen: [] })
})