Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
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"); 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"); 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user