Report failed bank connections and stop resurrecting deleted accounts

Three defects made new connections silently vanish while removed
accounts returned:

- A single shared account the journal cannot represent (securities or
  card entries without IBAN, stable identification or currency) aborted
  the entire consent. Usable accounts are now linked and the rest
  counted and reported.
- A consent that linked nothing was stored, redirected as success and
  later reaped by session recovery. It now fails with the reason.
- Callback failures rendered a bare JSON error page and were never
  logged. They now log and redirect into the app with the reason shown.
- Deleting an account left its session binding, so the next connect or
  sync recovered the binding and re-added the account. Account deletion
  now releases bindings, consents and cursors before committing.
This commit is contained in:
Lars Nolden
2026-09-11 11:46:59 +02:00
parent 3df9bda989
commit e77969b8c5
9 changed files with 242 additions and 34 deletions
+99 -2
View File
@@ -2,6 +2,8 @@ package app
import (
"context"
"slices"
"strings"
"testing"
"time"
@@ -95,7 +97,7 @@ func TestAuthorizedHistorySurvivesReopenAndRespectsIncrementalCursor(t *testing.
if _, err := a.Authorize(ctx, "N26", "DE", 24); err != nil {
t.Fatal(err)
}
if err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
@@ -170,7 +172,7 @@ func TestRenewedConsentWakesSchedulerAndAutomaticallyImports(t *testing.T) {
done := make(chan struct{})
go func() { defer close(done); a.RunScheduler(ctx) }()
defer func() { cancel(); <-done }()
if err := a.Callback(context.Background(), "one_time_code", "state"); err != nil {
if _, err := a.Callback(context.Background(), "one_time_code", "state"); err != nil {
t.Fatal(err)
}
select {
@@ -216,3 +218,98 @@ func TestInterruptedRenewalDiscardsSupersededConsentDuringRecovery(t *testing.T)
t.Fatal("recovered replacement did not resume imports")
}
}
// A removed bank account must stay removed. Session recovery reconstructs
// bindings for interrupted connects, so a stale binding silently resurrects
// the account on the next connect or sync.
func TestDeletedBankAccountIsNotResurrectedByLaterConnectOrSync(t *testing.T) {
a, s := testApp(t)
ctx := context.Background()
kept := s.Data.Accounts[0]
kept.ExternalAccountID = "kept_uid"
removed := domain.Account{ID: "removed_acct", DisplayName: "Mwst", Institution: "N26", Currency: "EUR", ExternalAccountID: "removed_uid", Active: true}
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
d.Accounts = []domain.Account{kept, removed}
return nil
})
if err != nil {
t.Fatal(err)
}
session := banking.Session{ID: "n26_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{kept, removed}}
b := &historyBank{bankScenario: bankScenario{session: session}}
a.bank = b
a.ops.Sessions = []banking.Session{session}
a.ops.Consents[session.ID] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 12}
a.ops.AccountSync[removed.ID] = time.Now().UTC().Format(time.RFC3339)
if err := a.saveOps(); err != nil {
t.Fatal(err)
}
s, err = a.ManageRegistry(ctx, s.Revision, "account", "delete", removed.ID, "")
if err != nil {
t.Fatal(err)
}
if slices.ContainsFunc(s.Data.Accounts, func(v domain.Account) bool { return v.ID == removed.ID }) {
t.Fatal("account deletion did not remove the account")
}
if _, tracked := a.ops.AccountSync[removed.ID]; tracked {
t.Fatal("deleted account kept its sync cursor")
}
// A later connect for a different bank triggers binding recovery.
other := domain.Account{ID: "ing_acct", DisplayName: "ING Giro", Institution: "ING", Currency: "EUR", ExternalAccountID: "ing_uid", Active: true}
b.session = banking.Session{ID: "ing_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{other}}
if _, err := a.Authorize(ctx, "ING", "DE", 12); err != nil {
t.Fatal(err)
}
if _, err := a.Callback(ctx, "one_time_code", b.authState); err != nil {
t.Fatal(err)
}
a = reopenBankingApp(t, a)
a.bank = b
after, err := a.Sync(ctx)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, account := range after.Data.Accounts {
names[account.ID] = true
}
if names[removed.ID] {
t.Fatalf("deleted bank account reappeared: %+v", after.Data.Accounts)
}
if !names[kept.ID] || !names[other.ID] {
t.Fatalf("connecting another bank lost existing accounts: %+v", after.Data.Accounts)
}
for _, connection := range after.Connections {
if connection.AccountID == removed.ID {
t.Fatal("deleted account still reported as connected")
}
}
}
// A consent that shares no accounts cannot sync and its session is reaped by
// recovery, so the user must be told instead of being silently redirected.
func TestConnectWithoutSharedAccountsFailsVisibly(t *testing.T) {
a, _ := testApp(t)
ctx := context.Background()
b := &historyBank{bankScenario: bankScenario{session: banking.Session{ID: "empty_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339)}}}
a.bank = b
if _, err := a.Authorize(ctx, "Kontist", "DE", 12); err != nil {
t.Fatal(err)
}
_, err := a.Callback(ctx, "one_time_code", b.authState)
if err == nil || !strings.Contains(err.Error(), "no accounts") {
t.Fatalf("empty consent reported success: %v", err)
}
after, e := a.Snapshot(ctx)
if e != nil {
t.Fatal(e)
}
if len(after.Sessions) != 0 {
t.Fatalf("unusable consent was stored: %+v", after.Sessions)
}
for _, connection := range after.Connections {
if connection.Status != "local" {
t.Fatalf("unusable consent produced a bank connection: %+v", connection)
}
}
}