Pace provider traffic and identify genuine foreground bank requests
This commit is contained in:
@@ -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, "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, "", ""); 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")
|
||||
|
||||
Reference in New Issue
Block a user