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.
802 lines
34 KiB
Go
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, "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")
|
|
}
|
|
}
|
|
}
|