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>
477 lines
22 KiB
JavaScript
477 lines
22 KiB
JavaScript
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')
|
|
})
|