Analyse now classifies up to ten same-kind transactions per provider request: the registry and history travel once per batch, so a thousand-row backfill costs about a hundred paced requests instead of a thousand. The answer schema appears once — an array item carrying an enum-bound ref — because providers meter strict schemas by token cost: duplicating registry enums per row, or bounding arrays with minItems/maxItems that Gemini expands per element, rejects real registries with a bare HTTP 400. Row count, duplicate refs, duplicate tags and taxonomy bounds are all enforced server-side instead, and a request still rejected outright halves until accepted, remembering the working size for the run. Batch requests scale the HTTP budget by row count, chunk failures cannot abort a run whose later rows succeeded, and rows resolved against one snapshot share one minted merchant. Measured on a real 165-row month over a zero-data-retention route: 165 analysed, 152 proposals, 0 errors, 17 requests, under 8 minutes. Fresh installs default to google/gemini-3.8-flash, the model that demonstrably honors strict structured outputs over a ZDR route. Preview changes now carry counterparty, amount and currency, and the review list shows the amount with a counterparty fallback for banks that leave descriptions empty.
221 lines
6.6 KiB
Go
221 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 := 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")
|
|
}
|
|
}
|
|
// Both rows share one kind, so the whole preview is one batch request.
|
|
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")
|
|
}
|