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 financeBankSync.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-auth-test-')) const keyPath = path.join(keyDir, 'private.pem') fs.writeFileSync(keyPath, pem(privateKey, 'pkcs8')) const OLD_ACCOUNT = 'konto-alt-4711' const OLD_SESSION = 'sess-alt-geheim-4711' const NEW_SESSION = 'sess-neu-geheim-4712' const fake = await startFakeEnableBanking({ publicKeyPem: pem(publicKey, 'spki'), appId: 'app-test' }) const account = (uid, { hash, iban, currency = 'EUR', name = 'Webklar' }) => ({ uid, identification_hash: hash, account_id: iban ? { iban } : {}, name, currency, }) 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 { startBankAuth, completeBankAuth, syncBank } = await import('../server/services/finance/bankSync.js') const store = await import('../server/services/finance/bankStore.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') after(async () => { await Promise.all([fake.close(), aw.close(), st.close()]) fs.rmSync(keyDir, { recursive: true, force: true }) }) const NOW = new Date('2026-10-01T09:00:00Z') const MINUTE = 60_000 const later = (minutes) => new Date(NOW.getTime() + minutes * MINUTE) const IBAN_A = 'DE00000000000000009313' const IBAN_B = 'DE00000000000000001234' const txDocs = () => aw.docs(config.collections.bankTransactions) const connection = () => aw.docs(config.collections.bankConnections).get('finom') const callsTo = (suffix) => fake.calls.filter((c) => c.path.endsWith(suffix)) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** * Ausgangszustand: alter Zugang (Konto OLD_ACCOUNT, Hash h-alt, IBAN_A) mit Warnstufen und Fehler, keine Buchungen. * Der Nachbau kennt danach nur das alte Konto und den Code 'neu' -> Sitzung NEW_SESSION mit dem Konto aus accounts. */ async function reset({ accounts = [account('konto-neu-1', { hash: 'h-alt', iban: IBAN_A })], stored = {}, validUntil = '2027-03-29T00:00:00.000Z' } = {}) { 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() fake.accounts.set(OLD_ACCOUNT, account(OLD_ACCOUNT, { hash: 'h-alt', iban: IBAN_A })) for (const a of accounts) fake.accounts.set(a.uid, a) fake.sessions.set('s-neu', { session_id: NEW_SESSION, status: 'AUTHORIZED', accounts: accounts.map((a) => a.uid), ...(validUntil ? { access: { valid_until: validUntil, balances: true, transactions: true } } : {}), aspsp: { name: 'Finom', country: 'DE' }, psu_type: 'business', }) fake.codes.set('neu', 's-neu') await store.saveConnection({ status: 'abgelaufen', sessionId: OLD_SESSION, accountUid: OLD_ACCOUNT, accountIdHash: 'h-alt', iban: IBAN_A, ibanMasked: 'DE00…9313', validUntil: '2026-10-02T00:00:00.000Z', warnedThresholds: '30,14', lastError: 'Bankzugang abgelaufen oder widerrufen', lastSyncOk: false, ...stored, }) } /** Freigabe starten und den gespeicherten state lesen. */ async function begin(now = NOW) { const { url } = await startBankAuth({ now }) return { url, state: connection().authState } } const rejects400 = (promise, message) => assert.rejects(promise, (err) => { assert.equal(err.status, 400) if (message instanceof RegExp) assert.match(err.message, message) else assert.equal(err.message, message) return true }) // ---------- startBankAuth ---------- test('startBankAuth: liefert die Bank-Adresse, speichert state und Startzeit, Freigabe 179 Tage', async () => { await reset() const { url } = await startBankAuth({ now: NOW }) assert.match(url, /^http:\/\/127\.0\.0\.1:\d+\/fake-bank\/auth\?state=/) const conn = connection() assert.match(conn.authState, /^[a-f0-9]{24}$/) assert.equal(conn.authStartedAt, '2026-10-01T09:00:00.000Z') assert.ok(url.endsWith(`state=${conn.authState}`)) const [call] = callsTo('/auth') assert.equal(callsTo('/auth').length, 1) assert.equal(call.body.state, conn.authState) assert.equal(call.body.access.valid_until, '2027-03-29T09:00:00.000Z') assert.equal(call.body.psu_type, 'business') assert.equal(call.body.redirect_url, config.enableBanking.redirectUrl) // der laufende Zugang bleibt unberuehrt assert.equal(conn.sessionId, OLD_SESSION) assert.equal(conn.status, 'abgelaufen') }) test('startBankAuth: jeder Start erzeugt einen neuen state, der alte gilt nicht mehr', async () => { await reset() const first = (await begin()).state const second = (await begin()).state assert.notEqual(first, second) await rejects400(completeBankAuth({ code: 'neu', state: first }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') assert.equal(connection().sessionId, OLD_SESSION) }) test('startBankAuth: legt die Verbindung an, wenn es noch keine gibt; scheitert die Bank, wird nichts gespeichert', async () => { await reset() await deleteDocument(config.collections.bankConnections, 'finom') fake.failNext({ path: '/auth', status: 500 }) await assert.rejects(startBankAuth({ now: NOW }), /Freigabe starten/) assert.equal(connection(), undefined) await startBankAuth({ now: NOW }) assert.match(connection().authState, /^[a-f0-9]{24}$/) }) // ---------- completeBankAuth: Erfolg ---------- test('completeBankAuth: richtiger state -> neue Sitzung, aktiv, Warnstufen und Fehler leer, Abruf gelaufen', async () => { await reset() fake.transactions.set('konto-neu-1', [ { entry_reference: 'n1', 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('konto-neu-1', [{ balance_type: 'ITBD', balance_amount: { amount: '108.93', currency: 'EUR' }, reference_date: '2026-10-01' }]) const { state } = await begin() const result = await completeBankAuth({ code: 'neu', state }, { now: later(5) }) assert.equal(result.ok, true) assert.equal(result.validUntil, '2027-03-29T00:00:00.000Z') assert.equal(result.sync.ok, true) assert.equal(result.sync.neu, 1) const conn = connection() assert.equal(conn.sessionId, NEW_SESSION) assert.equal(conn.accountUid, 'konto-neu-1') assert.equal(conn.accountIdHash, 'h-alt') assert.equal(conn.iban, IBAN_A) assert.equal(conn.ibanMasked, 'DE00…9313') assert.equal(conn.validUntil, '2027-03-29T00:00:00.000Z') assert.equal(conn.status, 'aktiv') assert.equal(conn.authState, '') assert.equal(conn.warnedThresholds, '') assert.equal(conn.lastError, '') // der Abruf nach dem Speichern hat das neue Konto gelesen assert.equal(conn.lastSyncOk, true) assert.equal(conn.balanceBookedCents, 10893) assert.equal(txDocs().size, 1) assert.equal(callsTo('/balances').length, 1) assert.match(callsTo('/balances')[0].path, /konto-neu-1/) }) test('completeBankAuth: die Sitzungs-ID steht nie in der Antwort', async () => { await reset() const { state } = await begin() const result = await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.ok(!JSON.stringify(result).includes(NEW_SESSION)) }) test('completeBankAuth: derselbe Rückkehr-Aufruf ein zweites Mal wird abgewiesen (state ist verbraucht)', async () => { await reset() const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) fake.calls.length = 0 await rejects400(completeBankAuth({ code: 'neu', state }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') assert.equal(callsTo('/sessions').length, 0) }) test('completeBankAuth: PSU-Kopfzeilen des Menschen gehen an den Abruf', async () => { await reset() const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW, psu: { ip: '203.0.113.7', userAgent: 'Mozilla/5.0 Test' } }) const call = callsTo('/balances')[0] assert.equal(call.headers['psu-ip-address'], '203.0.113.7') assert.equal(call.headers['psu-user-agent'], 'Mozilla/5.0 Test') }) test('completeBankAuth: ohne Ende der Freigabe von der Bank gilt die beantragte Frist (Start + 179 Tage)', async () => { await reset({ validUntil: null }) const { state } = await begin() const result = await completeBankAuth({ code: 'neu', state }, { now: later(3) }) assert.equal(result.validUntil, '2027-03-29T09:00:00.000Z') assert.equal(connection().validUntil, '2027-03-29T09:00:00.000Z') }) test('completeBankAuth: Ende der Freigabe in anderer ISO-Schreibweise wird als ISO-Zeitpunkt gespeichert', async () => { await reset({ validUntil: '2027-03-29T00:00:00.000000+00:00' }) const { state } = await begin() const result = await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(result.validUntil, '2027-03-29T00:00:00.000Z') }) test('completeBankAuth: genau 30 Minuten nach dem Start gilt noch', async () => { await reset() const { state } = await begin() const result = await completeBankAuth({ code: 'neu', state }, { now: later(30) }) assert.equal(result.ok, true) }) test('completeBankAuth: scheitert der Abruf danach, bleibt der neue Zugang gespeichert und der Fehler steht im Ergebnis', async () => { await reset() const { state } = await begin() fake.failNext({ path: /\/balances$/, status: 500, body: { code: 500, error: 'FAKE_ERROR' } }) const result = await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(result.ok, true) assert.equal(result.sync.ok, false) assert.ok(result.sync.error) assert.equal(connection().sessionId, NEW_SESSION) assert.equal(connection().status, 'aktiv') }) // ---------- completeBankAuth: Fehler ---------- test('completeBankAuth: falscher state -> 400, alte Sitzung bleibt, der Code wird nicht eingelöst', async () => { await reset() const { state } = await begin() fake.calls.length = 0 await rejects400(completeBankAuth({ code: 'neu', state: `${state}x` }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') await rejects400(completeBankAuth({ code: 'neu', state: 'ffffffffffffffffffffffff' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') assert.equal(connection().sessionId, OLD_SESSION) assert.equal(connection().status, 'abgelaufen') assert.equal(connection().warnedThresholds, '30,14') assert.equal(callsTo('/sessions').length, 0) // die gestartete Freigabe ist weiter gültig assert.equal(connection().authState, state) }) test('completeBankAuth: leerer oder fehlender state passt nie, auch wenn keine Freigabe gestartet wurde', async () => { await reset() // keine Freigabe gestartet: authState nicht gesetzt await rejects400(completeBankAuth({ code: 'neu', state: '' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') await rejects400(completeBankAuth({ code: 'neu' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') // gespeicherter state leer (nach Abschluss), Rückkehr ebenfalls leer await reset({ stored: { authState: '', authStartedAt: '' } }) await rejects400(completeBankAuth({ code: 'neu', state: '' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') // gar keine Verbindung await deleteDocument(config.collections.bankConnections, 'finom') await rejects400(completeBankAuth({ code: 'neu', state: 'abc' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') assert.equal(callsTo('/sessions').length, 0) }) test('completeBankAuth: state und code, die keine Zeichenketten sind, werden wie leer behandelt', async () => { await reset() const { state } = await begin() await rejects400(completeBankAuth({ code: 'neu', state: [state, state] }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') await rejects400(completeBankAuth({ code: ['neu'], state }, { now: NOW }), /code/i) assert.equal(callsTo('/sessions').length, 0) }) test('completeBankAuth: Start 31 Minuten her -> 400 abgelaufen, alte Sitzung bleibt', async () => { await reset() const { state } = await begin() fake.calls.length = 0 await rejects400(completeBankAuth({ code: 'neu', state }, { now: later(31) }), 'Die Freigabe ist abgelaufen. Bitte neu starten.') assert.equal(connection().sessionId, OLD_SESSION) assert.equal(callsTo('/sessions').length, 0) }) test('completeBankAuth: ohne lesbare Startzeit gilt die Freigabe als abgelaufen', async () => { await reset({ stored: { authState: 'a'.repeat(24), authStartedAt: '' } }) await rejects400(completeBankAuth({ code: 'neu', state: 'a'.repeat(24) }, { now: NOW }), 'Die Freigabe ist abgelaufen. Bitte neu starten.') await reset({ stored: { authState: 'a'.repeat(24), authStartedAt: 'gestern' } }) await rejects400(completeBankAuth({ code: 'neu', state: 'a'.repeat(24) }, { now: NOW }), 'Die Freigabe ist abgelaufen. Bitte neu starten.') }) test('completeBankAuth: error von der Bank -> 400 mit dem Fehlercode, alte Sitzung bleibt', async () => { await reset() const { state } = await begin() fake.calls.length = 0 await rejects400(completeBankAuth({ error: 'access_denied', state }, { now: NOW }), 'Finom hat die Freigabe nicht erteilt (access_denied).') assert.equal(connection().sessionId, OLD_SESSION) assert.equal(callsTo('/sessions').length, 0) }) test('completeBankAuth: error mit falschem state meldet zuerst die fremde Rückkehr; ein sehr langer error wird gekürzt', async () => { await reset() const { state } = await begin() await rejects400(completeBankAuth({ error: 'access_denied', state: 'nope' }, { now: NOW }), 'Diese Rückkehr passt zu keiner gestarteten Freigabe.') await assert.rejects(completeBankAuth({ error: 'x'.repeat(5000), state }, { now: NOW }), (err) => { assert.equal(err.status, 400) assert.ok(err.message.length < 200) return true }) }) test('completeBankAuth: Rückkehr ohne Code und ohne Fehler -> 400', async () => { await reset() const { state } = await begin() await rejects400(completeBankAuth({ state }, { now: NOW }), /code/i) assert.equal(connection().sessionId, OLD_SESSION) }) test('completeBankAuth: lehnt die Bank den Code ab, bleibt der alte Zugang, die Meldung nennt keine Sitzungs-ID', async () => { await reset() const { state } = await begin() await assert.rejects(completeBankAuth({ code: 'falsch', state }, { now: NOW }), (err) => { assert.match(err.message, /Sitzung anlegen/) assert.ok(!err.message.includes(OLD_SESSION) && !err.message.includes(NEW_SESSION)) return true }) assert.equal(connection().sessionId, OLD_SESSION) assert.equal(connection().authState, state) }) // ---------- completeBankAuth: Kontowahl ---------- const TWO = [account('konto-eins', { hash: 'h-eins', iban: IBAN_B }), account('konto-zwei', { hash: 'h-alt', iban: IBAN_A })] test('Kontowahl: gespeicherter identificationHash passt aufs zweite Konto -> zweites gewählt', async () => { await reset({ accounts: TWO }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(connection().accountUid, 'konto-zwei') assert.equal(connection().accountIdHash, 'h-alt') assert.equal(connection().iban, IBAN_A) }) test('Kontowahl: der Hash gewinnt gegen eine passende IBAN eines anderen Kontos', async () => { const accounts = [account('konto-eins', { hash: 'h-neu', iban: IBAN_A }), account('konto-zwei', { hash: 'h-alt', iban: IBAN_B })] await reset({ accounts }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(connection().accountUid, 'konto-zwei') }) test('Kontowahl: ohne Hash-Treffer entscheidet die gleiche IBAN (auch mit Leerzeichen und Kleinschreibung)', async () => { const accounts = [account('konto-eins', { hash: 'h-x', iban: IBAN_B }), account('konto-zwei', { hash: 'h-y', iban: IBAN_A.toLowerCase().replace(/(.{4})/g, '$1 ').trim() })] await reset({ accounts }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(connection().accountUid, 'konto-zwei') }) test('Kontowahl: ohne Treffer das erste Euro-Konto; ein anderes Konto setzt den Abrufbeginn zurück', async () => { const accounts = [account('konto-usd', { hash: 'h-1', iban: IBAN_B, currency: 'USD' }), account('konto-eur', { hash: 'h-2', iban: 'DE00000000000000005555' }), account('konto-eur-2', { hash: 'h-3', iban: 'DE00000000000000006666' })] await reset({ accounts, stored: { latestBookingDate: '2026-09-29' } }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(connection().accountUid, 'konto-eur') assert.equal(connection().accountIdHash, 'h-2') assert.equal(connection().ibanMasked, 'DE00…5555') // der Abruf beginnt beim neuen Konto nicht erst bei der letzten Buchung des alten assert.equal(callsTo('/transactions')[0].query.date_from, '2026-07-04') }) test('Kontowahl: dasselbe Konto behält den Abrufbeginn (nur ab letzter Buchung minus 7 Tage)', async () => { await reset({ stored: { latestBookingDate: '2026-09-29' } }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(callsTo('/transactions')[0].query.date_from, '2026-09-22') }) test('Kontowahl: kein Euro-Konto in der Freigabe -> 400, alter Zugang bleibt', async () => { await reset({ accounts: [account('konto-usd', { hash: 'h-1', iban: IBAN_B, currency: 'USD' })], stored: { accountIdHash: 'h-9', iban: 'DE99' } }) const { state } = await begin() await rejects400(completeBankAuth({ code: 'neu', state }, { now: NOW }), 'Kein Euro-Konto in der Freigabe.') assert.equal(connection().sessionId, OLD_SESSION) assert.equal(connection().status, 'abgelaufen') assert.equal(connection().authState, state) // keine Konten überhaupt await reset({ accounts: [] }) const again = await begin() await rejects400(completeBankAuth({ code: 'neu', state: again.state }, { now: NOW }), 'Kein Euro-Konto in der Freigabe.') }) test('Kontowahl: ohne gespeicherte Konto-Kennung (Übernahme) zählt das erste Euro-Konto, leere Werte treffen nicht', async () => { // gespeicherter Hash und IBAN leer, das erste Konto der Freigabe hat ebenfalls keinen Hash und keine IBAN const accounts = [{ uid: 'konto-eins', name: 'Webklar', currency: 'EUR' }, account('konto-zwei', { hash: 'h-2', iban: IBAN_A })] await reset({ accounts, stored: { accountIdHash: '', iban: '' } }) const { state } = await begin() await completeBankAuth({ code: 'neu', state }, { now: NOW }) assert.equal(connection().accountUid, 'konto-eins') assert.equal(connection().accountIdHash, '') assert.equal(connection().iban, '') assert.equal(connection().ibanMasked, '') }) // ---------- Rennen mit einem laufenden Abruf ---------- test('completeBankAuth: wartet auf einen laufenden Abruf; dessen spätes Ergebnis überschreibt den neuen Zugang nicht', async () => { // alter Zugang aktiv; der laufende Abruf bekommt (verspätet) ein 401 und würde den Zugang auf "abgelaufen" setzen await reset({ stored: { status: 'aktiv', warnedThresholds: '', lastError: '' } }) fake.balances.set('konto-neu-1', [{ balance_type: 'ITBD', balance_amount: { amount: '5.00', currency: 'EUR' }, reference_date: '2026-10-01' }]) const { state } = await begin() const hold = fake.holdNext({ path: /\/balances$/ }) fake.failNext({ path: /\/balances$/, status: 401, body: { code: 401, error: 'UNAUTHORIZED' } }) const running = syncBank({ now: NOW }) await hold.reached let finished = false const completing = completeBankAuth({ code: 'neu', state }, { now: NOW }).then((result) => { finished = true return result }) await sleep(150) // der laufende Abruf hält die Sperre: der neue Zugang ist noch nicht geschrieben assert.equal(finished, false) assert.equal(connection().sessionId, OLD_SESSION) assert.equal(connection().authState, state) hold.release() const [oldSync, result] = await Promise.all([running, completing]) // der alte Abruf ist wie geplant am 401 gescheitert ... assert.equal(oldSync.ok, false) // ... aber erst danach wurde der neue Zugang geschrieben und frisch abgerufen assert.equal(result.ok, true) assert.equal(result.sync.ok, true) const conn = connection() assert.equal(conn.sessionId, NEW_SESSION) assert.equal(conn.accountUid, 'konto-neu-1') assert.equal(conn.status, 'aktiv') assert.equal(conn.lastError, '') assert.equal(conn.lastSyncOk, true) assert.equal(conn.balanceBookedCents, 500) assert.equal(conn.authState, '') // die Sitzung wurde erst nach dem Ende des alten Abrufs angelegt const order = fake.calls.map((c) => c.path) assert.ok(order.findIndex((p) => p === '/sessions') > order.findIndex((p) => p.endsWith('/balances'))) }) test('completeBankAuth: ein Abruf direkt danach läuft nicht in die Sperre der Rückkehr (kein Deadlock)', async () => { await reset() const { state } = await begin() const [result, other] = await Promise.all([completeBankAuth({ code: 'neu', state }, { now: NOW }), syncBank({ now: NOW })]) assert.equal(result.ok, true) assert.equal(typeof other.ok, 'boolean') })