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: [] }) })