GET /api/banking/institutions lists the banks Enable Banking can connect for a country (personal AIS, connectable consents only), with logos restricted to https Enable Banking hosts to match the CSP image allowlist. The connect form offers a filterable dropdown with bank logos, falling back to the previous free-text input when the list is unavailable or banking is not configured.
324 lines
13 KiB
Go
324 lines
13 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
"finance-duck/internal/ratelimit"
|
|
)
|
|
|
|
type bankScenario struct {
|
|
session banking.Session
|
|
fail bool
|
|
}
|
|
|
|
func (b *bankScenario) Authorize(context.Context, string, string, string) (string, error) {
|
|
return "https://bank.example/authorize", nil
|
|
}
|
|
func (b *bankScenario) Institutions(context.Context, string) ([]banking.Institution, error) {
|
|
return []banking.Institution{{Name: "N26", Country: "DE"}}, nil
|
|
}
|
|
func (b *bankScenario) Exchange(context.Context, string) (banking.Session, error) {
|
|
return b.session, nil
|
|
}
|
|
func (b *bankScenario) Status(context.Context, string) (banking.SessionStatus, error) {
|
|
if b.fail {
|
|
return banking.SessionStatus{}, errors.New("expired")
|
|
}
|
|
status := banking.SessionStatus{ValidUntil: b.session.ValidUntil}
|
|
for _, account := range b.session.Accounts {
|
|
status.AccountIDs = append(status.AccountIDs, account.ExternalAccountID)
|
|
}
|
|
return status, nil
|
|
}
|
|
func (b *bankScenario) Balances(context.Context, string) ([]banking.Balance, error) {
|
|
return []banking.Balance{{Amount: "100.00", Currency: "EUR", Type: "CLBD"}}, nil
|
|
}
|
|
func (b *bankScenario) Transactions(_ context.Context, a domain.Account, from, to string, _ bool) ([]domain.Facts, error) {
|
|
if b.fail {
|
|
return nil, errors.New("offline")
|
|
}
|
|
return []domain.Facts{{Source: "enablebanking", AccountID: a.ID, BookingDate: time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02"), Amount: "-42.80", Currency: "EUR", RawDescription: "REWE", ExternalID: "entry_stable"}}, nil
|
|
}
|
|
func TestSyncRestoresSavedConsentBindingsAndDoesNotDuplicateFacts(t *testing.T) {
|
|
a, s := testApp(t)
|
|
account := s.Data.Accounts[0]
|
|
account.ExternalAccountID = "provider_new"
|
|
account.IBAN = "DE89370400440532013000"
|
|
provider := &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{account}}}
|
|
a.bank = provider
|
|
a.ops.Sessions = []banking.Session{provider.session}
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(first.Data.Accounts) != 1 || first.Data.Accounts[0].ExternalAccountID != "provider_new" || len(first.Data.Transactions) != 1 {
|
|
t.Fatalf("saved session did not recover/import: %+v", first.Data)
|
|
}
|
|
again, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(first.Data, again.Data) {
|
|
t.Fatal("repeated bank synchronization changed canonical financial data")
|
|
}
|
|
provider.fail = true
|
|
failed, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if failed.Status.SyncError == "" || !reflect.DeepEqual(again.Data, failed.Data) {
|
|
t.Fatal("provider failure was not isolated from canonical data")
|
|
}
|
|
}
|
|
func TestReconnectReplacesOldConsentWithoutDuplicatingLocalAccount(t *testing.T) {
|
|
a, s := testApp(t)
|
|
account := s.Data.Accounts[0]
|
|
account.ExternalAccountID = "old_uid"
|
|
account.IBAN = "DE89370400440532013000"
|
|
s, err := a.Mutate(context.Background(), s.Revision, func(d *domain.Dataset) error { return SaveAccount(d, account) })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.ops.Sessions = []banking.Session{{ID: "old_session", Accounts: []domain.Account{account}}}
|
|
a.ops.Consents["old_session"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
|
|
cursor := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
|
|
a.ops.AccountSync[account.ID] = cursor
|
|
renewed := account
|
|
renewed.ID = "provider_local_id"
|
|
renewed.ExternalAccountID = "new_uid"
|
|
renewed.DisplayName = "Bank-generated name"
|
|
a.bank = &bankScenario{session: banking.Session{ID: "new_session", ValidUntil: time.Now().Add(24 * time.Hour).Format(time.RFC3339), Accounts: []domain.Account{renewed}}}
|
|
a.authStates["one_time_state"] = authorization{Expires: time.Now().Add(time.Minute), Institution: "N26", Country: "DE", HistoryMonths: 24}
|
|
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, err := a.Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(after.Data.Accounts) != 1 || after.Data.Accounts[0].ID != account.ID || after.Data.Accounts[0].DisplayName != account.DisplayName || after.Data.Accounts[0].ExternalAccountID != "new_uid" {
|
|
t.Fatal("reconnect duplicated account or lost local display name")
|
|
}
|
|
if len(after.Sessions) != 1 || after.Sessions[0].ID != "new_session" {
|
|
t.Fatal("expired session remains active after reconnect")
|
|
}
|
|
if a.ops.AccountSync[account.ID] != cursor || after.Connections[0].HistoryMonths != 24 {
|
|
t.Fatal("reconnect reset the account cursor or lost the history choice")
|
|
}
|
|
if err = a.Callback(context.Background(), "bank_code", "one_time_state"); err == nil {
|
|
t.Fatal("authorization state replay was accepted")
|
|
}
|
|
}
|
|
|
|
// Exercise the controller's typed error without contacting a provider or waiting.
|
|
func bankRateError(t *testing.T) error {
|
|
t.Helper()
|
|
var controller ratelimit.Controller
|
|
ctx := context.Background()
|
|
if err := controller.Acquire(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer controller.Release()
|
|
_, err := controller.Do(ctx, func(context.Context) (*http.Response, error) {
|
|
return &http.Response{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Header: http.Header{"Retry-After": []string{"300"}},
|
|
Body: io.NopCloser(strings.NewReader("private provider response")),
|
|
}, nil
|
|
}, false)
|
|
if err == nil {
|
|
t.Fatal("rate limit response was accepted")
|
|
}
|
|
return fmt.Errorf("private provider response: %w", err)
|
|
}
|
|
|
|
type sessionBank struct {
|
|
bankScenario
|
|
statuses map[string]banking.SessionStatus
|
|
failures map[string]error
|
|
fetched []string
|
|
}
|
|
|
|
func (b *sessionBank) Status(_ context.Context, id string) (banking.SessionStatus, error) {
|
|
return b.statuses[id], b.failures[id]
|
|
}
|
|
|
|
func (b *sessionBank) Transactions(ctx context.Context, account domain.Account, from, to string, longest bool) ([]domain.Facts, error) {
|
|
b.fetched = append(b.fetched, account.ID)
|
|
return b.bankScenario.Transactions(ctx, account, from, to, longest)
|
|
}
|
|
|
|
func TestSyncSessionRateLimitPreservesBindingsAndRecovers(t *testing.T) {
|
|
a, s := testApp(t)
|
|
ctx := context.Background()
|
|
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
|
d.Accounts[0].ExternalAccountID = "main_uid"
|
|
for _, id := range []string{"mwst", "tax", "independent"} {
|
|
d.Accounts = append(d.Accounts, domain.Account{ID: id, DisplayName: id, Currency: "EUR", Active: true, ExternalAccountID: id + "_uid"})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expiry := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
|
|
limited := banking.Session{ID: "limited", ValidUntil: expiry}
|
|
healthy := banking.Session{ID: "healthy", ValidUntil: expiry}
|
|
for _, account := range s.Data.Accounts {
|
|
if account.ID == "independent" {
|
|
healthy.Accounts = append(healthy.Accounts, account)
|
|
} else {
|
|
limited.Accounts = append(limited.Accounts, account)
|
|
}
|
|
}
|
|
a.ops.Sessions = []banking.Session{limited, healthy}
|
|
a.ops.Consents["limited"] = Consent{Institution: "N26", Country: "DE", HistoryMonths: 24}
|
|
a.ops.Consents["healthy"] = Consent{Institution: "Other", Country: "DE", HistoryMonths: 12}
|
|
b := &sessionBank{
|
|
statuses: map[string]banking.SessionStatus{
|
|
"limited": {ValidUntil: expiry, AccountIDs: []string{"main_uid", "mwst_uid", "tax_uid"}},
|
|
// Another consent must not authorize an account whose own status failed.
|
|
"healthy": {ValidUntil: expiry, AccountIDs: []string{"independent_uid", "main_uid"}},
|
|
},
|
|
failures: map[string]error{},
|
|
}
|
|
a.bank = b
|
|
before, err := a.Sync(ctx)
|
|
if err != nil || len(before.Data.Transactions) != 4 {
|
|
t.Fatalf("initial sync: transactions=%d, error=%v, sync error=%s", len(before.Data.Transactions), err, before.Status.SyncError)
|
|
}
|
|
old := time.Now().Add(-48 * time.Hour).UTC().Format(time.RFC3339)
|
|
a.ops.LastSync = old
|
|
for _, account := range before.Data.Accounts {
|
|
a.ops.AccountSync[account.ID] = old
|
|
}
|
|
b.fetched = nil
|
|
b.failures["limited"] = bankRateError(t)
|
|
failed, err := a.Sync(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Count(failed.Status.SyncError, "429") != 1 || strings.Contains(failed.Status.SyncError, "bank connection unavailable") || strings.Contains(failed.Status.SyncError, "private provider response") || !strings.Contains(failed.Status.SyncError, "retry") {
|
|
t.Fatal("session rate limit was duplicated, obscured, or exposed private data")
|
|
}
|
|
if !reflect.DeepEqual(b.fetched, []string{"independent"}) {
|
|
t.Fatal("failed consent authorized retrieval or independent consent stopped syncing")
|
|
}
|
|
if !reflect.DeepEqual(before.Data, failed.Data) || !reflect.DeepEqual(before.Sessions, failed.Sessions) || a.ops.LastSync != old {
|
|
t.Fatal("rate limit changed existing facts, bindings, metadata, or last successful sync")
|
|
}
|
|
for _, account := range before.Data.Accounts {
|
|
if account.ID != "independent" && a.ops.AccountSync[account.ID] != old {
|
|
t.Fatal("failed account advanced its cursor")
|
|
}
|
|
}
|
|
if a.ops.AccountSync["independent"] == old || a.ops.Consents["limited"].NeedsReconnect || a.ops.Consents["limited"].HistoryMonths != 24 {
|
|
t.Fatal("rate limit lost consent settings, required reconnect, or stopped the healthy cursor")
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
a.bank = b
|
|
if a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync != old || a.ops.AccountSync["mwst"] != old {
|
|
t.Fatal("rate failure state did not survive restart safely")
|
|
}
|
|
delete(b.failures, "limited")
|
|
b.fetched = nil
|
|
recovered, err := a.Sync(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recovered.Status.SyncError != "" || a.ops.Consents["limited"].Error != "" || a.ops.Consents["limited"].NeedsReconnect || a.ops.LastSync == old {
|
|
t.Fatal("successful retry did not clear the transient failure")
|
|
}
|
|
if !reflect.DeepEqual(before.Data, recovered.Data) || len(b.fetched) != 4 {
|
|
t.Fatal("recovery duplicated facts or skipped an account")
|
|
}
|
|
for _, account := range recovered.Data.Accounts {
|
|
if a.ops.AccountSync[account.ID] == old {
|
|
t.Fatal("recovered account cursor did not advance")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSyncMissingMembershipStillRejectsAccount(t *testing.T) {
|
|
a, s, b := backfillApp(t)
|
|
b.session.Accounts = []domain.Account{s.Data.Accounts[1]}
|
|
before := domain.Clone(s.Data)
|
|
last := a.ops.LastSync
|
|
after, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(after.Status.SyncError, s.Data.Accounts[0].DisplayName+": bank connection unavailable") || !a.ops.Consents["current"].NeedsReconnect {
|
|
t.Fatal("missing account membership was treated as authorized")
|
|
}
|
|
if len(b.accounts) != 1 || b.accounts[0].ID != "other" || a.ops.AccountSync[s.Data.Accounts[0].ID] != last || a.ops.LastSync != last {
|
|
t.Fatal("missing member was fetched or advanced its cursor, or valid member was skipped")
|
|
}
|
|
if !reflect.DeepEqual(before.Accounts, after.Data.Accounts) || len(after.Data.Transactions) != 1 || after.Data.Transactions[0].Facts.AccountID != "other" {
|
|
t.Fatal("missing membership changed bindings or imported unauthorized facts")
|
|
}
|
|
}
|
|
|
|
func TestSyncTransactionFailuresPreserveProgressAndSafeErrors(t *testing.T) {
|
|
for _, scenario := range []string{"rate limit", "background rate limit", "reconnect", "private response"} {
|
|
t.Run(scenario, func(t *testing.T) {
|
|
a, s, b := backfillApp(t)
|
|
s = seed(t, a, s)
|
|
old := a.ops.LastSync
|
|
cursors := map[string]string{}
|
|
for id, cursor := range a.ops.AccountSync {
|
|
cursors[id] = cursor
|
|
}
|
|
switch scenario {
|
|
case "rate limit":
|
|
b.fetchErr = bankRateError(t)
|
|
case "background rate limit":
|
|
quota := &banking.BackgroundQuotaError{RateLimitError: ratelimit.NewError(time.Now().Add(6 * time.Hour))}
|
|
b.fetchErr = fmt.Errorf("private provider response: %w", quota)
|
|
case "reconnect":
|
|
b.fetchErr = fmt.Errorf("private provider response: %w", banking.ErrReconnect)
|
|
case "private response":
|
|
b.fetchErr = errors.New("private provider response")
|
|
}
|
|
failed, err := a.Sync(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := a.ops.Consents["current"]
|
|
if failed.Status.SyncError == "" || meta.Error == "" || strings.Contains(failed.Status.SyncError+meta.Error, "private provider response") {
|
|
t.Fatal("transaction failure was lost or exposed provider data")
|
|
}
|
|
if strings.Contains(scenario, "rate limit") && (!strings.Contains(failed.Status.SyncError, "429") || !strings.Contains(meta.Error, "429") || !strings.Contains(meta.Error, "retry")) {
|
|
t.Fatal("transaction rate error was obscured")
|
|
}
|
|
if scenario == "background rate limit" && !strings.Contains(meta.Error, "background") {
|
|
t.Fatal("daily bank quota was confused with a short request throttle")
|
|
}
|
|
if meta.NeedsReconnect != (scenario == "reconnect") {
|
|
t.Fatal("transaction failure classified consent incorrectly")
|
|
}
|
|
if !reflect.DeepEqual(s.Data, failed.Data) || !reflect.DeepEqual(cursors, a.ops.AccountSync) || a.ops.LastSync != old {
|
|
t.Fatal("failed retrieval imported partial data or advanced synchronization")
|
|
}
|
|
b.fetchErr = nil
|
|
recovered, err := a.Sync(context.Background())
|
|
if err != nil || recovered.Status.SyncError != "" || a.ops.Consents["current"].Error != "" || a.ops.Consents["current"].NeedsReconnect {
|
|
t.Fatal("successful retrieval did not clear the failure")
|
|
}
|
|
})
|
|
}
|
|
}
|