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