init
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package banking
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "private.pem")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := NewEnableBanking("test-app", path, "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()
|
||||
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":
|
||||
fmt.Fprint(w, `{"account_id":{"iban":"DE02120300000000202051"},"details":"Main account","currency":"EUR"}`)
|
||||
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.Accounts) != 1 || status.Accounts[0].ID != session.Accounts[0].ID || status.Accounts[0].ExternalAccountID != "uid-one" {
|
||||
t.Fatalf("account identity changed between session DTOs: %+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")
|
||||
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, "", "")
|
||||
if err == nil || rows != nil {
|
||||
t.Fatal("pagination cycle returned partial import")
|
||||
}
|
||||
}
|
||||
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, "", "")
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user