Security review follow-ups. The 200-character registry-name cap the UI forms promise now holds in domain.Validate for categories, tags, merchants and instruments, so a non-browser client cannot persist an unbounded name that every subsequent state response would carry. And a model-supplied merchant or taxonomy name containing control or format code points — bidi overrides, zero-width characters — is dropped like an identifier-shaped one: React escaping already prevented injection, but such names could visually spoof or reorder the review UI the operator approves from.
168 lines
7.5 KiB
Go
168 lines
7.5 KiB
Go
package domain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"math"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func sampleDataset() Dataset {
|
|
d := NewDataset()
|
|
d.Accounts = []Account{{ID: "acc_main", DisplayName: "Main", Currency: "EUR", Active: true}, {ID: "acc_save", DisplayName: "Savings", Currency: "EUR", Active: true}}
|
|
d.Categories = append(d.Categories, Category{ID: "cat_food", Name: "Food", ParentID: "cat_expenses", Kind: "expense"}, Category{ID: "cat_grocery", Name: "Groceries", ParentID: "cat_food", Kind: "expense"})
|
|
d.Tags = []Tag{{ID: "tag_shared", Name: "Shared"}}
|
|
d.Merchants = []Merchant{{ID: "mer_shop", Name: "Shop", Aliases: []string{"Shop GmbH"}, DefaultCategoryID: "cat_grocery", DefaultTagIDs: []string{"tag_shared"}, UseDefaults: true}}
|
|
f := Facts{ID: "tx_one", Source: "csv", AccountID: "acc_main", BookingDate: "2026-01-01", Amount: "-12.3401", Currency: "EUR", RawDescription: "Shopping", Fingerprint: "fp_one"}
|
|
d.Transactions = []Transaction{{Facts: f, Enrichment: Fallback(f)}}
|
|
return d
|
|
}
|
|
func TestMoneyExactBoundaries(t *testing.T) {
|
|
cases := []struct {
|
|
input, canonical string
|
|
minor int64
|
|
}{{"0", "0.00", 0}, {"-0.0000", "0.00", 0}, {"12.3401", "12.3401", 123401}, {"-0.0001", "-0.0001", -1}, {"922337203685477.5807", "922337203685477.5807", math.MaxInt64}, {"-922337203685477.5808", "-922337203685477.5808", math.MinInt64}}
|
|
for _, tc := range cases {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
m, err := ParseMoney(tc.input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if m.String() != tc.canonical {
|
|
t.Fatalf("got %q, want %q", m.String(), tc.canonical)
|
|
}
|
|
n, err := m.Minor()
|
|
if err != nil || n != tc.minor {
|
|
t.Fatalf("minor = %d, %v", n, err)
|
|
}
|
|
round, err := ParseMoney(m.String())
|
|
if err != nil || round != m {
|
|
t.Fatalf("unstable money: %s, %v", round, err)
|
|
}
|
|
})
|
|
}
|
|
for _, s := range []string{"", "+1", " 1", "01", ".1", "1.", "1.00001", "1e2", "NaN", "922337203685477.5808", "-922337203685477.5809", "99999999999999999999999999999999999999", "1,25", "--1"} {
|
|
t.Run("reject_"+s, func(t *testing.T) {
|
|
if _, err := ParseMoney(s); err == nil {
|
|
t.Fatalf("accepted %q", s)
|
|
}
|
|
if _, err := Money(s).Minor(); err == nil {
|
|
t.Fatalf("Minor accepted %q", s)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*Dataset)
|
|
}{
|
|
{"cycle", func(d *Dataset) { d.Categories[4].ParentID = "cat_grocery" }},
|
|
{"nonleaf assignment", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = "cat_food" }},
|
|
{"wrong category kind", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = IncomeFallback }},
|
|
{"remove fallback", func(d *Dataset) { d.Categories = append(d.Categories[:1], d.Categories[2:]...) }},
|
|
{"move fallback", func(d *Dataset) { d.Categories[1].ParentID = "cat_food" }},
|
|
{"fallback child", func(d *Dataset) { d.Categories[4].ParentID = ExpenseFallback }},
|
|
{"missing account", func(d *Dataset) { d.Transactions[0].Facts.AccountID = "acc_missing" }},
|
|
{"currency mismatch", func(d *Dataset) { d.Transactions[0].Facts.Currency = "USD" }},
|
|
{"invalid date", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "2026-02-30" }},
|
|
{"year zero cannot map to journal", func(d *Dataset) { d.Transactions[0].Facts.BookingDate = "0000-01-01" }},
|
|
{"invalid UTF-8 facts", func(d *Dataset) { d.Transactions[0].Facts.RawDescription = string([]byte{0xff}) }},
|
|
{"invalid money", func(d *Dataset) { d.Transactions[0].Facts.Amount = "1e2" }},
|
|
{"unknown tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_missing"} }},
|
|
{"duplicate tag", func(d *Dataset) { d.Transactions[0].Enrichment.TagIDs = []string{"tag_shared", "tag_shared"} }},
|
|
{"unknown merchant", func(d *Dataset) { d.Transactions[0].Enrichment.MerchantID = "mer_missing" }},
|
|
{"duplicate identity", func(d *Dataset) { d.Tags[0].ID = "acc_main" }},
|
|
{"invalid provenance date", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Timestamp = "yesterday" }},
|
|
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
|
|
{"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }},
|
|
{"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
d := sampleDataset()
|
|
tc.mutate(&d)
|
|
if err := Validate(d); err == nil {
|
|
t.Fatal("accepted invalid dataset")
|
|
}
|
|
})
|
|
}
|
|
d := sampleDataset()
|
|
if err := Validate(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := CategoryPath(d, "cat_grocery"); got != "Expenses / Food / Groceries" {
|
|
t.Fatalf("path: %s", got)
|
|
}
|
|
}
|
|
func transferDataset() Dataset {
|
|
d := sampleDataset()
|
|
d.Transactions[0].Facts.Amount = "-10.00"
|
|
peer := d.Transactions[0]
|
|
peer.Facts.ID = "tx_two"
|
|
peer.Facts.Fingerprint = "fp_two"
|
|
peer.Facts.AccountID = "acc_save"
|
|
peer.Facts.Amount = "10.00"
|
|
d.Transactions[0].Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_two", TagIDs: []string{}, Classification: Provenance{Source: "manual"}}
|
|
peer.Enrichment = Enrichment{Kind: "transfer", TransferPeerID: "tx_one", TagIDs: []string{}, Classification: Provenance{Source: "manual"}}
|
|
d.Transactions = append(d.Transactions, peer)
|
|
return d
|
|
}
|
|
func TestTransferRequiresReciprocalOppositeSameCurrencyAccounts(t *testing.T) {
|
|
d := transferDataset()
|
|
if err := Validate(d); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*Dataset)
|
|
}{
|
|
{"one-sided", func(d *Dataset) { d.Transactions[1].Enrichment = Fallback(d.Transactions[1].Facts) }},
|
|
{"self", func(d *Dataset) { d.Transactions[0].Enrichment.TransferPeerID = "tx_one" }},
|
|
{"same account", func(d *Dataset) { d.Transactions[1].Facts.AccountID = "acc_main" }},
|
|
{"unequal", func(d *Dataset) { d.Transactions[1].Facts.Amount = "10.0001" }},
|
|
{"unlike currencies", func(d *Dataset) { d.Accounts[1].Currency = "USD"; d.Transactions[1].Facts.Currency = "USD" }},
|
|
{"AI", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Source = "ai" }},
|
|
{"category", func(d *Dataset) { d.Transactions[0].Enrichment.CategoryID = ExpenseFallback }},
|
|
{"zero", func(d *Dataset) { d.Transactions[0].Facts.Amount = "0"; d.Transactions[1].Facts.Amount = "0" }},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
d := transferDataset()
|
|
tc.mutate(&d)
|
|
if err := Validate(d); err == nil {
|
|
t.Fatal("accepted invalid transfer")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func TestCloneOwnsNestedListsAndFallback(t *testing.T) {
|
|
original := sampleDataset()
|
|
original.Transactions[0].Enrichment.TagIDs = []string{"tag_shared"}
|
|
copy := Clone(original)
|
|
copy.Merchants[0].Aliases[0] = "Changed"
|
|
copy.Merchants[0].DefaultTagIDs[0] = "other"
|
|
copy.Transactions[0].Enrichment.TagIDs[0] = "other"
|
|
copy.Categories[0].Name = "Changed"
|
|
if original.Merchants[0].Aliases[0] != "Shop GmbH" || original.Merchants[0].DefaultTagIDs[0] != "tag_shared" || original.Transactions[0].Enrichment.TagIDs[0] != "tag_shared" || original.Categories[0].Name != "Expenses" {
|
|
t.Fatal("clone shares mutable storage")
|
|
}
|
|
f := original.Transactions[0].Facts
|
|
f.Amount = "1.00"
|
|
if e := Fallback(f); e.Kind != "income" || e.CategoryID != IncomeFallback {
|
|
t.Fatalf("income fallback: %#v", e)
|
|
}
|
|
f.Amount = "-1.00"
|
|
if e := Fallback(f); e.Kind != "expense" || e.CategoryID != ExpenseFallback {
|
|
t.Fatalf("expense fallback: %#v", e)
|
|
}
|
|
empty := Clone(Dataset{})
|
|
raw, err := json.Marshal(empty)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(raw), "null") {
|
|
t.Fatalf("nil public lists: %s", raw)
|
|
}
|
|
}
|