Files
finance-duck/internal/app/banking_settings_test.go
T
Lars Nolden e77969b8c5 Report failed bank connections and stop resurrecting deleted accounts
Three defects made new connections silently vanish while removed
accounts returned:

- A single shared account the journal cannot represent (securities or
  card entries without IBAN, stable identification or currency) aborted
  the entire consent. Usable accounts are now linked and the rest
  counted and reported.
- A consent that linked nothing was stored, redirected as success and
  later reaped by session recovery. It now fails with the reason.
- Callback failures rendered a bare JSON error page and were never
  logged. They now log and redirect into the app with the reason shown.
- Deleting an account left its session binding, so the next connect or
  sync recovered the binding and re-added the account. Account deletion
  now releases bindings, consents and cursors before committing.
2026-09-11 11:46:59 +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","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", 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", 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")
}
}