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.
385 lines
14 KiB
Go
385 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
const bankingCallback = "http://localhost:8080/api/banking/callback"
|
|
|
|
func bankingKey(t *testing.T) (*rsa.PrivateKey, string) {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return key, string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
|
}
|
|
|
|
// Exercise the live provider, verifying the actual signed JWT and callback sent
|
|
// upstream, rather than inspecting its private fields or merely saved metadata.
|
|
func bankingAuthorization(t *testing.T, a *App, key *rsa.PrivateKey, appID, redirect string) string {
|
|
t.Helper()
|
|
pending := ""
|
|
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
parts := strings.Split(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "), ".")
|
|
if len(parts) != 3 {
|
|
t.Error("missing signed banking authorization")
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
|
hash := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
|
|
if err != nil || rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, hash[:], sig) != nil {
|
|
t.Error("live provider signed with the wrong key")
|
|
w.WriteHeader(401)
|
|
return
|
|
}
|
|
headerBytes, _ := base64.RawURLEncoding.DecodeString(parts[0])
|
|
var header map[string]string
|
|
if json.Unmarshal(headerBytes, &header) != nil || header["kid"] != appID {
|
|
t.Error("live provider signed for the wrong application")
|
|
}
|
|
switch r.URL.Path {
|
|
case "/aspsps":
|
|
fmt.Fprint(w, `{"aspsps":[{"name":"N26","country":"DE","psu_types":["personal"],"maximum_consent_validity":3600}]}`)
|
|
case "/auth":
|
|
var req struct {
|
|
State string `json:"state"`
|
|
Redirect string `json:"redirect_url"`
|
|
}
|
|
if json.NewDecoder(r.Body).Decode(&req) != nil || req.Redirect != redirect {
|
|
t.Error("wrong callback sent to banking provider")
|
|
}
|
|
pending = req.State
|
|
fmt.Fprint(w, `{"url":"https://enablebanking.com/auth/consent"}`)
|
|
case "/sessions":
|
|
fmt.Fprint(w, `{"session_id":"session-one","access":{"valid_until":"2099-01-01T00:00:00Z"},"aspsp":{"name":"N26","country":"DE"},"accounts":[{"uid":"uid-one","identification_hash":"stable-one","account_id":{"iban":"DE02120300000000202051"},"details":"Bank account","currency":"EUR"}]}`)
|
|
case "/accounts/uid-one/balances":
|
|
fmt.Fprint(w, `{"balances":[{"balance_amount":{"currency":"EUR","amount":"12.50"},"balance_type":"CLBD"}]}`)
|
|
default:
|
|
t.Errorf("unexpected banking request: %s", r.URL.Path)
|
|
w.WriteHeader(500)
|
|
}
|
|
}))
|
|
t.Cleanup(mock.Close)
|
|
provider, ok := a.bank.(*banking.EnableBanking)
|
|
if !ok {
|
|
t.Fatal("banking provider unavailable")
|
|
}
|
|
provider.BaseURL = mock.URL
|
|
provider.HTTPClient = mock.Client()
|
|
if _, err := a.Authorize(context.Background(), "N26", "DE", banking.PSUPersonal, 12); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pending
|
|
}
|
|
|
|
func reopenBankingApp(t *testing.T, a *App) *App {
|
|
t.Helper()
|
|
dir := a.dir
|
|
if err := a.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reopened, err := Open(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { reopened.Close() })
|
|
return reopened
|
|
}
|
|
|
|
func TestBankingRuntimeRotationPreservesConsentAndRejectsPendingCallback(t *testing.T) {
|
|
a, s := testApp(t)
|
|
s = seed(t, a, s)
|
|
key, keyPEM := bankingKey(t)
|
|
ctx := context.Background()
|
|
if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending := bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
|
if _, err := a.Callback(ctx, "code", pending); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, err := a.Snapshot(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
accountID := before.Sessions[0].Accounts[0].ID
|
|
a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z"
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending = bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
|
rotated, rotatedPEM := bankingKey(t)
|
|
callback := "https://finance.example/api/banking/callback"
|
|
after, err := a.SaveBankingSettings(ctx, "app-one", &rotatedPEM, callback)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(before.Sessions, after.Sessions) || !reflect.DeepEqual(before.Data, after.Data) || a.ops.AccountSync[accountID] == "" {
|
|
t.Fatal("same-application rotation discarded consent, cursor or canonical data")
|
|
}
|
|
if _, err := a.Callback(ctx, "code", pending); err == nil {
|
|
t.Fatal("rotation accepted a stale pending callback")
|
|
}
|
|
bankingAuthorization(t, a, rotated, "app-one", callback)
|
|
balances, err := a.Balances(ctx, accountID)
|
|
if err != nil || len(balances) != 1 || balances[0].Amount.String() != "12.50" {
|
|
t.Fatalf("rotated consent could not fetch balances: %v %v", balances, err)
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
bankingAuthorization(t, a, rotated, "app-one", callback)
|
|
if _, err := a.Balances(ctx, accountID); err != nil {
|
|
t.Fatal("same-app consent did not survive restart", err)
|
|
}
|
|
if _, err := a.SaveBankingSettings(ctx, "app-one", nil, bankingCallback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bankingAuthorization(t, a, rotated, "app-one", bankingCallback)
|
|
}
|
|
|
|
func TestBankingAppSwitchAndDisableNeverReuseOldSessions(t *testing.T) {
|
|
for _, remove := range []bool{false, true} {
|
|
t.Run(fmt.Sprint("remove=", remove), func(t *testing.T) {
|
|
a, s := testApp(t)
|
|
seed(t, a, s)
|
|
key, keyPEM := bankingKey(t)
|
|
ctx := context.Background()
|
|
if _, err := a.SaveBankingSettings(ctx, "app-one", &keyPEM, bankingCallback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending := bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
|
if _, err := a.Callback(ctx, "code", pending); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := a.Snapshot(ctx)
|
|
accountID := before.Sessions[0].Accounts[0].ID
|
|
a.ops.AccountSync[accountID] = "2026-09-01T00:00:00Z"
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pending = bankingAuthorization(t, a, key, "app-one", bankingCallback)
|
|
var err error
|
|
if remove {
|
|
_, err = a.RemoveBankingSettings(ctx)
|
|
} else {
|
|
_, err = a.SaveBankingSettings(ctx, "app-two", &keyPEM, bankingCallback)
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for restart := range 2 {
|
|
if restart != 0 {
|
|
// The credential save deliberately left old sync-state on disk.
|
|
a = reopenBankingApp(t, a)
|
|
}
|
|
if !remove {
|
|
bankingAuthorization(t, a, key, "app-two", bankingCallback)
|
|
}
|
|
if _, err := a.Callback(ctx, "code", pending); err == nil {
|
|
t.Fatal("old pending authorization accepted after app change")
|
|
}
|
|
if _, err := a.Balances(ctx, accountID); err == nil {
|
|
t.Fatal("old account UID used with changed credentials")
|
|
}
|
|
after, err := a.Snapshot(ctx)
|
|
if err != nil || len(after.Sessions) != 0 || len(a.ops.AccountSync) != 0 || !reflect.DeepEqual(before.Data, after.Data) {
|
|
t.Fatal("app change retained session/cursor or changed canonical data")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBankingSavedCredentialsAndDisableOverrideEnvironment(t *testing.T) {
|
|
a, _ := testApp(t)
|
|
envKey, envPEM := bankingKey(t)
|
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
|
if err := os.WriteFile(keyFile, []byte(envPEM), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("ENABLEBANKING_APP_ID", "environment-app")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
|
a = reopenBankingApp(t, a)
|
|
bankingAuthorization(t, a, envKey, "environment-app", bankingCallback)
|
|
key, keyPEM := bankingKey(t)
|
|
ctx := context.Background()
|
|
state, err := a.SaveBankingSettings(ctx, "saved-app", &keyPEM, bankingCallback)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded, err := json.Marshal(state)
|
|
if err != nil || strings.Contains(string(encoded), "PRIVATE KEY") || strings.Contains(string(encoded), "private_key") {
|
|
t.Fatal("private key exposed in State")
|
|
}
|
|
info, err := os.Stat(filepath.Join(a.dir, "state", "enablebanking.json"))
|
|
if err != nil || info.Mode().Perm() != 0600 {
|
|
t.Fatal("saved credential is not private")
|
|
}
|
|
// Saved settings must not even read a now-unavailable environment key.
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", filepath.Join(t.TempDir(), "missing.pem"))
|
|
a = reopenBankingApp(t, a)
|
|
bankingAuthorization(t, a, key, "saved-app", bankingCallback)
|
|
if _, err := a.RemoveBankingSettings(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
if _, err := a.Authorize(ctx, "N26", "DE", banking.PSUPersonal, 12); err == nil {
|
|
t.Fatal("disabled saved configuration fell back to environment")
|
|
}
|
|
}
|
|
|
|
func TestBankingRejectedSettingsAndFailedWritePreserveActiveProvider(t *testing.T) {
|
|
a, _ := testApp(t)
|
|
key, keyPEM := bankingKey(t)
|
|
ctx := context.Background()
|
|
if _, err := a.SaveBankingSettings(ctx, "active-app", &keyPEM, bankingCallback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bad := "secret-invalid-private-key"
|
|
for _, input := range []struct {
|
|
app string
|
|
key *string
|
|
callback string
|
|
}{
|
|
{"active-app", &bad, bankingCallback},
|
|
{"new-app", nil, bankingCallback},
|
|
{"", &keyPEM, bankingCallback},
|
|
{"secret invalid app", &keyPEM, bankingCallback},
|
|
{"active-app", &keyPEM, "https://secret.example/wrong"},
|
|
} {
|
|
if _, err := a.SaveBankingSettings(ctx, input.app, input.key, input.callback); err == nil || strings.Contains(err.Error(), "secret") {
|
|
t.Fatal("invalid configuration accepted or leaked in error")
|
|
}
|
|
}
|
|
pending := bankingAuthorization(t, a, key, "active-app", bankingCallback)
|
|
path := filepath.Join(a.dir, "state", "enablebanking.json")
|
|
if err := os.Rename(path, path+".backup"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Mkdir(path, 0700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.SaveBankingSettings(ctx, "new-app", &keyPEM, bankingCallback); err == nil {
|
|
t.Fatal("failed write reported success")
|
|
}
|
|
if _, err := a.RemoveBankingSettings(ctx); err == nil {
|
|
t.Fatal("failed removal reported success")
|
|
}
|
|
if _, err := a.Callback(ctx, "code", pending); err != nil {
|
|
t.Fatal("failed credential write invalidated active authorization", err)
|
|
}
|
|
bankingAuthorization(t, a, key, "active-app", bankingCallback)
|
|
}
|
|
|
|
func TestBankingMalformedSavedSettingsFailClosed(t *testing.T) {
|
|
_, keyPEM := bankingKey(t)
|
|
for _, malformed := range []string{
|
|
`{}`, `null`, `{"private_key":"secret-invalid-key"}`, `{"disabled":true,"scope":"bank_test","app_id":"","redirect_url":"","private_key":"","disabled":false}`,
|
|
} {
|
|
a, _ := testApp(t)
|
|
if _, err := a.SaveBankingSettings(context.Background(), "valid-app", &keyPEM, bankingCallback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := a.dir
|
|
if err := a.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("ENABLEBANKING_APP_ID", "environment-app")
|
|
if err := os.WriteFile(filepath.Join(dir, "state", "enablebanking.json"), []byte(malformed), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reopened, err := Open(dir)
|
|
if err == nil {
|
|
reopened.Close()
|
|
t.Fatal("invalid saved settings were accepted")
|
|
}
|
|
if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "APP_ID") {
|
|
t.Fatal("saved error leaked input or fell back to environment")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBankingLegacyEnvironmentSessionsBindBeforeFirstSave(t *testing.T) {
|
|
a, s := testApp(t)
|
|
key, keyPEM := bankingKey(t)
|
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
|
if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("ENABLEBANKING_APP_ID", "legacy-app")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
|
account := s.Data.Accounts[0]
|
|
account.ExternalAccountID = "uid-one"
|
|
// Simulate sync-state written by the version predating Settings.
|
|
a.ops.Sessions = []banking.Session{{ID: "legacy-session", ValidUntil: "2099-01-01T00:00:00Z", Accounts: []domain.Account{account}}}
|
|
a.ops.BankingScope = ""
|
|
a.ops.AccountSync[account.ID] = "2026-09-01T00:00:00Z"
|
|
if err := a.saveOps(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a = reopenBankingApp(t, a)
|
|
ctx := context.Background()
|
|
before, err := a.Snapshot(ctx)
|
|
if err != nil || len(before.Sessions) != 1 {
|
|
t.Fatal("legacy environment consent was not retained")
|
|
}
|
|
// Restart again before any UI save, proving the migration itself persisted.
|
|
a = reopenBankingApp(t, a)
|
|
after, err := a.SaveBankingSettings(ctx, "legacy-app", nil, bankingCallback)
|
|
if err != nil || !reflect.DeepEqual(before.Sessions, after.Sessions) || a.ops.AccountSync[account.ID] == "" {
|
|
t.Fatal("first same-app Settings save discarded legacy consent")
|
|
}
|
|
bankingAuthorization(t, a, key, "legacy-app", bankingCallback)
|
|
}
|
|
|
|
func TestBankingEnvironmentAppChangeInvalidatesBoundSessions(t *testing.T) {
|
|
a, _ := testApp(t)
|
|
key, keyPEM := bankingKey(t)
|
|
keyFile := filepath.Join(t.TempDir(), "env.pem")
|
|
if err := os.WriteFile(keyFile, []byte(keyPEM), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("ENABLEBANKING_APP_ID", "env-one")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", keyFile)
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", bankingCallback)
|
|
a = reopenBankingApp(t, a)
|
|
ctx := context.Background()
|
|
pending := bankingAuthorization(t, a, key, "env-one", bankingCallback)
|
|
if _, err := a.Callback(ctx, "code", pending); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _ := a.Snapshot(ctx)
|
|
t.Setenv("ENABLEBANKING_APP_ID", "env-two")
|
|
a = reopenBankingApp(t, a)
|
|
bankingAuthorization(t, a, key, "env-two", bankingCallback)
|
|
if _, err := a.Balances(ctx, before.Sessions[0].Accounts[0].ID); err == nil {
|
|
t.Fatal("environment app change reused another application's account")
|
|
}
|
|
after, err := a.Snapshot(ctx)
|
|
if err != nil || len(after.Sessions) != 0 || !reflect.DeepEqual(before.Data, after.Data) {
|
|
t.Fatal("environment change retained sessions or lost canonical data")
|
|
}
|
|
}
|