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))