Files
finance-duck/internal/banking/enablebanking_test.go
T
Lars Nolden e77969b8c5 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.
2026-09-11 11:46:59 +02:00

802 lines
34 KiB
Go

package banking
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
"finance-duck/internal/ratelimit"
)
func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.PrivateKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
p, err := NewEnableBanking("test-app", keyPEM, "http://localhost:8080/api/banking/callback")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
p.BaseURL = server.URL
p.HTTPClient = server.Client()
p.requests = ratelimit.Controller{}
return p, key
}
func assertJWT(t *testing.T, r *http.Request, key *rsa.PrivateKey) {
t.Helper()
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
t.Error("missing Bearer authentication")
return
}
parts := strings.Split(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), ".")
if len(parts) != 3 {
t.Error("invalid JWT structure")
return
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Error(err)
return
}
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], signature); err != nil {
t.Errorf("invalid JWT signature: %v", err)
}
var header map[string]string
b, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(b, &header); err != nil {
t.Error(err)
return
}
if header["alg"] != "RS256" || header["kid"] != "test-app" || header["typ"] != "JWT" {
t.Errorf("wrong JWT header: %v", header)
}
var claims struct {
Issuer string `json:"iss"`
Audience string `json:"aud"`
Issued int64 `json:"iat"`
Expires int64 `json:"exp"`
}
b, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Error(err)
return
}
if err := json.Unmarshal(b, &claims); err != nil {
t.Error(err)
return
}
now := time.Now().Unix()
if claims.Issuer != "enablebanking.com" || claims.Audience != "api.enablebanking.com" || claims.Issued > now+1 || claims.Expires <= now || claims.Expires-claims.Issued > 86400 {
t.Errorf("invalid JWT claims: %+v", claims)
}
}
func TestEnableBankingDocumentedFlowAndPagination(t *testing.T) {
var key *rsa.PrivateKey
expiry := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
pages := 0
handler := func(w http.ResponseWriter, r *http.Request) {
assertJWT(t, r, key)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/aspsps":
if r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" {
t.Error("institution filter missing")
}
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
case "/auth":
if r.Method != "POST" {
t.Error("wrong auth method")
}
var request struct {
Access struct {
ValidUntil string `json:"valid_until"`
Balances bool `json:"balances"`
Transactions bool `json:"transactions"`
} `json:"access"`
State string `json:"state"`
Redirect string `json:"redirect_url"`
PSUType string `json:"psu_type"`
ASPSP institutionDTO `json:"aspsp"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
valid, err := time.Parse(time.RFC3339, request.Access.ValidUntil)
if err != nil || valid.After(time.Now().Add(time.Hour)) || !valid.After(time.Now()) || !request.Access.Balances || !request.Access.Transactions || request.State != "csrf-state" || request.Redirect != "http://localhost:8080/api/banking/callback" || request.PSUType != "personal" || request.ASPSP.Name != "N26" {
t.Errorf("invalid authorization request: %+v", request)
}
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
case "/sessions":
if r.Method != "POST" {
t.Error("wrong exchange method")
}
var request map[string]string
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request["code"] != "secret-code" {
t.Error("missing exchange code")
}
fmt.Fprintf(w, `{"session_id":"session-1","access":{"valid_until":%q},"aspsp":{"name":"N26","country":"DE"},"accounts":[{"uid":"uid-one","identification_hash":"stable-hash","account_id":{"iban":"DE02120300000000202051"},"details":"Main account","currency":"EUR"}]}`, expiry)
case "/sessions/session-1":
fmt.Fprintf(w, `{"status":"AUTHORIZED","access":{"valid_until":%q},"aspsp":{"name":"N26","country":"DE"},"accounts":["uid-one"],"accounts_data":[{"uid":"uid-one","identification_hash":"stable-hash"}]}`, expiry)
case "/accounts/uid-one/details":
t.Error("session membership must not require account details")
http.Error(w, "account details unavailable", http.StatusServiceUnavailable)
case "/accounts/uid-one/balances":
fmt.Fprint(w, `{"balances":[{"name":"Booked","balance_amount":{"currency":"EUR","amount":"1234.5678"},"balance_type":"CLBD","reference_date":"2026-09-01"}]}`)
case "/accounts/uid-one/transactions":
pages++
q := r.URL.Query()
if q.Get("transaction_status") != "BOOK" || q.Get("date_from") != "2026-09-01" || q.Get("date_to") != "2026-09-30" {
t.Error("missing booked/date filters")
}
if pages == 1 {
if q.Get("continuation_key") != "" {
t.Error("unexpected initial continuation")
}
fmt.Fprint(w, `{"transactions":[{"entry_reference":"entry-one","transaction_id":"unstable","transaction_amount":{"amount":"12.3456","currency":"EUR"},"credit_debit_indicator":"DBIT","status":"BOOK","booking_date":"2026-09-01","value_date":"2026-09-02","creditor":{"name":"Cafe"},"creditor_account":{"iban":"DE89370400440532013000"},"remittance_information":["first","second"]},{"transaction_amount":{"amount":"99.00","currency":"EUR"},"credit_debit_indicator":"DBIT","status":"PDNG","booking_date":"2026-09-01"}],"continuation_key":"opaque +/=?token"}`)
} else {
if q.Get("continuation_key") != "opaque +/=?token" {
t.Error("pagination key was not encoded correctly")
}
fmt.Fprint(w, `{"transactions":[{"transaction_id":"not-a-stable-id","transaction_amount":{"amount":"20.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-03","debtor":{"name":"Employer"},"debtor_account":{"iban":"DE02120300000000202051"},"remittance_information":["Income"]}],"continuation_key":null}`)
}
default:
t.Errorf("unexpected request %s", r.URL.Path)
http.NotFound(w, r)
}
}
p, k := testProvider(t, handler)
key = k
authorization, err := p.Authorize(context.Background(), "N26", "de", "csrf-state")
if err != nil || authorization != "https://enablebanking.com/auth/consent" {
t.Fatalf("authorize: %s %v", authorization, err)
}
session, err := p.Exchange(context.Background(), "secret-code")
if err != nil {
t.Fatal(err)
}
if session.ID != "session-1" || len(session.Accounts) != 1 || session.Accounts[0].IBAN != "DE02120300000000202051" {
t.Fatalf("incorrect session: %+v", session)
}
status, err := p.Status(context.Background(), session.ID)
if err != nil {
t.Fatal(err)
}
if len(status.AccountIDs) != 1 || status.AccountIDs[0] != session.Accounts[0].ExternalAccountID || status.ValidUntil != expiry {
t.Fatalf("incorrect consent membership or expiry: %+v", status)
}
balances, err := p.Balances(context.Background(), "uid-one")
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "1234.5678" || balances[0].Type != "CLBD" {
t.Fatalf("balance precision lost: %+v %v", balances, err)
}
transactions, err := p.Transactions(context.Background(), session.Accounts[0], "2026-09-01", "2026-09-30", false)
if err != nil {
t.Fatal(err)
}
if pages != 2 || len(transactions) != 2 {
t.Fatalf("booked pagination: pages=%d rows=%d", pages, len(transactions))
}
if transactions[0].Amount.String() != "-12.3456" || transactions[0].ExternalID != "entry-one" || transactions[0].Counterparty != "Cafe" || transactions[0].RawDescription != "first\nsecond" || transactions[1].Amount.String() != "20.00" || transactions[1].ExternalID != "" || transactions[1].Counterparty != "Employer" {
t.Fatalf("wrong booking facts: %+v", transactions)
}
}
func TestEnableBankingFailsClosed(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "secret-account-IBAN private upstream failure", http.StatusUnauthorized)
})
_, err := p.Balances(context.Background(), "sensitive-account-identifier")
if err == nil || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "sensitive") || !strings.Contains(err.Error(), "401") {
t.Fatalf("unsafe error: %v", err)
}
if _, err := p.Status(context.Background(), "session"); err == nil || errors.Is(err, ErrReconnect) {
t.Fatalf("application HTTP401 conflated with bank consent: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := p.Balances(ctx, "uid"); err == nil {
t.Fatal("ignored cancellation")
}
}
func TestEnableBankingRejectsPaginationCyclesAndPartialResults(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"transactions":[{"transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-01"}],"continuation_key":"same"}`)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err == nil || rows != nil {
t.Fatal("pagination cycle returned partial import")
}
}
func TestEnableBankingLongestStrategyIsOnlySentWhenRequested(t *testing.T) {
var strategies []string
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
strategies = append(strategies, r.URL.Query().Get("strategy"))
fmt.Fprint(w, `{"transactions":[]}`)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
if _, err := p.Transactions(context.Background(), account, "2020-01-01", "", true); err != nil {
t.Fatal(err)
}
if _, err := p.Transactions(context.Background(), account, "2026-09-01", "2026-09-30", false); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(strategies, []string{"longest", ""}) {
t.Fatalf("wrong fetching strategies requested: %q", strategies)
}
}
func TestEnableBankingInstitutionsListsOnlyConnectableBanksWithSafeLogos(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/aspsps" || r.URL.Query().Get("country") != "DE" || r.URL.Query().Get("psu_type") != "personal" || r.URL.Query().Get("service") != "AIS" {
t.Errorf("wrong institution listing request: %s", r.URL)
}
fmt.Fprint(w, `{"aspsps":[
{"name":"Sparkasse","country":"DE","maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"},
{"name":"N26","country":"DE","maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"},
{"name":"Tracker Bank","country":"DE","maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"},
{"name":"Evil","country":"DE","maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"},
{"name":"Dormant","country":"DE","maximum_consent_validity":0},
{"name":"Elsewhere","country":"AT","maximum_consent_validity":3600},
{"name":"","country":"DE","maximum_consent_validity":3600}
]}`)
})
list, err := p.Institutions(context.Background(), "de")
if err != nil {
t.Fatal(err)
}
expected := []Institution{
{Name: "Evil", Country: "DE"},
{Name: "N26", Country: "DE"},
{Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/"},
{Name: "Tracker Bank", Country: "DE"},
}
if !reflect.DeepEqual(list, expected) {
t.Fatalf("wrong connectable institutions: %+v", list)
}
if _, err := p.Institutions(context.Background(), "DEU"); err == nil {
t.Fatal("accepted an invalid country code")
}
}
func TestEnableBankingLinksUsableAccountsAndCountsTheRest(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"session_id":"session-1","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"ING","country":"DE"},"accounts":[
{"uid":"giro","identification_hash":"hash-giro","account_id":{"iban":"DE02120300000000202051"},"details":"Girokonto","currency":"EUR"},
{"uid":"depot","identification_hash":"hash-depot","details":"Direkt-Depot"},
{"uid":"card","account_id":{},"details":"Credit card","currency":"EUR"},
{"uid":"extra","identification_hash":"hash-extra","details":"Extra-Konto","currency":"EUR"}
]}`)
})
session, err := p.Exchange(context.Background(), "code")
if err != nil {
t.Fatalf("one unusable shared account discarded the whole consent: %v", err)
}
var names []string
for _, account := range session.Accounts {
names = append(names, account.DisplayName)
}
if !reflect.DeepEqual(names, []string{"Girokonto", "Extra-Konto"}) || session.Unlinkable != 2 {
t.Fatalf("wrong linked accounts or unlinkable count: %v %d", names, session.Unlinkable)
}
}
func TestEnableBankingSurfacesOnlyDocumentedErrorCodes(t *testing.T) {
body := ""
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
fmt.Fprint(w, body)
})
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
fetch := func() error {
_, err := p.Transactions(context.Background(), account, "2020-01-01", "", true)
return err
}
body = `{"code":422,"error":"WRONG_TRANSACTIONS_PERIOD","message":"private-bank-text","detail":"private-account-detail"}`
err := fetch()
if err == nil || !strings.Contains(err.Error(), "WRONG_TRANSACTIONS_PERIOD") || !strings.Contains(err.Error(), "422") {
t.Fatalf("documented period error was hidden: %v", err)
}
if strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
t.Fatalf("unsafe or misclassified period error: %v", err)
}
body = `{"code":422,"error":"UNDOCUMENTED_PRIVATE_CODE","detail":"secret"}`
if err = fetch(); err == nil || strings.Contains(err.Error(), "UNDOCUMENTED") || strings.Contains(err.Error(), "secret") || !strings.Contains(err.Error(), "422") {
t.Fatalf("undocumented provider code leaked: %v", err)
}
body = `{"code":404,"error":"SESSION_DOES_NOT_EXIST"}`
if err = fetch(); !errors.Is(err, ErrReconnect) {
t.Fatalf("dead session did not request reconnection: %v", err)
}
}
func TestEnableBankingSessionRevocationAndInvalidBookedDates(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/sessions/") {
fmt.Fprint(w, `{"status":"REVOKED","accounts":[],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`)
return
}
fmt.Fprint(w, `{"transactions":[{"transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","value_date":"2026-09-01"}]}`)
})
if _, err := p.Status(context.Background(), "revoked"); !errors.Is(err, ErrReconnect) {
t.Fatalf("revoked consent must request reconnection: %v", err)
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err == nil || rows != nil {
t.Fatal("invented booking date for missing bank fact")
}
}
func TestEnableBankingDoesNotFollowRedirects(t *testing.T) {
leaked := false
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { leaked = true }))
defer target.Close()
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
})
if _, err := p.Balances(context.Background(), "uid"); err == nil || leaked {
t.Fatalf("followed sensitive banking redirect: leaked=%v error=%v", leaked, err)
}
}
func TestEnableBankingExpiredConsentRequiresReconnect(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"AUTHORIZED","accounts":[],"access":{"valid_until":"2000-01-01T00:00:00Z"}}`)
})
if _, err := p.Status(context.Background(), "expired"); !errors.Is(err, ErrReconnect) {
t.Fatalf("expired consent must request reconnection: %v", err)
}
}
func TestEnableBankingRejectsInvalidSessionMembership(t *testing.T) {
for name, payload := range map[string]string{
"empty UID": `{"status":"AUTHORIZED","accounts":[""],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`,
"non-string UID": `{"status":"AUTHORIZED","accounts":[{}],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`,
"invalid expiry": `{"status":"AUTHORIZED","accounts":["uid"],"access":{"valid_until":"not-a-date"}}`,
} {
t.Run(name, func(t *testing.T) {
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, payload)
})
status, err := p.Status(context.Background(), "session")
if err == nil || status.ValidUntil != "" || status.AccountIDs != nil || errors.Is(err, ErrReconnect) {
t.Fatalf("invalid response returned usable membership or claimed revoked consent: %+v %v", status, err)
}
})
}
}
type bankingRoundTripFunc func(*http.Request) (*http.Response, error)
func (f bankingRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
type bankingClosedBody struct {
io.ReadCloser
closed *atomic.Int32
onClose func()
}
func (b *bankingClosedBody) Close() error {
b.closed.Add(1)
err := b.ReadCloser.Close()
if b.onClose != nil {
b.onClose()
}
return err
}
func TestEnableBankingGETRecoversAfterRateLimit(t *testing.T) {
for _, endpoint := range []string{"status", "transactions"} {
t.Run(endpoint, func(t *testing.T) {
t.Parallel()
var calls, closed atomic.Int32
var first atomic.Int64
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
first.Store(time.Now().UnixNano())
w.Header().Set("Retry-After", "1")
http.Error(w, "private provider response", http.StatusTooManyRequests)
return
}
if time.Since(time.Unix(0, first.Load())) < time.Second {
t.Error("retried before provider cooldown elapsed")
}
if closed.Load() != 1 {
t.Error("retried without closing the rate-limit response")
}
if endpoint == "status" {
fmt.Fprint(w, `{"status":"AUTHORIZED","accounts":["uid"],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`)
} else {
fmt.Fprint(w, `{"transactions":[{"entry_reference":"entry","transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-01"}]}`)
}
})
transport := p.HTTPClient.Transport
p.HTTPClient.Timeout = 500 * time.Millisecond
p.HTTPClient.Transport = bankingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
response, err := transport.RoundTrip(r)
if err == nil && response.StatusCode == http.StatusTooManyRequests {
response.Body = &bankingClosedBody{ReadCloser: response.Body, closed: &closed}
}
return response, err
})
if endpoint == "status" {
status, err := p.Status(context.Background(), "session")
if err != nil || len(status.AccountIDs) != 1 || status.AccountIDs[0] != "uid" {
t.Fatalf("session membership did not recover: %+v %v", status, err)
}
} else {
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(context.Background(), account, "", "", false)
if err != nil || len(rows) != 1 || rows[0].ExternalID != "entry" || rows[0].Amount.String() != "1.00" {
t.Fatalf("transaction retrieval did not recover: %+v %v", rows, err)
}
}
if calls.Load() != 2 || closed.Load() != 1 {
t.Fatalf("unexpected retry requests or response leaks: calls=%d closed=%d", calls.Load(), closed.Load())
}
})
}
}
func TestEnableBankingCooldownCoversAllEndpoints(t *testing.T) {
var calls atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "300")
http.Error(w, "private provider response", http.StatusTooManyRequests)
})
_, err := p.Status(context.Background(), "session")
var initial *ratelimit.RateLimitError
if !errors.As(err, &initial) || !initial.RetryAt().After(time.Now()) || errors.Is(err, ErrReconnect) || strings.Contains(err.Error(), "private") {
t.Fatalf("unsafe or missing rate-limit error: %v", err)
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
for name, request := range map[string]func() error{
"status": func() error { _, err := p.Status(context.Background(), "other-session"); return err },
"balances": func() error { _, err := p.Balances(context.Background(), "uid"); return err },
"transactions": func() error { _, err := p.Transactions(context.Background(), account, "", "", false); return err },
"exchange": func() error { _, err := p.Exchange(context.Background(), "once-only-code"); return err },
"authorize": func() error { _, err := p.Authorize(context.Background(), "N26", "DE", "state"); return err },
} {
t.Run(name, func(t *testing.T) {
err := request()
var limit *ratelimit.RateLimitError
if !errors.As(err, &limit) || !limit.RetryAt().Equal(initial.RetryAt()) || errors.Is(err, ErrReconnect) || strings.Contains(err.Error(), "private") {
t.Fatalf("cooldown was lost or unsafe: %v", err)
}
if calls.Load() != 1 {
t.Fatalf("provider contacted during cooldown: %d requests", calls.Load())
}
})
}
}
func TestEnableBankingNeverReplaysMutationAfterRateLimit(t *testing.T) {
for _, endpoint := range []string{"exchange", "authorize"} {
t.Run(endpoint, func(t *testing.T) {
var posts atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/aspsps" {
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","maximum_consent_validity":3600}]}`)
return
}
if r.Method != http.MethodPost {
t.Error("unexpected non-mutation request")
}
posts.Add(1)
w.Header().Set("Retry-After", "1")
http.Error(w, "private once-only exchange failure", http.StatusTooManyRequests)
})
var err error
if endpoint == "exchange" {
_, err = p.Exchange(context.Background(), "once-only-code")
} else {
_, err = p.Authorize(context.Background(), "N26", "DE", "state")
}
var limit *ratelimit.RateLimitError
if !errors.As(err, &limit) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
t.Fatalf("mutation rate limit was lost or unsafe: %v", err)
}
if posts.Load() != 1 {
t.Fatalf("mutation replayed %d times", posts.Load())
}
})
}
}
func TestEnableBankingCanceledRetryRetainsCooldown(t *testing.T) {
var calls, closed atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "60")
http.Error(w, "private provider response", http.StatusTooManyRequests)
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
transport := p.HTTPClient.Transport
p.HTTPClient.Transport = bankingRoundTripFunc(func(r *http.Request) (*http.Response, error) {
response, err := transport.RoundTrip(r)
if err == nil && response.StatusCode == http.StatusTooManyRequests {
response.Body = &bankingClosedBody{ReadCloser: response.Body, closed: &closed, onClose: cancel}
}
return response, err
})
_, err := p.Status(ctx, "session")
var original *ratelimit.RateLimitError
if !errors.Is(err, context.Canceled) || !errors.As(err, &original) || strings.Contains(err.Error(), "private") {
t.Fatalf("cancellation lost safe rate-limit evidence: %v", err)
}
_, err = p.Balances(context.Background(), "uid")
var retained *ratelimit.RateLimitError
if !errors.As(err, &retained) || !retained.RetryAt().Equal(original.RetryAt()) {
t.Fatalf("cancellation discarded provider cooldown: %v", err)
}
if calls.Load() != 1 || closed.Load() != 1 {
t.Fatalf("cancellation retried or leaked a response: calls=%d closed=%d", calls.Load(), closed.Load())
}
}
func TestEnableBankingPSUSurvivesRetryAndPaginationWithoutLeakingToBackground(t *testing.T) {
var manualCalls, backgroundCalls atomic.Int32
psu := PSU{IPAddress: "203.0.113.42", UserAgent: "test-browser", Accept: "application/json", AcceptCharset: "utf-8", AcceptEncoding: "gzip", AcceptLanguage: "de"}
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/accounts/uid/transactions" {
for name, want := range map[string]string{
"Psu-Ip-Address": psu.IPAddress, "Psu-User-Agent": psu.UserAgent,
"Psu-Accept": psu.Accept, "Psu-Accept-Charset": psu.AcceptCharset,
"Psu-Accept-Encoding": psu.AcceptEncoding, "Psu-Accept-Language": psu.AcceptLanguage,
} {
if got := r.Header.Get(name); got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
switch manualCalls.Add(1) {
case 1:
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, `{"error":"ASPSP_RATE_LIMIT_EXCEEDED","detail":"private"}`)
case 2:
fmt.Fprint(w, `{"transactions":[],"continuation_key":"next-private-page"}`)
case 3:
if r.URL.Query().Get("continuation_key") != "next-private-page" || r.URL.Query().Get("date_from") != "2026-01-01" {
t.Error("pagination lost original filters or continuation")
}
fmt.Fprint(w, `{"transactions":[{"entry_reference":"entry","transaction_amount":{"amount":"1.00","currency":"EUR"},"credit_debit_indicator":"CRDT","status":"BOOK","booking_date":"2026-09-01"}]}`)
default:
t.Error("unexpected manual replay")
}
return
}
backgroundCalls.Add(1)
for name := range r.Header {
if strings.HasPrefix(strings.ToLower(name), "psu-") {
t.Errorf("PSU metadata escaped manual account request: %s", name)
}
}
if r.URL.Path == "/sessions/session" {
fmt.Fprint(w, `{"status":"AUTHORIZED","accounts":["uid"],"access":{"valid_until":"2099-01-01T00:00:00Z"}}`)
} else {
fmt.Fprint(w, `{"balances":[]}`)
}
})
p.requests = ratelimit.Controller{InitialBackoff: time.Millisecond}
ctx := WithPSU(context.Background(), psu)
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
rows, err := p.Transactions(ctx, account, "2026-01-01", "", false)
if err != nil || len(rows) != 1 || rows[0].ExternalID != "entry" {
t.Fatalf("manual history failed: %+v %v", rows, err)
}
if _, err := p.Balances(context.Background(), "uid"); err != nil {
t.Fatal(err)
}
if _, err := p.Status(ctx, "session"); err != nil {
t.Fatal(err)
}
if manualCalls.Load() != 3 || backgroundCalls.Load() != 2 {
t.Fatalf("unexpected request counts: manual=%d other=%d", manualCalls.Load(), backgroundCalls.Load())
}
}
func TestEnableBankingBackgroundQuotaIsScopedAndExpires(t *testing.T) {
var calls atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, `{"error":"ASPSP_RATE_LIMIT_EXCEEDED","message":"private-bank-message"}`)
return
}
if strings.HasSuffix(r.URL.Path, "/transactions") {
fmt.Fprint(w, `{"transactions":[]}`)
} else {
fmt.Fprint(w, `{"balances":[]}`)
}
})
before := time.Now()
_, err := p.Balances(context.Background(), "uid")
var first *ratelimit.RateLimitError
if !errors.As(err, &first) || first.RetryAt().Before(before.Add(6*time.Hour)) || first.RetryAt().After(time.Now().Add(6*time.Hour)) || strings.Contains(err.Error(), "private") || errors.Is(err, ErrReconnect) {
t.Fatalf("missing safe six-hour bank quota: %v", err)
}
var quota *BackgroundQuotaError
if !errors.As(err, &quota) || !quota.RetryAt().Equal(first.RetryAt()) {
t.Fatalf("background quota identity was lost: %v", err)
}
_, err = p.Balances(context.Background(), "uid")
var retained *ratelimit.RateLimitError
if !errors.As(err, &retained) || !retained.RetryAt().Equal(first.RetryAt()) || calls.Load() != 1 {
t.Fatalf("background quota was replayed: calls=%d err=%v", calls.Load(), err)
}
if _, err := p.Balances(WithPSU(context.Background(), PSU{IPAddress: "203.0.113.42", UserAgent: "test-browser"}), "uid"); err != nil {
t.Fatalf("background quota blocked real user: %v", err)
}
_, err = p.Balances(context.Background(), "uid")
if !errors.As(err, &quota) || !quota.RetryAt().Equal(first.RetryAt()) || calls.Load() != 2 {
t.Fatalf("foreground success erased background quota: calls=%d err=%v", calls.Load(), err)
}
if _, err := p.Balances(context.Background(), "other-uid"); err != nil {
t.Fatalf("background quota blocked another account: %v", err)
}
account := fixtureDataset().Accounts[0]
account.ExternalAccountID = "uid"
if _, err := p.Transactions(context.Background(), account, "", "", false); err != nil {
t.Fatalf("balance quota blocked transaction endpoint: %v", err)
}
// Simulate the stored deadline passing without a six-hour wall-clock wait.
p.backgroundQuotas["/accounts/uid/balances"] = time.Now().Add(-time.Second)
if _, err := p.Balances(context.Background(), "uid"); err != nil {
t.Fatalf("expired quota blocked retrieval: %v", err)
}
if calls.Load() != 5 {
t.Fatalf("unexpected bank replays: %d", calls.Load())
}
}
func TestEnableBankingBackgroundQuotaPreservesLongerHints(t *testing.T) {
for _, hint := range []string{"43200", time.Now().Add(24 * time.Hour).UTC().Format(http.TimeFormat), "999999999999999999999999999999999"} {
t.Run(hint, func(t *testing.T) {
var calls atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", hint)
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, `{"error":"ASPSP_RATE_LIMIT_EXCEEDED"}`)
})
before := time.Now()
_, err := p.Balances(context.Background(), "uid")
var limit *ratelimit.RateLimitError
if !errors.As(err, &limit) || calls.Load() != 1 {
t.Fatalf("quota replayed or lost: calls=%d err=%v", calls.Load(), err)
}
switch hint {
case "43200":
if limit.RetryAt().Before(before.Add(12 * time.Hour)) {
t.Fatal("shortened numeric provider hint")
}
case "999999999999999999999999999999999":
if !limit.RetryAt().IsZero() {
t.Fatal("overflow became a finite short retry")
}
default:
date, _ := http.ParseTime(hint)
if !limit.RetryAt().Equal(date) {
t.Fatal("shortened absolute provider hint")
}
}
})
}
}
func TestEnableBankingOnlyExactBoundedBankErrorUsesBackgroundQuota(t *testing.T) {
for name, payload := range map[string]string{
"message only": `{"error":"OTHER","message":"ASPSP_RATE_LIMIT_EXCEEDED"}`,
"prefix": `{"error":"ASPSP_RATE_LIMIT_EXCEEDED_OTHER"}`,
"nested": `{"error":{"code":"ASPSP_RATE_LIMIT_EXCEEDED"}}`,
"oversized": `{"error":"ASPSP_RATE_LIMIT_EXCEEDED","detail":"` + strings.Repeat("x", 16<<10) + `"}`,
} {
t.Run(name, func(t *testing.T) {
var calls atomic.Int32
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Header().Set("Retry-After", "300")
w.WriteHeader(http.StatusTooManyRequests)
fmt.Fprint(w, payload)
})
_, err := p.Balances(context.Background(), "uid")
var first *ratelimit.RateLimitError
if !errors.As(err, &first) || first.RetryAt().After(time.Now().Add(6*time.Minute)) {
t.Fatalf("unrecognized error used bank quota: %v", err)
}
_, err = p.Balances(WithPSU(context.Background(), PSU{IPAddress: "203.0.113.42"}), "other")
var common *ratelimit.RateLimitError
if !errors.As(err, &common) || !common.RetryAt().Equal(first.RetryAt()) || calls.Load() != 1 {
t.Fatalf("generic platform cooldown not shared: calls=%d err=%v", calls.Load(), err)
}
})
}
}
func TestEnableBankingValidatesUploadedCredentials(t *testing.T) {
_, key := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("credential validation must not call provider")
})
pkcs1 := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
der, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
pkcs8 := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})
for _, content := range [][]byte{pkcs1, pkcs8} {
p, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback")
if err != nil {
t.Fatal(err)
}
token, err := p.jwt()
if err != nil {
t.Fatal(err)
}
r := httptest.NewRequest(http.MethodGet, "/", nil)
r.Header.Set("Authorization", "Bearer "+token)
assertJWT(t, r, key)
}
weak, err := rsa.GenerateKey(rand.Reader, 1024)
if err != nil {
t.Fatal(err)
}
for name, content := range map[string][]byte{
"invalid": []byte("secret-invalid-key"),
"oversized": bytes.Repeat([]byte("k"), MaxPrivateKeyPEM+1),
"multiple": append(append([]byte{}, pkcs1...), pkcs8...),
"prefix": append([]byte("secret-prefix\n"), pkcs1...),
"weak": pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(weak)}),
} {
t.Run(name, func(t *testing.T) {
if _, err := NewEnableBanking("test-app", content, "https://finance.example/api/banking/callback"); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatal("invalid PEM accepted or leaked")
}
})
}
for _, appID := range []string{"", "app one", "app\none", "app\u007fone", strings.Repeat("a", 257)} {
if _, err := NewEnableBanking(appID, pkcs1, "https://finance.example/api/banking/callback"); err == nil {
t.Fatal("invalid app ID accepted")
}
}
for _, redirect := range []string{
"https://finance.example/", "https://finance.example/api/banking/callback?secret=value",
"https://finance.example/api/banking/callback#", "https://finance.example/api/banking/callback?",
"https://user:secret@finance.example/api/banking/callback", "ftp://finance.example/api/banking/callback",
"https://finance.example/api/banking/%63allback", "https:///api/banking/callback",
} {
if _, err := NewEnableBanking("test-app", pkcs1, redirect); err == nil || strings.Contains(err.Error(), "secret") {
t.Fatal("invalid callback accepted or leaked")
}
}
}