From cba14ed0f0938137fe564015aa0035bbfbfbe23c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:34:44 +0000 Subject: [PATCH] Finanzen: Bankzugang aus WOMS neu verbinden (Freigabe, Rueckkehr, Kontowahl) startBankAuth/completeBankAuth in bankSync.js. Die Rueckkehr prueft und speichert die Sitzung unter der Sperre der Abrufe (ein laufender Abruf kann den neuen Zugang nicht mehr ueberschreiben) und ruft danach einmal ab. Nachbau: holdNext haelt einen Aufruf an. Co-Authored-By: Claude Sonnet 5.5 --- server/services/finance/bankSync.js | 110 ++++++- test/financeBankAuth.test.js | 476 ++++++++++++++++++++++++++++ test/support/fakeEnableBanking.js | 21 ++ 3 files changed, 605 insertions(+), 2 deletions(-) create mode 100644 test/financeBankAuth.test.js diff --git a/server/services/finance/bankSync.js b/server/services/finance/bankSync.js index 52107b6..188a624 100644 --- a/server/services/finance/bankSync.js +++ b/server/services/finance/bankSync.js @@ -1,6 +1,6 @@ -import { createHash } from 'node:crypto' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { withLocks } from '../keyedLock.js' -import { EnableBankingError, getBalances, getTransactions, toCents } from './enableBanking.js' +import { createSession, EnableBankingError, getBalances, getTransactions, startAuth, toCents } from './enableBanking.js' import { addDays, berlinDate } from './dates.js' import { deleteTransaction, getConnection, listPendingTransactions, saveConnection, upsertTransaction } from './bankStore.js' @@ -9,6 +9,9 @@ import { deleteTransaction, getConnection, listPendingTransactions, saveConnecti * idempotent in Appwrite (bankStore). Dieselbe Buchung kommt bei jedem Abruf wieder und landet im selben * Dokument; Zuordnung und Notiz des Nutzers bleiben dabei unberuehrt. Vorgemerkte Buchungen (PDNG), die die * Bank nicht mehr nennt, werden geloescht. syncBank wirft nie: jeder Fehler landet in der Verbindung (lastError). + * + * Ausserdem der Weg zum neuen Bankzugang (startBankAuth, completeBankAuth): Freigabe bei Finom starten, nach der + * Rueckkehr die Sitzung speichern und sofort einmal abrufen. */ // Die Bank liefert hoechstens ~90 Tage: ein Abruf beginnt spaetestens heute - 89 Tage, sonst eine Woche vor der @@ -165,3 +168,106 @@ async function runSync({ now, psu }) { export function syncBank({ now = new Date(), psu = null } = {}) { return withLocks(SYNC_LOCK, () => runSync({ now, psu })) } + +// ---------- Bankzugang neu verbinden ---------- + +// Freigabe beim Start fuer 179 Tage (die Bank erlaubt hoechstens 180); die Rueckkehr muss binnen 30 Minuten kommen +const ACCESS_DAYS = 179 +const DAY_MS = 86_400_000 +const AUTH_MAX_AGE_MS = 30 * 60_000 +// So viel vom error der Rueckkehradresse kommt in die Meldung (der Wert stammt aus der Adresse) +const AUTH_ERROR_SHOWN_MAX = 80 + +const badRequest = (message) => Object.assign(new Error(message), { status: 400 }) +const requestedValidUntil = (startedAtMs) => new Date(startedAtMs + ACCESS_DAYS * DAY_MS).toISOString() +const compactIban = (iban) => String(iban || '').replace(/\s+/g, '').toUpperCase() +const queryText = (value) => (typeof value === 'string' ? value.trim() : '') + +function sameText(a, b) { + const left = Buffer.from(a) + const right = Buffer.from(b) + return left.length === right.length && timingSafeEqual(left, right) +} + +/** Erste vier und letzte vier Zeichen der IBAN, dazwischen "…" (zu kurze Werte werden nicht gezeigt). */ +function maskIban(iban) { + const compact = compactIban(iban) + return compact.length > 8 ? `${compact.slice(0, 4)}…${compact.slice(-4)}` : '' +} + +/** + * Das Konto der neuen Freigabe: dasselbe identificationHash wie gespeichert, sonst dieselbe IBAN, sonst das erste + * Euro-Konto. known = es ist das bisherige Konto. null, wenn die Freigabe kein passendes Konto enthaelt. + */ +function pickAccount(accounts, connection) { + const usable = accounts.filter((account) => typeof account?.uid === 'string' && account.uid) + const hash = text(connection?.accountIdHash, 128) + const iban = compactIban(connection?.iban) + const known = (hash && usable.find((account) => account.identificationHash === hash)) || (iban && usable.find((account) => compactIban(account.iban) === iban)) + if (known) return { account: known, known: true } + const euro = usable.find((account) => account.currency === 'EUR') + return euro ? { account: euro, known: false } : null +} + +/** + * Freigabe bei Finom starten. Liefert die Adresse, zu der der Nutzer geschickt wird; state und Startzeit stehen + * danach in der Verbindung (authState, authStartedAt) und gelten fuer die Rueckkehr (completeBankAuth). + */ +export async function startBankAuth({ now = new Date() } = {}) { + const state = randomBytes(12).toString('hex') + const { url } = await startAuth({ state, validUntil: requestedValidUntil(now.getTime()) }) + await saveConnection({ authState: state, authStartedAt: now.toISOString() }) + return { url } +} + +/** Prueft die Rueckkehr und speichert die neue Sitzung. Laeuft unter der Sperre der Abrufe. */ +async function saveReturnedSession({ code, state, error, now }) { + const connection = await getConnection() + const expected = text(connection?.authState, 64) + if (!state || !expected || !sameText(state, expected)) throw badRequest('Diese Rückkehr passt zu keiner gestarteten Freigabe.') + const startedAt = Date.parse(connection.authStartedAt) + // NaN (keine lesbare Startzeit) besteht den Vergleich nicht: dann gilt die Freigabe als abgelaufen + if (!(now.getTime() - startedAt <= AUTH_MAX_AGE_MS)) throw badRequest('Die Freigabe ist abgelaufen. Bitte neu starten.') + if (error) throw badRequest(`Finom hat die Freigabe nicht erteilt (${error.slice(0, AUTH_ERROR_SHOWN_MAX)}).`) + if (!code) throw badRequest('Die Rückkehr enthält keinen Freigabecode.') + + const session = await createSession(code) + const picked = pickAccount(session.accounts, connection) + if (!picked) throw badRequest('Kein Euro-Konto in der Freigabe.') + const { account, known } = picked + + const grantedUntil = Date.parse(session.validUntil ?? '') + const validUntil = Number.isNaN(grantedUntil) ? requestedValidUntil(startedAt) : new Date(grantedUntil).toISOString() + const iban = text(account.iban, 40) + await saveConnection({ + sessionId: text(session.sessionId, 64), + accountUid: text(account.uid, 64), + accountIdHash: text(account.identificationHash, 128), + iban, + ibanMasked: maskIban(iban), + validUntil, + status: 'aktiv', + authState: '', + authStartedAt: '', + warnedThresholds: '', + lastError: '', + // Ein anderes Konto hat andere Buchungen: der naechste Abruf liest wieder den ganzen Zeitraum + ...(known ? {} : { latestBookingDate: '' }), + }) + return { validUntil } +} + +/** + * Rueckkehr von Finom (code, state, error aus der Adresse): bei passender, nicht abgelaufener Freigabe wird die + * Sitzung gespeichert (Status "aktiv", Warnstufen und Fehler geleert) und einmal abgerufen. Die Pruefung und das + * Speichern laufen unter derselben Sperre wie der Abruf: ein gerade laufender Abruf endet zuerst und kann den neuen + * Zugang nicht mehr ueberschreiben. Der Abruf danach laeuft nach Freigabe der Sperre (psu wie bei syncBank). + * Fehler der Rueckkehr sind Error mit status 400 und deutscher Meldung. Liefert { ok: true, validUntil, sync } + * (sync = Ergebnis von syncBank). + */ +export async function completeBankAuth({ code, state, error } = {}, { now = new Date(), psu = null } = {}) { + const failure = typeof error === 'string' ? error.trim() : error ? 'unbekannt' : '' + const { validUntil } = await withLocks(SYNC_LOCK, () => saveReturnedSession({ code: queryText(code), state: queryText(state), error: failure, now })) + const sync = await syncBank({ now, psu }) + return { ok: true, validUntil, sync } +} diff --git a/test/financeBankAuth.test.js b/test/financeBankAuth.test.js new file mode 100644 index 0000000..0a50b7a --- /dev/null +++ b/test/financeBankAuth.test.js @@ -0,0 +1,476 @@ +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') +}) diff --git a/test/support/fakeEnableBanking.js b/test/support/fakeEnableBanking.js index be723fd..1cc2125 100644 --- a/test/support/fakeEnableBanking.js +++ b/test/support/fakeEnableBanking.js @@ -45,6 +45,8 @@ export async function startFakeEnableBanking({ publicKeyPem, appId = null }) { let authSeq = 0 // Eingeplanter Fehler (failNext): der naechste passende Aufruf scheitert einmalig let pendingFailure = null + // Angehaltener Aufruf (holdNext): der naechste passende Aufruf wartet, bis release() gerufen wird + let pendingHold = null const matchesPath = (path, pathname) => (path instanceof RegExp ? path.test(pathname) : path === pathname) @@ -126,6 +128,12 @@ export async function startFakeEnableBanking({ publicKeyPem, appId = null }) { calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams.entries()), headers: { ...req.headers }, body }) const authError = jwtError(req.headers.authorization) + if (!authError && pendingHold && (!pendingHold.path || matchesPath(pendingHold.path, url.pathname))) { + const hold = pendingHold + pendingHold = null + hold.arrive() + await hold.gate + } let result if (authError) { result = [401, { code: 401, error: 'UNAUTHORIZED', message: `JWT abgelehnt: ${authError}` }] @@ -168,6 +176,19 @@ export async function startFakeEnableBanking({ publicKeyPem, appId = null }) { failNext: ({ path = null, status = 500, body = { error: 'FAKE_ERROR' }, destroy = false }) => { pendingFailure = { path, status, body, destroy } }, + /** + * Der naechste Aufruf mit gueltigem JWT (path wie bei failNext) bleibt stehen, bis release() gerufen wird + * (ein langsamer Bankabruf); erst dann laeuft er weiter (auch ein mit failNext eingeplanter Fehler greift erst + * dann). reached erfuellt sich, sobald der Aufruf angekommen ist. Liefert { reached, release }. + */ + holdNext: ({ path = null } = {}) => { + let release + let arrive + const gate = new Promise((resolve) => (release = resolve)) + const reached = new Promise((resolve) => (arrive = resolve)) + pendingHold = { path, gate, arrive } + return { reached, release } + }, close: () => { server.closeAllConnections?.() return new Promise((resolve) => server.close(resolve))