Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
+120
-39
@@ -1,10 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -30,36 +32,39 @@ type Status struct {
|
||||
AIConfigured bool `json:"ai_configured"`
|
||||
}
|
||||
type State struct {
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
Connections []Connection `json:"connections"`
|
||||
Data domain.Dataset `json:"data"`
|
||||
Revision string `json:"revision"`
|
||||
Status Status `json:"status"`
|
||||
Settings Settings `json:"settings"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
CallbackURL string `json:"callback_url"`
|
||||
BankingAppID string `json:"banking_app_id"`
|
||||
Connections []Connection `json:"connections"`
|
||||
}
|
||||
type operational struct {
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
Sessions []banking.Session `json:"sessions"`
|
||||
LastSync string `json:"last_sync"`
|
||||
SyncError string `json:"sync_error"`
|
||||
Consents map[string]Consent `json:"consents"`
|
||||
AccountSync map[string]string `json:"account_sync"`
|
||||
BankingScope string `json:"banking_scope"`
|
||||
}
|
||||
type App struct {
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
syncRequested chan struct{}
|
||||
mu sync.Mutex
|
||||
dir string
|
||||
journal *journal.Store
|
||||
index *analytics.Store
|
||||
indexed string
|
||||
indexError string
|
||||
settings Settings
|
||||
ops operational
|
||||
bank banking.Provider
|
||||
classifier classification.Client
|
||||
previews map[string]Preview
|
||||
authStates map[string]authorization
|
||||
callbackURL string
|
||||
bankingSettings bankingSettings
|
||||
syncRequested chan struct{}
|
||||
}
|
||||
|
||||
func Open(dir string) (*App, error) {
|
||||
@@ -115,17 +120,13 @@ func Open(dir string) (*App, error) {
|
||||
if a.ops.AccountSync == nil {
|
||||
a.ops.AccountSync = make(map[string]string)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: os.Getenv("OPENROUTER_API_KEY"), Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
appID, key, redirect := os.Getenv("ENABLEBANKING_APP_ID"), os.Getenv("ENABLEBANKING_KEY_FILE"), os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||
a.callbackURL = redirect
|
||||
if appID != "" || key != "" || redirect != "" {
|
||||
if appID == "" || key == "" || redirect == "" {
|
||||
return fail(errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables"))
|
||||
}
|
||||
a.bank, err = banking.NewEnableBanking(appID, key, redirect)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
apiKey, err := loadOpenRouterKey(filepath.Join(dir, "state", "openrouter.json"))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
a.classifier = classification.Client{APIKey: apiKey, Model: a.settings.Model, IncludeAmount: a.settings.IncludeAmount}
|
||||
if err = a.loadBankingSettings(); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
a.index, err = analytics.Open(filepath.Join(dir, "cache", "finance.duckdb"))
|
||||
if err != nil {
|
||||
@@ -155,7 +156,7 @@ func (a *App) snapshot(ctx context.Context) (State, error) {
|
||||
a.indexError = ""
|
||||
}
|
||||
}
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
return State{Data: d, Revision: rev, Settings: a.settings, Sessions: copySessions(a.ops.Sessions), CallbackURL: a.callbackURL, BankingAppID: a.bankingSettings.AppID, Connections: a.connections(d), Status: Status{SyncError: a.ops.SyncError, LastSync: a.ops.LastSync, IndexError: a.indexError, BankingConfigured: a.bank != nil, AIConfigured: a.classifier.APIKey != ""}}, nil
|
||||
}
|
||||
func (a *App) Snapshot(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
@@ -237,6 +238,86 @@ func (a *App) saveOps() error {
|
||||
}
|
||||
return atomicFile(filepath.Join(a.dir, "state", "sync-state.json"), append(b, '\n'))
|
||||
}
|
||||
|
||||
func normalizeOpenRouterKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
const invalid = "OpenRouter API key must be at most 4096 bytes and contain only non-whitespace ASCII characters"
|
||||
if len(key) > 4096 {
|
||||
return "", errors.New(invalid)
|
||||
}
|
||||
for i := range len(key) {
|
||||
if key[i] < 0x21 || key[i] > 0x7e {
|
||||
return "", errors.New(invalid)
|
||||
}
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func loadOpenRouterKey(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
return normalizeOpenRouterKey(os.Getenv("OPENROUTER_API_KEY"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", errors.New("cannot read saved OpenRouter credential")
|
||||
}
|
||||
defer f.Close()
|
||||
// Bound encoded storage too, allowing JSON escapes for a maximum-size key.
|
||||
b, err := io.ReadAll(io.LimitReader(f, 32*1024+1))
|
||||
if err != nil {
|
||||
return "", errors.New("cannot read saved OpenRouter credential")
|
||||
}
|
||||
invalid := errors.New("invalid saved OpenRouter credential")
|
||||
if len(b) > 32*1024 {
|
||||
return "", invalid
|
||||
}
|
||||
// Require exactly one case-sensitive string field, rejecting duplicates,
|
||||
// unknown fields, null, and trailing JSON rather than silently disabling AI.
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
if token, err := dec.Token(); err != nil || token != json.Delim('{') {
|
||||
return "", invalid
|
||||
}
|
||||
if token, err := dec.Token(); err != nil || token != "api_key" {
|
||||
return "", invalid
|
||||
}
|
||||
token, err := dec.Token()
|
||||
key, ok := token.(string)
|
||||
if err != nil || !ok {
|
||||
return "", invalid
|
||||
}
|
||||
if token, err := dec.Token(); err != nil || token != json.Delim('}') {
|
||||
return "", invalid
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return "", invalid
|
||||
}
|
||||
key, err = normalizeOpenRouterKey(key)
|
||||
if err != nil {
|
||||
return "", invalid
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (a *App) SaveOpenRouterKey(ctx context.Context, key string) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
key, err := normalizeOpenRouterKey(key)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
b, err := json.Marshal(struct {
|
||||
APIKey string `json:"api_key"`
|
||||
}{APIKey: key})
|
||||
if err != nil {
|
||||
return State{}, errors.New("cannot encode OpenRouter credential")
|
||||
}
|
||||
if err := atomicFile(filepath.Join(a.dir, "state", "openrouter.json"), append(b, '\n')); err != nil {
|
||||
return State{}, errors.New("cannot save OpenRouter credential")
|
||||
}
|
||||
a.classifier.APIKey = key
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
|
||||
func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -244,7 +325,7 @@ func (a *App) SaveSettings(ctx context.Context, s Settings) (State, error) {
|
||||
if len(s.Model) > 200 {
|
||||
return State{}, errors.New("model name is too long")
|
||||
}
|
||||
b := []byte("# Secrets belong in environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
b := []byte("# Preferences only. Manage secrets in Settings or environment variables, never this file.\nclassification_model = " + strconv.Quote(s.Model) + "\ninclude_amount = " + strconv.FormatBool(s.IncludeAmount) + "\n")
|
||||
if err := atomicFile(filepath.Join(a.dir, "config.toml"), b); err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
|
||||
@@ -86,9 +86,12 @@ func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("import not visible in analytics: %+v", dash.Totals)
|
||||
}
|
||||
}
|
||||
func mockClassifier(t *testing.T, a *App) {
|
||||
func mockClassifier(t *testing.T, a *App, inspect ...func(*http.Request)) {
|
||||
t.Helper()
|
||||
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, check := range inspect {
|
||||
check(r)
|
||||
}
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"finance-duck/internal/banking"
|
||||
"finance-duck/internal/domain"
|
||||
)
|
||||
|
||||
// Scope binds operational sessions to an application generation. It is committed
|
||||
// with credentials, so a crash before saving sync-state cannot revive old sessions.
|
||||
type bankingSettings struct {
|
||||
AppID string `json:"app_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (a *App) clearBankingSessions(scope string) {
|
||||
a.ops.Sessions = nil
|
||||
a.ops.Consents = make(map[string]Consent)
|
||||
a.ops.AccountSync = make(map[string]string)
|
||||
a.ops.LastSync = ""
|
||||
a.ops.SyncError = ""
|
||||
a.ops.BankingScope = scope
|
||||
}
|
||||
|
||||
func (a *App) loadBankingSettings() error {
|
||||
path := filepath.Join(a.dir, "state", "enablebanking.json")
|
||||
f, err := os.Open(path)
|
||||
var cfg bankingSettings
|
||||
var provider *banking.EnableBanking
|
||||
fromEnvironment := os.IsNotExist(err)
|
||||
if fromEnvironment {
|
||||
cfg.AppID = os.Getenv("ENABLEBANKING_APP_ID")
|
||||
cfg.RedirectURL = os.Getenv("ENABLEBANKING_REDIRECT_URL")
|
||||
keyFile := os.Getenv("ENABLEBANKING_KEY_FILE")
|
||||
cfg.Disabled = cfg.AppID == "" && cfg.RedirectURL == "" && keyFile == ""
|
||||
if !cfg.Disabled {
|
||||
if cfg.AppID == "" || cfg.RedirectURL == "" || keyFile == "" {
|
||||
return errors.New("Enable Banking requires APP_ID, KEY_FILE and REDIRECT_URL environment variables")
|
||||
}
|
||||
key, e := os.Open(keyFile)
|
||||
if e != nil {
|
||||
return errors.New("cannot read Enable Banking private key")
|
||||
}
|
||||
b, e := io.ReadAll(io.LimitReader(key, banking.MaxPrivateKeyPEM+1))
|
||||
key.Close()
|
||||
if e != nil {
|
||||
return errors.New("cannot read Enable Banking private key")
|
||||
}
|
||||
cfg.PrivateKey = string(b)
|
||||
}
|
||||
// A stable environment identity detects app-ID changes on restart while
|
||||
// retaining sessions through key or callback rotation of the same app.
|
||||
hash := sha256.Sum256([]byte(cfg.AppID))
|
||||
cfg.Scope = "env_" + hex.EncodeToString(hash[:])
|
||||
} else {
|
||||
if err != nil {
|
||||
return errors.New("cannot read saved Enable Banking settings")
|
||||
}
|
||||
defer f.Close()
|
||||
const limit = 256 * 1024 // Allows JSON escaping of a maximum-size PEM.
|
||||
b, e := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
invalid := errors.New("invalid saved Enable Banking settings")
|
||||
if e != nil || len(b) > limit {
|
||||
return invalid
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(b))
|
||||
if token, e := decoder.Token(); e != nil || token != json.Delim('{') {
|
||||
return invalid
|
||||
}
|
||||
seen := make(map[string]bool, 5)
|
||||
for decoder.More() {
|
||||
token, e := decoder.Token()
|
||||
name, ok := token.(string)
|
||||
if e != nil || !ok || seen[name] {
|
||||
return invalid
|
||||
}
|
||||
seen[name] = true
|
||||
value, e := decoder.Token()
|
||||
if e != nil {
|
||||
return invalid
|
||||
}
|
||||
if name == "disabled" {
|
||||
cfg.Disabled, ok = value.(bool)
|
||||
} else {
|
||||
var text string
|
||||
text, ok = value.(string)
|
||||
switch name {
|
||||
case "app_id":
|
||||
cfg.AppID = text
|
||||
case "redirect_url":
|
||||
cfg.RedirectURL = text
|
||||
case "private_key":
|
||||
cfg.PrivateKey = text
|
||||
case "scope":
|
||||
cfg.Scope = text
|
||||
default:
|
||||
return invalid
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return invalid
|
||||
}
|
||||
}
|
||||
if token, e := decoder.Token(); e != nil || token != json.Delim('}') {
|
||||
return invalid
|
||||
}
|
||||
if _, e := decoder.Token(); e != io.EOF || len(seen) != 5 || cfg.Scope == "" || len(cfg.Scope) > 256 {
|
||||
return invalid
|
||||
}
|
||||
if cfg.Disabled && (cfg.AppID != "" || cfg.RedirectURL != "" || cfg.PrivateKey != "") {
|
||||
return invalid
|
||||
}
|
||||
}
|
||||
if !cfg.Disabled {
|
||||
provider, err = banking.NewEnableBanking(cfg.AppID, []byte(cfg.PrivateKey), cfg.RedirectURL)
|
||||
if err != nil {
|
||||
return errors.New("invalid Enable Banking settings")
|
||||
}
|
||||
}
|
||||
if a.ops.BankingScope != cfg.Scope {
|
||||
if a.ops.BankingScope == "" && fromEnvironment && !cfg.Disabled {
|
||||
// Legacy sessions predate Settings and belong to the validated env app.
|
||||
a.ops.BankingScope = cfg.Scope
|
||||
} else {
|
||||
a.clearBankingSessions(cfg.Scope)
|
||||
}
|
||||
// Fail closed if legacy binding or mismatch invalidation cannot persist.
|
||||
if err = a.saveOps(); err != nil {
|
||||
return errors.New("cannot bind Enable Banking sessions")
|
||||
}
|
||||
}
|
||||
a.bankingSettings = cfg
|
||||
a.callbackURL = cfg.RedirectURL
|
||||
if provider != nil {
|
||||
a.bank = provider
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SaveBankingSettings(ctx context.Context, appID string, privateKey *string, redirectURL string) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
key := ""
|
||||
if privateKey != nil {
|
||||
key = *privateKey
|
||||
} else if !a.bankingSettings.Disabled && appID == a.bankingSettings.AppID {
|
||||
key = a.bankingSettings.PrivateKey
|
||||
}
|
||||
if key == "" {
|
||||
return State{}, errors.New("an Enable Banking private key is required for this application")
|
||||
}
|
||||
provider, err := banking.NewEnableBanking(appID, []byte(key), redirectURL)
|
||||
if err != nil {
|
||||
return State{}, err
|
||||
}
|
||||
cfg := bankingSettings{AppID: appID, PrivateKey: key, RedirectURL: redirectURL, Scope: a.bankingSettings.Scope}
|
||||
if a.bankingSettings.Disabled || appID != a.bankingSettings.AppID || cfg.Scope == "" {
|
||||
cfg.Scope = domain.NewID("bank")
|
||||
}
|
||||
return a.persistBankingSettings(ctx, cfg, provider)
|
||||
}
|
||||
|
||||
func (a *App) RemoveBankingSettings(ctx context.Context) (State, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.persistBankingSettings(ctx, bankingSettings{Disabled: true, Scope: domain.NewID("bank")}, nil)
|
||||
}
|
||||
|
||||
// Caller holds mu. Only the credential file must commit: an older sync-state
|
||||
// remains unusable because its scope differs. The next operational save or Open
|
||||
// writes the cleared sessions, without a fallible two-file transaction here.
|
||||
func (a *App) persistBankingSettings(ctx context.Context, cfg bankingSettings, provider *banking.EnableBanking) (State, error) {
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return State{}, errors.New("cannot encode Enable Banking settings")
|
||||
}
|
||||
if err = atomicFile(filepath.Join(a.dir, "state", "enablebanking.json"), append(b, '\n')); err != nil {
|
||||
return State{}, errors.New("cannot save Enable Banking settings")
|
||||
}
|
||||
if cfg.Scope != a.ops.BankingScope {
|
||||
a.clearBankingSessions(cfg.Scope)
|
||||
}
|
||||
a.authStates = make(map[string]authorization)
|
||||
a.bankingSettings = cfg
|
||||
a.callbackURL = cfg.RedirectURL
|
||||
a.bank = nil
|
||||
if provider != nil {
|
||||
a.bank = provider
|
||||
}
|
||||
return a.snapshot(ctx)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,13 @@ func (a *App) Balances(ctx context.Context, id string) ([]banking.Balance, error
|
||||
}
|
||||
for _, account := range s.Data.Accounts {
|
||||
if account.ID == id && account.ExternalAccountID != "" {
|
||||
return a.bank.Balances(ctx, account.ExternalAccountID)
|
||||
for _, session := range a.ops.Sessions {
|
||||
for _, linked := range session.Accounts {
|
||||
if linked.ID == account.ID && linked.ExternalAccountID == account.ExternalAccountID {
|
||||
return a.bank.Balances(ctx, linked.ExternalAccountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, errors.New("account is not connected")
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
|
||||
t.Helper()
|
||||
p, err := a.Preview(context.Background(), PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.CancelPreview(p.ID)
|
||||
if key == "" {
|
||||
if len(p.Changes) != 0 || len(p.Errors) != 2 {
|
||||
t.Fatal("disabled AI did not leave both transactions unclassified")
|
||||
}
|
||||
} else {
|
||||
if len(p.Errors) != 0 || len(p.Changes) != 2 {
|
||||
t.Fatalf("classification failed: %+v", p.Errors)
|
||||
}
|
||||
for _, change := range p.Changes {
|
||||
if change.After.CategoryID != "groceries" {
|
||||
t.Fatal("provider classification was not applied to the preview")
|
||||
}
|
||||
select {
|
||||
case got := <-auth:
|
||||
if got != "Bearer "+key {
|
||||
t.Fatal("provider received the wrong Authorization credential")
|
||||
}
|
||||
default:
|
||||
t.Fatal("classification did not reach the provider")
|
||||
}
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-auth:
|
||||
t.Fatal("unexpected provider request")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
auth := make(chan string, 8)
|
||||
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||
for _, key := range []string{"first-private-key", "replacement-private-key", ""} {
|
||||
var err error
|
||||
s, err = a.SaveOpenRouterKey(context.Background(), " \t"+key+"\r\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Status.AIConfigured != (key != "") {
|
||||
t.Fatal("credential status did not update immediately")
|
||||
}
|
||||
encoded, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "private-key") {
|
||||
t.Fatal("saved credential leaked into browser state")
|
||||
}
|
||||
checkOpenRouterPreview(t, a, s, auth, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
auth := make(chan string, 8)
|
||||
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||
dir, baseURL := a.dir, a.classifier.BaseURL
|
||||
t.Setenv("OPENROUTER_API_KEY", "environment-private-key")
|
||||
reopen := func() {
|
||||
t.Helper()
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
a, err = Open(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.classifier.BaseURL = baseURL
|
||||
s, err = a.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if a != nil {
|
||||
a.Close()
|
||||
}
|
||||
})
|
||||
reopen()
|
||||
checkOpenRouterPreview(t, a, s, auth, "environment-private-key")
|
||||
for _, key := range []string{"saved-private-key", ""} {
|
||||
var err error
|
||||
s, err = a.SaveOpenRouterKey(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "state", "openrouter.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("credential permissions: %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
reopen()
|
||||
if s.Status.AIConfigured != (key != "") {
|
||||
t.Fatal("restarted credential status ignored saved preference")
|
||||
}
|
||||
checkOpenRouterPreview(t, a, s, auth, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterMalformedStorageFailsClosedWithoutLeaking(t *testing.T) {
|
||||
t.Setenv("OPENROUTER_API_KEY", "environment-private-key")
|
||||
t.Setenv("ENABLEBANKING_APP_ID", "")
|
||||
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
||||
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
||||
for name, content := range map[string]string{
|
||||
"missing": `{}`,
|
||||
"null": `{"api_key":null}`,
|
||||
"wrong type": `{"api_key":123}`,
|
||||
"case variant": `{"API_KEY":"saved-private-key"}`,
|
||||
"unknown field": `{"api_key":"saved-private-key","extra":true}`,
|
||||
"duplicate": `{"api_key":"saved-private-key","api_key":""}`,
|
||||
"trailing JSON": `{"api_key":"saved-private-key"} {}`,
|
||||
"malformed": `{"api_key":"saved-private-key`,
|
||||
"control byte": `{"api_key":"saved-private-key\u0000"}`,
|
||||
"oversized": `{"api_key":"` + strings.Repeat("k", 4097) + `"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "state"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "state", "openrouter.json"), []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := Open(dir)
|
||||
if err == nil {
|
||||
a.Close()
|
||||
t.Fatal("malformed credential silently fell back to environment")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) {
|
||||
t.Fatal("startup error leaked credential content")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
auth := make(chan string, 8)
|
||||
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||
key := strings.Repeat("k", 4096)
|
||||
s, err := a.SaveOpenRouterKey(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatal("maximum-size key was rejected")
|
||||
}
|
||||
for name, invalid := range map[string]string{
|
||||
"too long": key + "k",
|
||||
"internal whitespace": "private-key value",
|
||||
"control byte": "private-key\x00",
|
||||
"DEL": "private-key\x7f",
|
||||
"non ASCII": "private-key\u00e9",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := a.SaveOpenRouterKey(context.Background(), invalid)
|
||||
if err == nil {
|
||||
t.Fatal("invalid credential was accepted")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private-key") || strings.Contains(err.Error(), strings.Repeat("k", 20)) {
|
||||
t.Fatal("validation error leaked credential content")
|
||||
}
|
||||
})
|
||||
}
|
||||
checkOpenRouterPreview(t, a, s, auth, key)
|
||||
}
|
||||
|
||||
func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
|
||||
a, s := testApp(t)
|
||||
s = seed(t, a, s)
|
||||
auth := make(chan string, 8)
|
||||
mockClassifier(t, a, func(r *http.Request) { auth <- r.Header.Get("Authorization") })
|
||||
s, err := a.SaveOpenRouterKey(context.Background(), "active-private-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(a.dir, "state", "openrouter.json")
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A directory at the destination makes atomic rename fail even as root.
|
||||
if err := os.Mkdir(path, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"replacement-private-key", ""} {
|
||||
_, err := a.SaveOpenRouterKey(context.Background(), key)
|
||||
if err == nil {
|
||||
t.Fatal("credential save unexpectedly succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), "private-key") {
|
||||
t.Fatal("persistence error leaked credential content")
|
||||
}
|
||||
}
|
||||
checkOpenRouterPreview(t, a, s, auth, "active-private-key")
|
||||
}
|
||||
Reference in New Issue
Block a user