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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 15:41:54 +00:00
1 parent 75ca8b78c0
commit cba14ed0f0
3 files changed
+605 -2

No files matched your search

+476
View File
@@ -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')
})
+21
View File
@@ -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))