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 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, "a) || !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, "a) || !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") } } }