Files
aml/pkg/api/alerts_test.go
zeekayandHanzo Dev 6024f38a0f alerts: the last of the record leaves memory
An alert is what a rule said about a transaction at the time it was judged. The
case that cites it is durable now and so is the record it was raised against,
so an alert store that empties on a rollout leaves a case naming evidence that
is no longer there — and the replay that measures a rule's false-positive rate
reads the same alerts to know which rules actually fired.

Same shape as the case plane: an app means the durable shelf, no app means the
map, and the map keeps its LRU bound because memory does. The durable shelf
does not evict; an alert is evidence, and the retention plane is what decides
when evidence goes.

One JSON column rather than a column per field. An alert is only ever read
whole — the route hands back a transaction's alerts, the replay reads which
rules fired — so nothing filters on a score or a citation, and a column per
field would be a second place the shape of an alert is declared.

TestAlertsSurviveARestart writes two tenants' alerts on one transaction, takes
the bytes, opens a second instance and reads them back per tenant: the org is
what scopes the read, and it still is on the other side.

All three planes are now durable and each has its restart test. LLM.md carries
the table. Full suite green (18 packages).

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 18:57:41 -07:00

101 lines
2.8 KiB
Go

package api
import (
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/hanzoai/base/tests"
"github.com/luxfi/aml/pkg/types"
)
// TestAlertsSurviveARestart closes the last of the record that was in memory.
//
// An alert is what a rule said about a transaction at the time it was judged. A
// case cites it, and the replay that measures a rule's false-positive rate reads
// it to know which rules actually fired — so an alert store that empties on a
// rollout leaves a case naming evidence that is no longer there.
func TestAlertsSurviveARestart(t *testing.T) {
first, err := tests.NewTestApp()
if err != nil {
t.Fatalf("first instance: %v", err)
}
if err := EnsureAlerts(first); err != nil {
t.Fatalf("EnsureAlerts: %v", err)
}
raised := time.Now().UTC().Add(-time.Minute)
NewAlertStoreBase(first).Add("tx-1", []types.Alert{{
ID: "al-1", OrgID: "hanzo/acme", TxID: "tx-1", RuleID: "struct-24h",
RuleName: "Structuring, 24 hours", Severity: types.SeverityCritical,
Score: 0.91, ActionTaken: types.ActionBlock, CreatedAt: raised,
}, {
ID: "al-2", OrgID: "zoo/acme", TxID: "tx-1", RuleID: "ctr-day",
RuleName: "Daily reporting limit", Severity: types.SeverityHigh,
Score: 0.62, ActionTaken: types.ActionReport, CreatedAt: raised,
}})
dir := copyAlertTree(t, first.DataDir())
first.Cleanup()
second, err := tests.NewTestApp(dir)
if err != nil {
t.Fatalf("second instance: %v", err)
}
t.Cleanup(second.Cleanup)
after := NewAlertStoreBase(second)
got := after.ByTx("hanzo/acme", "tx-1")
if len(got) != 1 {
t.Fatalf("after the restart the transaction has %d alerts, want 1", len(got))
}
if got[0].ID != "al-1" || got[0].RuleName != "Structuring, 24 hours" || got[0].Score != 0.91 {
t.Fatalf("the alert came back changed: %+v", got[0])
}
// The org is what scopes the read, and it still does on the other side.
if other := after.ByTx("lux/acme", "tx-1"); len(other) != 0 {
t.Fatalf("a tenant with no alerts on this transaction read %d", len(other))
}
if zoo := after.ByTx("zoo/acme", "tx-1"); len(zoo) != 1 || zoo[0].ID != "al-2" {
t.Fatalf("the other tenant's own alert did not survive: %+v", zoo)
}
}
func copyAlertTree(t *testing.T, src string) string {
t.Helper()
dst := t.TempDir()
err := filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(target)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
if err != nil {
t.Fatalf("copy data dir: %v", err)
}
return dst
}