Pace provider traffic and identify genuine foreground bank requests

This commit is contained in:
Lars Nolden
2026-09-10 17:47:44 +02:00
parent ba3ea6ae5a
commit 4324660888
13 changed files with 817 additions and 32 deletions
+180
View File
@@ -39,6 +39,7 @@ func testProvider(t *testing.T, handler http.HandlerFunc) (*EnableBanking, *rsa.
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) {
@@ -462,6 +463,185 @@ func TestEnableBankingCanceledRetryRetainsCooldown(t *testing.T) {
}
}
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", "")
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, "", ""); 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")