Files
finance-duck/internal/app/openrouter_test.go
T

220 lines
6.6 KiB
Go

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