Files
finance-duck/internal/app/banking_settings_test.go
Lars Nolden c33e8d5573 Authorize consents for the account-holder type the bank supports
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.
2026-09-11 13:39:45 +02:00

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")
}
}