Add native NixOS deployment and UI-managed provider credentials
This commit is contained in:
@@ -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