757 lines
32 KiB
Go
757 lines
32 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"finance-duck/internal/analytics"
|
|
"finance-duck/internal/app"
|
|
"finance-duck/internal/banking"
|
|
"finance-duck/internal/domain"
|
|
)
|
|
|
|
func TestOriginAndHostGuardProtectNoLoginService(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
h, err := New(a, fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<!doctype html><title>Finance</title>")}}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct {
|
|
name, host, origin, content string
|
|
want int
|
|
}{{"rebound host", "attacker.example", "", "application/json", 403}, {"cross origin", "localhost:8080", "https://attacker.example", "application/json", 403}, {"simple form CSRF", "localhost:8080", "", "text/plain", 415}, {"valid local mutation", "localhost:8080", "http://localhost:8080", "application/json", 200}}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/settings", strings.NewReader(`{"model":"example/model"}`))
|
|
r.Host = tt.host
|
|
r.Header.Set("Content-Type", tt.content)
|
|
r.Header.Set("Origin", tt.origin)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != tt.want {
|
|
t.Fatalf("got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
r := httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
var s app.State
|
|
if err = json.NewDecoder(w.Body).Decode(&s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.Settings.Model != "example/model" {
|
|
t.Fatal("same-origin edit not persisted")
|
|
}
|
|
r = httptest.NewRequest(http.MethodGet, "http://localhost:8080/", nil)
|
|
w = httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
b, _ := io.ReadAll(w.Body)
|
|
if w.Code != 200 || !strings.Contains(string(b), "<!doctype html>") {
|
|
t.Fatalf("UI not served: %d %s", w.Code, b)
|
|
}
|
|
}
|
|
|
|
func TestOpenRouterKeyIsWriteOnlyAndRequiresExplicitRemoval(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
h, err := New(a, fstest.MapFS{}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const secret = "test-openrouter-private-key"
|
|
check := func(method, path, body, origin string, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest(method, "http://localhost:8080"+path, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Origin", origin)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if strings.Contains(w.Body.String(), secret) {
|
|
t.Fatal("credential leaked in HTTP response")
|
|
}
|
|
if w.Code != want {
|
|
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
|
}
|
|
return w
|
|
}
|
|
configured := func(w *httptest.ResponseRecorder, want bool) {
|
|
t.Helper()
|
|
var state app.State
|
|
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.Status.AIConfigured != want {
|
|
t.Fatalf("configured = %t, want %t", state.Status.AIConfigured, want)
|
|
}
|
|
}
|
|
const endpoint = "/api/settings/openrouter"
|
|
const origin = "http://localhost:8080"
|
|
keyJSON := `{"api_key":"` + secret + `"}`
|
|
check("POST", endpoint, keyJSON, "https://attacker.example", http.StatusForbidden)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
|
configured(check("POST", endpoint, keyJSON, origin, http.StatusOK), true)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
|
// Ordinary preference updates must not implicitly erase credentials.
|
|
configured(check("POST", "/api/settings", `{"model":"example/model"}`, origin, http.StatusOK), true)
|
|
for _, body := range []string{
|
|
`{}`,
|
|
`{"api_key":null}`,
|
|
`{"api_key":["` + secret + `"]}`,
|
|
`{"` + secret + `":"unexpected field"}`,
|
|
keyJSON + `{}`,
|
|
`{"api_key":"` + secret + `\ninvalid"}`,
|
|
} {
|
|
check("POST", endpoint, body, origin, http.StatusBadRequest)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
|
}
|
|
configured(check("POST", endpoint, `{"api_key":""}`, origin, http.StatusOK), false)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
|
}
|
|
|
|
func TestBankingConfigurationProtectsPrivateKeyAndCallbackOrigin(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
const origin = "https://finance.internal:8444"
|
|
const callback = origin + "/api/banking/callback"
|
|
const endpoint = "/api/settings/enablebanking"
|
|
h, err := New(a, fstest.MapFS{}, origin)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
|
|
secretLine := strings.Split(keyPEM, "\n")[1]
|
|
payload := func(v any) string {
|
|
t.Helper()
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
check := func(method, path, body, requestOrigin string, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
// The reverse-proxy hop is HTTP; public Origin and callback are HTTPS.
|
|
r := httptest.NewRequest(method, "http://finance.internal:8444"+path, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
r.Header.Set("Origin", requestOrigin)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if strings.Contains(w.Body.String(), secretLine) || strings.Contains(w.Body.String(), "PRIVATE KEY") {
|
|
t.Fatal("private key leaked in banking response")
|
|
}
|
|
if w.Code != want {
|
|
t.Fatalf("%s %s: got %d, want %d: %s", method, path, w.Code, want, w.Body.String())
|
|
}
|
|
return w
|
|
}
|
|
configured := func(w *httptest.ResponseRecorder, want bool) {
|
|
t.Helper()
|
|
var state app.State
|
|
if err := json.Unmarshal(w.Body.Bytes(), &state); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state.Status.BankingConfigured != want {
|
|
t.Fatalf("banking configured = %t, want %t", state.Status.BankingConfigured, want)
|
|
}
|
|
if want && (state.BankingAppID != "bank-app" || state.CallbackURL != callback) {
|
|
t.Fatal("saved application metadata is not available to the UI")
|
|
}
|
|
}
|
|
save := payload(map[string]any{"app_id": "bank-app", "private_key": keyPEM, "redirect_url": callback})
|
|
check("POST", endpoint, save, "https://attacker.example", http.StatusForbidden)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
|
configured(check("POST", endpoint, save, origin, http.StatusOK), true)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
|
// A callback correction can retain the current signing key.
|
|
configured(check("POST", endpoint, payload(map[string]any{"app_id": "bank-app", "private_key": nil, "redirect_url": callback}), origin, http.StatusOK), true)
|
|
for _, body := range []string{
|
|
`{}`,
|
|
payload(map[string]any{"app_id": "bank-app", "redirect_url": "https://attacker.example/api/banking/callback"}),
|
|
payload(map[string]any{"app_id": "bank-app", "redirect_url": "http://finance.internal:8444/api/banking/callback"}),
|
|
payload(map[string]any{"app_id": "different-app", "private_key": nil, "redirect_url": callback}),
|
|
payload(map[string]any{"app_id": "", "private_key": "", "redirect_url": callback}),
|
|
payload(map[string]any{"remove": true, "private_key": keyPEM}),
|
|
payload(map[string]any{secretLine: "unknown field"}),
|
|
save + `{}`,
|
|
} {
|
|
check("POST", endpoint, body, origin, http.StatusBadRequest)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), true)
|
|
}
|
|
configured(check("POST", endpoint, `{"remove":true}`, origin, http.StatusOK), false)
|
|
configured(check("GET", "/api/state", "", origin, http.StatusOK), false)
|
|
}
|
|
|
|
type psuRoundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f psuRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
return f(r)
|
|
}
|
|
|
|
func TestManualBankContextUsesOnlyTrustedPeerAndBrowserMetadata(t *testing.T) {
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
|
origin, err := url.Parse("https://finance.internal")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tt := range []struct {
|
|
name string
|
|
public bool
|
|
peer string
|
|
forwarded []string
|
|
wantIP string
|
|
userAgent string
|
|
}{
|
|
{"direct rejects forwarding", false, "198.51.100.8:1234", []string{"192.0.2.99"}, "198.51.100.8", "real-browser"},
|
|
{"untrusted remote proxy", true, "198.51.100.8:1234", []string{"192.0.2.99"}, "198.51.100.8", "real-browser"},
|
|
{"unconfigured loopback", false, "127.0.0.1:1234", []string{"192.0.2.99"}, "127.0.0.1", "real-browser"},
|
|
{"trusted appended hop", true, "127.0.0.1:1234", []string{"192.0.2.99, 203.0.113.42"}, "203.0.113.42", "real-browser"},
|
|
{"last header appended hop", true, "[::1]:1234", []string{"192.0.2.99", "203.0.113.42"}, "203.0.113.42", "real-browser"},
|
|
{"IPv6 client", true, "[::1]:1234", []string{"192.0.2.99, 2001:db8::42"}, "2001:db8::42", "real-browser"},
|
|
{"missing trusted hop", true, "127.0.0.1:1234", nil, "", "real-browser"},
|
|
{"invalid appended hop not leading spoof", true, "127.0.0.1:1234", []string{"192.0.2.99, invalid"}, "", "real-browser"},
|
|
{"unknown peer and absent user agent", false, "invalid", []string{"192.0.2.99"}, "", ""},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := &Server{}
|
|
if tt.public {
|
|
s.origin = origin
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "https://finance.internal/api/backfill?secret=private", strings.NewReader(`{}`))
|
|
r.RemoteAddr = tt.peer
|
|
r.Header["X-Forwarded-For"] = tt.forwarded
|
|
r.Header.Set("User-Agent", tt.userAgent)
|
|
r.Header.Set("Accept", "application/json")
|
|
r.Header.Set("Accept-Charset", "utf-8")
|
|
r.Header.Set("Accept-Encoding", "gzip, br")
|
|
r.Header.Set("Accept-Language", "de-DE")
|
|
for _, name := range []string{"Cookie", "Authorization", "Referer", "Psu-Ip-Address", "Psu-User-Agent", "Psu-Referer", "Psu-Geo-Location", "Psu-Cookie"} {
|
|
r.Header.Set(name, "private-spoofed-value")
|
|
}
|
|
p, err := banking.NewEnableBanking("test-app", keyPEM, "https://finance.internal/api/banking/callback")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
called := false
|
|
p.HTTPClient = &http.Client{Transport: psuRoundTripFunc(func(out *http.Request) (*http.Response, error) {
|
|
called = true
|
|
want := map[string]string{
|
|
"Psu-Ip-Address": tt.wantIP, "Psu-User-Agent": tt.userAgent,
|
|
"Psu-Accept": "application/json", "Psu-Accept-Charset": "utf-8",
|
|
"Psu-Accept-Encoding": "gzip, br", "Psu-Accept-Language": "de-DE",
|
|
}
|
|
for name, value := range want {
|
|
if got := out.Header.Get(name); got != value {
|
|
t.Errorf("%s = %q, want %q", name, got, value)
|
|
}
|
|
}
|
|
for name, values := range out.Header {
|
|
if strings.HasPrefix(strings.ToLower(name), "psu-") {
|
|
if _, allowed := want[name]; !allowed {
|
|
t.Errorf("unexpected PSU header %s", name)
|
|
}
|
|
}
|
|
if strings.Contains(strings.Join(values, ","), "private") {
|
|
t.Errorf("secret request metadata leaked in %s", name)
|
|
}
|
|
}
|
|
if out.URL.RawQuery != "" || out.Header.Get("Cookie") != "" || out.Header.Get("Referer") != "" {
|
|
t.Error("request URL or secret headers copied to bank")
|
|
}
|
|
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"balances":[]}`))}, nil
|
|
})}
|
|
if _, err := p.Balances(s.manualBankContext(r), "uid"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !called {
|
|
t.Fatal("manual retrieval never reached bank transport")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The whole import must travel through prepare -> review -> confirm over HTTP,
|
|
// and only the multipart upload may bypass the JSON content-type CSRF guard.
|
|
func TestCSVImportOverHTTPImportsOnlyAfterConfirmation(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
h, err := New(a, fstest.MapFS{}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const origin = "http://localhost:8080"
|
|
send := func(path, contentType, body, origin string, want int) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080"+path, strings.NewReader(body))
|
|
r.Header.Set("Content-Type", contentType)
|
|
r.Header.Set("Origin", origin)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != want {
|
|
t.Fatalf("POST %s: got %d, want %d: %s", path, w.Code, want, w.Body.String())
|
|
}
|
|
return w
|
|
}
|
|
state := func(w *httptest.ResponseRecorder) app.State {
|
|
t.Helper()
|
|
var s app.State
|
|
if err := json.Unmarshal(w.Body.Bytes(), &s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
get := func(path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080"+path, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET %s: got %d: %s", path, w.Code, w.Body.String())
|
|
}
|
|
return w
|
|
}
|
|
account := `{"revision":"` + state(get("/api/state")).Revision + `","account":{"display_name":"ING","institution":"ING","currency":"EUR","active":true}}`
|
|
current := state(send("/api/accounts", "application/json", account, origin, http.StatusOK))
|
|
if len(current.Data.Accounts) != 1 {
|
|
t.Fatalf("account was not created: %+v", current.Data.Accounts)
|
|
}
|
|
statement := "Buchung;Wertstellungsdatum;Auftraggeber/Empfänger;Buchungstext;Verwendungszweck;Betrag;Währung\n" +
|
|
"09.12.2025;09.12.2025;VISA Firma;Lastschrift;NR XXXX 4025 KAUFUMSATZ;-13,98;EUR\n" +
|
|
"28.11.2025;28.11.2025;Rente;Gehalt/Rente;RV-RENTE 11.2025;2.647,74;EUR\n"
|
|
upload := func(revision, accountID string) (string, string) {
|
|
t.Helper()
|
|
var body strings.Builder
|
|
form := multipart.NewWriter(&body)
|
|
for name, value := range map[string]string{"revision": revision, "account_id": accountID} {
|
|
if err := form.WriteField(name, value); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
file, err := form.CreateFormFile("file", "umsatzanzeige.csv")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := io.WriteString(file, statement); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := form.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return form.FormDataContentType(), body.String()
|
|
}
|
|
contentType, body := upload(current.Revision, current.Data.Accounts[0].ID)
|
|
// A multipart upload from another origin is still refused.
|
|
send("/api/import/prepare", contentType, body, "https://attacker.example", http.StatusForbidden)
|
|
var prepared app.CSVImport
|
|
if err := json.Unmarshal(send("/api/import/prepare", contentType, body, origin, http.StatusOK).Body.Bytes(), &prepared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if prepared.SourceLabel != "ING" || prepared.MappedBy != "preset" || prepared.New != 2 || len(prepared.Samples) != 2 {
|
|
t.Fatalf("unexpected prepared import: %+v", prepared)
|
|
}
|
|
if transactions := state(get("/api/state")).Data.Transactions; len(transactions) != 0 {
|
|
t.Fatalf("preparing an import wrote %d transactions", len(transactions))
|
|
}
|
|
// Confirmation is an ordinary JSON mutation, guarded like every other one.
|
|
confirm := `{"id":"` + prepared.ID + `","revision":"` + prepared.Revision + `"}`
|
|
send("/api/import/confirm", "text/plain", confirm, origin, http.StatusUnsupportedMediaType)
|
|
send("/api/import/cancel", "text/plain", `{"id":"`+prepared.ID+`"}`, origin, http.StatusUnsupportedMediaType)
|
|
var result app.ImportResult
|
|
if err := json.Unmarshal(send("/api/import/confirm", "application/json", confirm, origin, http.StatusOK).Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Imported != 2 || len(result.State.Data.Transactions) != 2 {
|
|
t.Fatalf("confirmation did not import the reviewed statement: %+v", result)
|
|
}
|
|
for _, tx := range result.State.Data.Transactions {
|
|
if tx.Facts.Source != "ing_csv" || tx.Facts.Currency != "EUR" {
|
|
t.Fatalf("unexpected imported facts: %+v", tx.Facts)
|
|
}
|
|
}
|
|
send("/api/import/confirm", "application/json", confirm, origin, http.StatusBadRequest)
|
|
}
|
|
|
|
func TestDashboardRepeatedTagFiltersOverHTTP(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
state, err := a.Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = a.Mutate(context.Background(), state.Revision, func(data *domain.Dataset) error {
|
|
data.Accounts = append(data.Accounts, domain.Account{ID: "acc_eur", DisplayName: "Current", Currency: "EUR", Active: true})
|
|
data.Tags = append(data.Tags, domain.Tag{ID: "tag_shared", Name: "Shared"}, domain.Tag{ID: "tag_work", Name: "Work"})
|
|
for _, item := range []struct {
|
|
id string
|
|
amount domain.Money
|
|
kind string
|
|
category string
|
|
tags []string
|
|
}{
|
|
{"tx_both", "-10.0000", "expense", domain.ExpenseFallback, []string{"tag_shared", "tag_work"}},
|
|
{"tx_work", "-20.0000", "expense", domain.ExpenseFallback, []string{"tag_work"}},
|
|
{"tx_income", "100.0000", "income", domain.IncomeFallback, []string{}},
|
|
} {
|
|
data.Transactions = append(data.Transactions, domain.Transaction{
|
|
Facts: domain.Facts{ID: item.id, Source: "test", AccountID: "acc_eur", BookingDate: "2026-02-10",
|
|
Amount: item.amount, Currency: "EUR", RawDescription: item.id, Fingerprint: item.id},
|
|
Enrichment: domain.Enrichment{Kind: item.kind, CategoryID: item.category, TagIDs: item.tags},
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := New(a, fstest.MapFS{}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
include []string
|
|
exclude []string
|
|
want []analytics.Total
|
|
}{
|
|
{
|
|
name: "repeated includes use union without duplication",
|
|
include: []string{"tag_shared", "tag_work"},
|
|
want: []analytics.Total{{Currency: "EUR", Expenses: "30.0000", Income: "0.0000", Net: "-30.0000"}},
|
|
},
|
|
{
|
|
name: "repeated exclusions preserve untagged income",
|
|
exclude: []string{"tag_shared", "tag_work"},
|
|
want: []analytics.Total{{Currency: "EUR", Expenses: "0.0000", Income: "100.0000", Net: "100.0000"}},
|
|
},
|
|
{
|
|
name: "include and exclude compose with exclusion winning",
|
|
include: []string{"tag_shared", "tag_work"},
|
|
exclude: []string{"tag_missing", "tag_shared"},
|
|
want: []analytics.Total{{Currency: "EUR", Expenses: "20.0000", Income: "0.0000", Net: "-20.0000"}},
|
|
},
|
|
{
|
|
name: "comma separated values are not a list",
|
|
include: []string{"tag_shared,tag_work"},
|
|
want: []analytics.Total{},
|
|
},
|
|
}
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
query := url.Values{"from": {"2026-02-01"}, "to": {"2026-02-28"}, "currency": {"EUR"}}
|
|
for _, id := range tt.include {
|
|
query.Add("tag_ids", id)
|
|
}
|
|
for _, id := range tt.exclude {
|
|
query.Add("exclude_tag_ids", id)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/dashboard?"+query.Encode(), nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET dashboard: %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var got analytics.Dashboard
|
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !reflect.DeepEqual(got.Totals, tt.want) {
|
|
t.Fatalf("totals: got %#v, want %#v", got.Totals, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTransactionsBulkOverHTTP(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "")
|
|
t.Setenv("ENABLEBANKING_APP_ID", "")
|
|
t.Setenv("ENABLEBANKING_KEY_FILE", "")
|
|
t.Setenv("ENABLEBANKING_REDIRECT_URL", "")
|
|
a, err := app.Open(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer a.Close()
|
|
state, err := a.Snapshot(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = a.Mutate(context.Background(), state.Revision, func(d *domain.Dataset) error {
|
|
d.Accounts = []domain.Account{
|
|
{ID: "acc_current", DisplayName: "Current", Currency: "EUR", Active: true},
|
|
{ID: "acc_savings", DisplayName: "Savings", Currency: "EUR", Active: true},
|
|
{ID: "acc_broker", DisplayName: "Broker", Currency: "EUR", Kind: domain.AccountInvestment, Active: true},
|
|
}
|
|
d.Categories = append(d.Categories, domain.Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"})
|
|
d.Tags = []domain.Tag{{ID: "tag_keep", Name: "Keep"}, {ID: "tag_remove", Name: "Remove"}, {ID: "tag_add", Name: "Add"}, {ID: "tag_absent", Name: "Absent"}}
|
|
d.Merchants = []domain.Merchant{{ID: "mer_old", Name: "Previous merchant"}, {ID: "mer_new", Name: "New merchant"}}
|
|
for _, item := range []struct {
|
|
id, account, kind, category, merchant, peer, counterparty string
|
|
amount domain.Money
|
|
tags []string
|
|
investment *domain.Investment
|
|
}{
|
|
{"tx_a", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Corner Bakery", "-10.0000", []string{"tag_keep", "tag_remove"}, nil},
|
|
{"tx_b", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Market Hall", "-20.0000", []string{"tag_add", "tag_keep"}, nil},
|
|
{"tx_untouched", "acc_current", "expense", domain.ExpenseFallback, "mer_old", "", "Station Kiosk", "-3.0000", []string{"tag_remove"}, nil},
|
|
{"tx_income", "acc_current", "income", domain.IncomeFallback, "", "", "Employer", "100.0000", []string{"tag_remove"}, nil},
|
|
{"tx_out", "acc_current", "transfer", "", "", "tx_in", "Savings", "-25.0000", []string{"tag_keep"}, nil},
|
|
{"tx_in", "acc_savings", "transfer", "", "", "tx_out", "Current", "25.0000", []string{}, nil},
|
|
{"tx_investment", "acc_broker", domain.KindInvestment, "", "", "", "Deposit", "30.0000", []string{"tag_keep"}, &domain.Investment{Event: domain.EventDeposit}},
|
|
} {
|
|
d.Transactions = append(d.Transactions, domain.Transaction{
|
|
Facts: domain.Facts{
|
|
ID: item.id, Source: "test", AccountID: item.account, BookingDate: "2026-02-10", ValueDate: "2026-02-11",
|
|
Amount: item.amount, Currency: "EUR", RawDescription: "Bank description " + item.id,
|
|
ExternalID: "external_" + item.id, Fingerprint: item.id, Counterparty: item.counterparty,
|
|
CounterpartyIBAN: "DE89370400440532013000", Investment: item.investment,
|
|
},
|
|
Enrichment: domain.Enrichment{
|
|
Kind: item.kind, CategoryID: item.category, MerchantID: item.merchant, TagIDs: item.tags,
|
|
TransferPeerID: item.peer, Classification: domain.Provenance{Source: "rules"},
|
|
},
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := New(a, fstest.MapFS{}, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
snapshot := func() app.State {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "http://localhost:8080/api/state", nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET state: %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var result app.State
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result
|
|
}
|
|
post := func(body map[string]any, status int) app.State {
|
|
t.Helper()
|
|
if _, ok := body["revision"]; !ok {
|
|
body["revision"] = snapshot().Revision
|
|
}
|
|
raw, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := httptest.NewRequest(http.MethodPost, "http://localhost:8080/api/transactions/bulk", strings.NewReader(string(raw)))
|
|
r.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != status {
|
|
t.Fatalf("POST bulk: got %d, want %d: %s", w.Code, status, w.Body.String())
|
|
}
|
|
var result app.State
|
|
if status == http.StatusOK {
|
|
if err := json.NewDecoder(w.Body).Decode(&result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
persisted := snapshot()
|
|
if result.Revision != persisted.Revision || !reflect.DeepEqual(result.Data, persisted.Data) {
|
|
t.Fatal("bulk response differs from persisted state")
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
transaction := func(s app.State, id string) domain.Transaction {
|
|
t.Helper()
|
|
for _, tx := range s.Data.Transactions {
|
|
if tx.Facts.ID == id {
|
|
return tx
|
|
}
|
|
}
|
|
t.Fatalf("missing transaction %s", id)
|
|
return domain.Transaction{}
|
|
}
|
|
for _, tt := range []struct {
|
|
name string
|
|
body map[string]any
|
|
}{
|
|
{"empty selection", map[string]any{"transaction_ids": []string{}, "add_tag_ids": []string{"tag_add"}}},
|
|
{"empty transaction ID", map[string]any{"transaction_ids": []string{"tx_a", ""}, "add_tag_ids": []string{"tag_add"}}},
|
|
{"duplicate transaction ID", map[string]any{"transaction_ids": []string{"tx_a", "tx_a"}, "add_tag_ids": []string{"tag_add"}}},
|
|
{"missing transaction rolls back category merchant tags and aliases", map[string]any{"transaction_ids": []string{"tx_a", "tx_missing"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
|
|
{"no operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{}, "remove_tag_ids": []string{}}},
|
|
{"unknown added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_missing"}}},
|
|
{"unknown removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_missing"}}},
|
|
{"duplicate added tag", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add", "tag_add"}}},
|
|
{"duplicate removed tag", map[string]any{"transaction_ids": []string{"tx_a"}, "remove_tag_ids": []string{"tag_remove", "tag_remove"}}},
|
|
{"overlapping tag operations", map[string]any{"transaction_ids": []string{"tx_a"}, "add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_add"}}},
|
|
{"nonleaf category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_expenses", "add_tag_ids": []string{"tag_add"}}},
|
|
{"unknown category", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_missing", "merchant_id": "mer_new"}},
|
|
{"category cannot be cleared", map[string]any{"transaction_ids": []string{"tx_a"}, "category_id": ""}},
|
|
{"incompatible category rolls back entire batch", map[string]any{"transaction_ids": []string{"tx_a", "tx_income"}, "category_id": "cat_food", "merchant_id": "mer_new", "add_tag_ids": []string{"tag_add"}}},
|
|
{"unknown merchant", map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": "mer_missing"}},
|
|
{"transfer category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "category_id": "cat_food"}},
|
|
{"transfer merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_out"}, "merchant_id": ""}},
|
|
{"investment category edit", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "category_id": "cat_food"}},
|
|
{"investment merchant clear", map[string]any{"transaction_ids": []string{"tx_a", "tx_investment"}, "merchant_id": ""}},
|
|
{"bank facts cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "amount": "1.0000", "add_tag_ids": []string{"tag_add"}}},
|
|
{"kind cannot be edited", map[string]any{"transaction_ids": []string{"tx_a"}, "kind": "income", "add_tag_ids": []string{"tag_add"}}},
|
|
{"transfer links cannot be edited", map[string]any{"transaction_ids": []string{"tx_out"}, "transfer_peer_id": "", "add_tag_ids": []string{"tag_add"}}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
before := snapshot()
|
|
post(tt.body, http.StatusBadRequest)
|
|
after := snapshot()
|
|
if before.Revision != after.Revision || !reflect.DeepEqual(before.Data, after.Data) {
|
|
t.Fatal("rejected batch changed persisted data or revision")
|
|
}
|
|
})
|
|
}
|
|
t.Run("multi row edit preserves facts unrelated tags and unselected rows", func(t *testing.T) {
|
|
before := snapshot()
|
|
after := post(map[string]any{
|
|
"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": "cat_food", "merchant_id": "mer_new",
|
|
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove", "tag_absent"},
|
|
}, http.StatusOK)
|
|
for _, old := range before.Data.Transactions {
|
|
got := transaction(after, old.Facts.ID)
|
|
if old.Facts.ID != "tx_a" && old.Facts.ID != "tx_b" {
|
|
if !reflect.DeepEqual(old, got) {
|
|
t.Fatalf("unselected transaction changed: %s", old.Facts.ID)
|
|
}
|
|
continue
|
|
}
|
|
tags := slices.Clone(got.Enrichment.TagIDs)
|
|
slices.Sort(tags)
|
|
if !reflect.DeepEqual(tags, []string{"tag_add", "tag_keep"}) || got.Enrichment.CategoryID != "cat_food" || got.Enrichment.MerchantID != "mer_new" {
|
|
t.Fatalf("bulk changes not applied: %+v", got.Enrichment)
|
|
}
|
|
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind || got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID {
|
|
t.Fatalf("immutable transaction fields changed: %s", old.Facts.ID)
|
|
}
|
|
if got.Enrichment.Classification.Source != "manual" {
|
|
t.Fatalf("missing manual provenance: %+v", got.Enrichment.Classification)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, got.Enrichment.Classification.Timestamp); err != nil {
|
|
t.Fatalf("invalid manual timestamp: %v", err)
|
|
}
|
|
}
|
|
for _, merchant := range after.Data.Merchants {
|
|
if merchant.ID == "mer_new" && (!slices.Contains(merchant.Aliases, "Corner Bakery") || !slices.Contains(merchant.Aliases, "Market Hall")) {
|
|
t.Fatalf("explicit merchant assignment did not learn aliases: %+v", merchant)
|
|
}
|
|
}
|
|
post(map[string]any{"revision": before.Revision, "transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusConflict)
|
|
unchanged := snapshot()
|
|
if unchanged.Revision != after.Revision || !reflect.DeepEqual(unchanged.Data, after.Data) {
|
|
t.Fatal("stale batch overwrote the successful edit")
|
|
}
|
|
})
|
|
t.Run("tag-only edits preserve individual categories merchants and transfer links", func(t *testing.T) {
|
|
before := snapshot()
|
|
after := post(map[string]any{
|
|
"transaction_ids": []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"},
|
|
"add_tag_ids": []string{"tag_add"}, "remove_tag_ids": []string{"tag_remove"},
|
|
}, http.StatusOK)
|
|
for _, id := range []string{"tx_a", "tx_untouched", "tx_income", "tx_out", "tx_investment"} {
|
|
old, got := transaction(before, id), transaction(after, id)
|
|
if !slices.Contains(got.Enrichment.TagIDs, "tag_add") || slices.Contains(got.Enrichment.TagIDs, "tag_remove") {
|
|
t.Fatalf("tags not updated on %s: %+v", id, got.Enrichment)
|
|
}
|
|
if id == "tx_out" || id == "tx_investment" {
|
|
if !slices.Contains(got.Enrichment.TagIDs, "tag_keep") {
|
|
t.Fatalf("unrelated tag removed from %s", id)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(old.Facts, got.Facts) || got.Enrichment.Kind != old.Enrichment.Kind ||
|
|
got.Enrichment.CategoryID != old.Enrichment.CategoryID || got.Enrichment.MerchantID != old.Enrichment.MerchantID ||
|
|
got.Enrichment.TransferPeerID != old.Enrichment.TransferPeerID || got.Enrichment.Classification.Source != "manual" {
|
|
t.Fatalf("tag edit changed other fields or omitted manual provenance on %s: %+v", id, got)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(transaction(before, "tx_in"), transaction(after, "tx_in")) {
|
|
t.Fatal("tag edit changed unselected transfer counterpart")
|
|
}
|
|
if !reflect.DeepEqual(before.Data.Merchants, after.Data.Merchants) {
|
|
t.Fatal("tag-only edits learned merchant aliases")
|
|
}
|
|
})
|
|
t.Run("merchant clearing preserves category and tags and fallback remains selectable", func(t *testing.T) {
|
|
before := snapshot()
|
|
cleared := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "merchant_id": ""}, http.StatusOK)
|
|
for _, id := range []string{"tx_a", "tx_b"} {
|
|
old, got := transaction(before, id), transaction(cleared, id)
|
|
if got.Enrichment.MerchantID != "" || old.Enrichment.CategoryID != got.Enrichment.CategoryID ||
|
|
!reflect.DeepEqual(old.Enrichment.TagIDs, got.Enrichment.TagIDs) || !reflect.DeepEqual(old.Facts, got.Facts) {
|
|
t.Fatalf("merchant clear changed unrelated fields: %+v", got)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(before.Data.Merchants, cleared.Data.Merchants) {
|
|
t.Fatal("merchant clearing changed aliases")
|
|
}
|
|
fallback := post(map[string]any{"transaction_ids": []string{"tx_a", "tx_b"}, "category_id": domain.ExpenseFallback}, http.StatusOK)
|
|
for _, id := range []string{"tx_a", "tx_b"} {
|
|
if transaction(fallback, id).Enrichment.CategoryID != domain.ExpenseFallback {
|
|
t.Fatalf("fallback category was not assigned to %s", id)
|
|
}
|
|
}
|
|
})
|
|
}
|