Files
Lars Nolden c33e8d5573 Authorize consents for the account-holder type the bank supports
Kontist authorized but shared no accounts: psu_type was hardcoded to
personal, and Enable Banking documents that a psu_type mismatch can
yield a consent without the expected accounts. The bank listing now
reports each institution's supported psu_types, the connect form offers
only those, the chosen type reaches POST /auth, and an unsupported
combination is refused before the user is sent to a bank. The choice is
stored per consent so reconnecting reuses it; consents predating the
choice stay personal.

Also repairs the frontend derivation, which the Montserrat dependency
broke: npmDepsHash was stale and web/public was missing from the
fileset, so the traced duck icon never reached the built assets.
2026-09-11 13:39:45 +02:00

852 lines
36 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") != "" || r.URL.Query().Get("service") != "AIS" {
t.Errorf("wrong institution listing request: %s", r.URL)
}
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"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", PSUPersonal, "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("service") != "AIS" {
t.Errorf("wrong institution listing request: %s", r.URL)
}
fmt.Fprint(w, `{"aspsps":[
{"name":"Sparkasse","country":"DE","psu_types":["personal","business"],"maximum_consent_validity":3600,"logo":"https://enablebanking.com/brands/DE/Sparkasse/"},
{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"http://enablebanking.com/brands/DE/N26/"},
{"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600},
{"name":"Tracker Bank","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://tracker.example/pixel.png"},
{"name":"Evil","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600,"logo":"https://evil-enablebanking.com/logo"},
{"name":"Dormant","country":"DE","psu_types":["personal"],"maximum_consent_validity":0},
{"name":"Typeless","country":"DE","psu_types":["corporate"],"maximum_consent_validity":3600},
{"name":"Elsewhere","country":"AT","psu_types":["personal"],"maximum_consent_validity":3600},
{"name":"","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}
]}`)
})
list, err := p.Institutions(context.Background(), "de")
if err != nil {
t.Fatal(err)
}
expected := []Institution{
{Name: "Evil", Country: "DE", PSUTypes: []string{PSUPersonal}},
{Name: "Kontist", Country: "DE", PSUTypes: []string{PSUBusiness}},
{Name: "N26", Country: "DE", PSUTypes: []string{PSUPersonal}},
{Name: "Sparkasse", Country: "DE", Logo: "https://enablebanking.com/brands/DE/Sparkasse/", PSUTypes: []string{PSUPersonal, PSUBusiness}},
{Name: "Tracker Bank", Country: "DE", PSUTypes: []string{PSUPersonal}},
}
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")
}
}
// Enable Banking documents that authorizing with the wrong psu_type yields a
// consent that shares no accounts, so the requested type must reach /auth and
// an unsupported type must fail before the user is sent to a bank.
func TestEnableBankingAuthorizesTheRequestedAccountHolderType(t *testing.T) {
var sent []string
p, _ := testProvider(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/aspsps" {
fmt.Fprint(w, `{"aspsps":[
{"name":"Kontist","country":"DE","psu_types":["business"],"maximum_consent_validity":3600},
{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}
]}`)
return
}
var request struct {
PSUType string `json:"psu_type"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Error(err)
}
sent = append(sent, request.PSUType)
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
})
if _, err := p.Authorize(context.Background(), "Kontist", "DE", PSUBusiness, "state"); err != nil {
t.Fatalf("business consent rejected: %v", err)
}
if _, err := p.Authorize(context.Background(), "N26", "DE", PSUPersonal, "state"); err != nil {
t.Fatalf("personal consent rejected: %v", err)
}
if !reflect.DeepEqual(sent, []string{PSUBusiness, PSUPersonal}) {
t.Fatalf("requested account types did not reach the provider: %q", sent)
}
_, err := p.Authorize(context.Background(), "Kontist", "DE", PSUPersonal, "state")
var consent *ConsentError
if !errors.As(err, &consent) || !strings.Contains(err.Error(), "business") {
t.Fatalf("personal consent for a business-only bank was not refused: %v", err)
}
if len(sent) != 2 {
t.Fatal("unsupported account type still started a bank authorization")
}
if _, err := p.Authorize(context.Background(), "N26", "DE", "corporate", "state"); err == nil {
t.Fatal("accepted an undocumented account type")
}
}
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", PSUPersonal, "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","psu_types":["personal"],"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", PSUPersonal, "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")
}
}
}