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 := runPreview(t, a, 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") }