mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
Subject.Valid is the single place that decides what an unusable subject is — unknown kind, no tenant, no identifier — and the scope that builds subjects and both stores that consume them all defer to it. Each of those failures has the same consequence and it is the worst one available: a subject naming nobody yields an empty window, and a rule over an empty window sees no activity, which is indistinguishable from a customer who did nothing. A rule that reaches no verdict now records review regardless of the action it was configured with. A failing flag-only rule escalates, because nobody looks at a flag; a failing block rule does not block, because an evidence outage would otherwise decline every payment. Reference data refuses rather than guesses: an unloaded or undated jurisdiction listing fails loudly instead of answering not-listed for every country on earth. Dropped the empty-expression check, which the parser already makes. 22 of 22 mutations to the engine, history and reference guards are killed by the suite; 58 of 58 across measure, sanctions and engine together. Co-authored-by: Hanzo Dev <[email protected]>
580 lines
21 KiB
Go
580 lines
21 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/luxfi/aml/pkg/history"
|
|
"github.com/luxfi/aml/pkg/reference"
|
|
"github.com/luxfi/aml/pkg/types"
|
|
)
|
|
|
|
var ref = time.Date(2026, 3, 10, 12, 0, 0, 0, time.UTC)
|
|
|
|
// screenList answers from a fixed set of names.
|
|
type screenList struct {
|
|
sanctioned []string
|
|
pep []string
|
|
err error
|
|
}
|
|
|
|
func (s screenList) Hit(_ context.Context, name, class string) (Hit, error) {
|
|
if s.err != nil {
|
|
return Hit{}, s.err
|
|
}
|
|
want := s.sanctioned
|
|
if class == ClassPEP {
|
|
want = s.pep
|
|
}
|
|
for _, n := range want {
|
|
if strings.EqualFold(n, name) {
|
|
return Hit{Matched: true, Score: 1, Name: n, List: class}, nil
|
|
}
|
|
}
|
|
return Hit{}, nil
|
|
}
|
|
|
|
func providers(store history.Store) Providers {
|
|
return Providers{
|
|
History: store,
|
|
Screen: screenList{sanctioned: []string{"Ivan Petrov"}, pep: []string{"Jane Minister"}},
|
|
Reference: reference.Jurisdictions{
|
|
AsOf: ref.AddDate(0, -1, 0),
|
|
Action: []string{"KP"},
|
|
Monitoring: []string{"SY"},
|
|
},
|
|
Rate: reference.Rates{AsOf: ref, USDPer: map[string]float64{"EUR": 1.08, "KWD": 3.25}},
|
|
Now: func() time.Time { return ref },
|
|
Zone: time.UTC,
|
|
}
|
|
}
|
|
|
|
func rule(id, dsl string) types.Rule {
|
|
return types.Rule{
|
|
ID: id, Name: id, DSL: dsl, Enabled: true, Weight: 0.3,
|
|
Severity: types.SeverityHigh, Action: types.ActionReview,
|
|
}
|
|
}
|
|
|
|
func tx(usd float64) types.Transaction {
|
|
return types.Transaction{
|
|
ID: "tx-1", OrgID: "org", UserID: "u1", Notional: usd,
|
|
Currency: "USD", Timestamp: ref,
|
|
}
|
|
}
|
|
|
|
// --- admission ---------------------------------------------------------------
|
|
|
|
func TestRuleNamingAbsentEvidenceIsRejected(t *testing.T) {
|
|
// The central fix. A deployment with no screening provider must refuse a rule
|
|
// that screens, rather than installing it and answering "no match" for ever.
|
|
e := New(Providers{History: history.NewMemory(nil), Rate: reference.Rates{}})
|
|
err := e.SetRules([]types.Rule{rule("sanctions", `Screened(Entity.Name, "sanctions")`)})
|
|
if err == nil {
|
|
t.Fatal("a rule that screens must be rejected when no screening provider is configured")
|
|
}
|
|
if !errors.Is(err, ErrNoProvider) {
|
|
t.Fatalf("want a missing-provider error, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "Screened") {
|
|
t.Fatalf("the error must name the term that cannot be answered, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRejectedRuleSetInstallsNothing(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
e := New(providers(store))
|
|
good := rule("ok", `Count("user", "24h") > 0`)
|
|
bad := rule("bad", `Nonsense("user") > 0`)
|
|
|
|
if err := e.SetRules([]types.Rule{good, bad}); err == nil {
|
|
t.Fatal("a set containing an uninstallable rule must be rejected")
|
|
}
|
|
if n := len(e.Rules()); n != 0 {
|
|
t.Fatalf("nothing must be installed from a rejected set, got %d rules", n)
|
|
}
|
|
// The good rule alone installs.
|
|
if err := e.SetRules([]types.Rule{good}); err != nil {
|
|
t.Fatalf("the valid rule alone must install: %v", err)
|
|
}
|
|
if n := len(e.Rules()); n != 1 {
|
|
t.Fatalf("want 1 rule installed, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestRuleMustYieldBoolean(t *testing.T) {
|
|
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
// A rule that returns a number is an authoring error: read as truthy it fires
|
|
// on any activity at all.
|
|
if err := e.SetRules([]types.Rule{rule("numeric", `Sum("user", "24h")`)}); err == nil {
|
|
t.Fatal("a rule that does not yield a boolean must be rejected")
|
|
}
|
|
if err := e.SetRules([]types.Rule{rule("boolean", `Sum("user", "24h") > 100`)}); err != nil {
|
|
t.Fatalf("the comparison form must be accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEmptyAndUnparseableRulesRejected(t *testing.T) {
|
|
e := New(providers(history.NewMemory(nil)))
|
|
for _, dsl := range []string{"", " ", "Count(", "&&"} {
|
|
if err := e.SetRules([]types.Rule{rule("r", dsl)}); err == nil {
|
|
t.Errorf("rule %q must be rejected", dsl)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNegativeWeightRejected(t *testing.T) {
|
|
e := New(providers(history.NewMemory(nil)))
|
|
r := rule("negative", `Count("user", "24h") > 0`)
|
|
r.Weight = -1
|
|
if err := e.SetRules([]types.Rule{r}); err == nil {
|
|
t.Fatal("a negative weight would subtract from the risk score and must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestVocabularyReportsOnlyAnswerableTerms(t *testing.T) {
|
|
full := New(providers(history.NewMemory(nil))).Evaluator().Vocabulary()
|
|
if len(full) == 0 {
|
|
t.Fatal("a fully configured deployment must report a vocabulary")
|
|
}
|
|
bare := New(Providers{History: history.NewMemory(nil)}).Evaluator().Vocabulary()
|
|
for _, term := range bare {
|
|
if term == "Screened" || term == "Tier" || term == "USD" {
|
|
t.Fatalf("term %q must not be offered when its provider is absent", term)
|
|
}
|
|
}
|
|
if len(bare) >= len(full) {
|
|
t.Fatalf("a deployment missing providers must report fewer terms: bare %d, full %d", len(bare), len(full))
|
|
}
|
|
}
|
|
|
|
// --- detection ---------------------------------------------------------------
|
|
|
|
func deposit(user string, hoursAgo int, usd float64, dir string) history.Event {
|
|
return history.Event{
|
|
ID: "e", At: ref.Add(-time.Duration(hoursAgo) * time.Hour),
|
|
USD: usd, Currency: "USD", Direction: dir, User: user,
|
|
}
|
|
}
|
|
|
|
func TestStructuringFiresOnSplitDeposits(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
for i := 1; i <= 5; i++ {
|
|
store.Append("org", deposit("u1", i, 2500, history.In))
|
|
}
|
|
e := New(providers(store))
|
|
r := rule("structuring", `Structured("user", "24h", 10000.0, 3)`)
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(2500), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("five sub-threshold deposits totalling 12,500 must raise an alert, got %d", len(alerts))
|
|
}
|
|
if action != types.ActionReview {
|
|
t.Fatalf("action = %q, want review", action)
|
|
}
|
|
}
|
|
|
|
func TestStructuringDoesNotFireOnOrdinaryActivity(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
store.Append("org", deposit("u1", 2, 200, history.In))
|
|
store.Append("org", deposit("u1", 4, 150, history.In))
|
|
e := New(providers(store))
|
|
if err := e.SetRules([]types.Rule{rule("structuring", `Structured("user", "24h", 10000.0, 3)`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(200), types.Entity{ID: "u1"})
|
|
if len(alerts) != 0 {
|
|
t.Fatalf("two small deposits must not raise an alert, got %d", len(alerts))
|
|
}
|
|
if action != types.ActionAllow {
|
|
t.Fatalf("action = %q, want allow", action)
|
|
}
|
|
}
|
|
|
|
func TestHistoryIsScopedToTheOrganisation(t *testing.T) {
|
|
// The same customer identifier in two tenants must not aggregate together.
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
for i := 1; i <= 5; i++ {
|
|
store.Append("other-org", deposit("u1", i, 2500, history.In))
|
|
}
|
|
e := New(providers(store))
|
|
if err := e.SetRules([]types.Rule{rule("structuring", `Structured("user", "24h", 10000.0, 3)`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, _ := e.Evaluate(context.Background(), tx(2500), types.Entity{ID: "u1"})
|
|
if len(alerts) != 0 {
|
|
t.Fatalf("another tenant's transactions must not count towards this one, got %d alerts", len(alerts))
|
|
}
|
|
}
|
|
|
|
func TestSanctionsRuleFires(t *testing.T) {
|
|
// A literal listed name must produce an alert. In the previous engine this
|
|
// rule could not fire under any circumstances.
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
e := New(providers(store))
|
|
r := rule("sanctions", `Screened(Entity.Name, "sanctions")`)
|
|
r.Action = types.ActionBlock
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Name: "Ivan Petrov"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("a listed name must raise an alert, got %d", len(alerts))
|
|
}
|
|
if action != types.ActionBlock {
|
|
t.Fatalf("action = %q, want block", action)
|
|
}
|
|
// An unlisted name must not.
|
|
alerts, _, action = e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Name: "Maria Gonzalez"})
|
|
if len(alerts) != 0 || action != types.ActionAllow {
|
|
t.Fatalf("an unlisted name must not raise an alert, got %d alerts and action %q", len(alerts), action)
|
|
}
|
|
}
|
|
|
|
func TestJurisdictionTiersAreDistinguished(t *testing.T) {
|
|
e := New(providers(history.NewMemory(nil)))
|
|
r := rule("countermeasures", `Tier(Entity.Jurisdiction) == "action"`)
|
|
r.Action = types.ActionBlock
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for code, wantAlerts := range map[string]int{"KP": 1, "SY": 0, "FR": 0} {
|
|
alerts, _, _ := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Jurisdiction: code})
|
|
if len(alerts) != wantAlerts {
|
|
t.Errorf("jurisdiction %s: got %d alerts, want %d", code, len(alerts), wantAlerts)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrencyConvertedBeforeComparingToThreshold(t *testing.T) {
|
|
e := New(providers(history.NewMemory(nil)))
|
|
if err := e.SetRules([]types.Rule{rule("report", `USD() > 10000.0`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// 10,000 Kuwaiti dinar is about 32,500 dollars and is over the threshold.
|
|
// Passing the amount through as though it were dollars would place it exactly
|
|
// on the wrong side.
|
|
kwd := tx(10000)
|
|
kwd.Currency = "KWD"
|
|
alerts, _, _ := e.Evaluate(context.Background(), kwd, types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("10,000 KWD is over a 10,000 dollar threshold and must alert, got %d", len(alerts))
|
|
}
|
|
// 10,000 dollars exactly is not over it.
|
|
alerts, _, _ = e.Evaluate(context.Background(), tx(10000), types.Entity{ID: "u1"})
|
|
if len(alerts) != 0 {
|
|
t.Fatalf("exactly 10,000 dollars is not over the threshold, got %d alerts", len(alerts))
|
|
}
|
|
}
|
|
|
|
func TestUnknownCurrencyGoesToReviewNotThrough(t *testing.T) {
|
|
e := New(providers(history.NewMemory(nil)))
|
|
if err := e.SetRules([]types.Rule{rule("report", `USD() > 10000.0`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
odd := tx(10_000_000)
|
|
odd.Currency = "ZWL"
|
|
alerts, _, action := e.Evaluate(context.Background(), odd, types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("a transaction the engine cannot value must not pass silently, got %d alerts", len(alerts))
|
|
}
|
|
if alerts[0].EvalErr == "" {
|
|
t.Fatal("the alert must record why the rule reached no verdict")
|
|
}
|
|
if action != types.ActionReview {
|
|
t.Fatalf("action = %q, want review — an unassessable transaction needs a person", action)
|
|
}
|
|
}
|
|
|
|
func TestStoreFailureGoesToReviewNotThrough(t *testing.T) {
|
|
e := New(Providers{
|
|
History: failing{},
|
|
Rate: reference.Rates{AsOf: ref},
|
|
Now: func() time.Time { return ref },
|
|
})
|
|
if err := e.SetRules([]types.Rule{rule("velocity", `Sum("user", "24h") > 1000.0`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(500), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 || action != types.ActionReview {
|
|
t.Fatalf("a storage failure must route to review, got %d alerts and action %q", len(alerts), action)
|
|
}
|
|
if !strings.Contains(alerts[0].EvalErr, "unavailable") {
|
|
t.Fatalf("the failure must be recorded, got %q", alerts[0].EvalErr)
|
|
}
|
|
}
|
|
|
|
type failing struct{}
|
|
|
|
func (failing) Window(context.Context, history.Subject, time.Duration) ([]history.Event, error) {
|
|
return nil, errors.New("store unavailable")
|
|
}
|
|
|
|
func TestAbsentSubjectIsAnErrorNotAnEmptyHistory(t *testing.T) {
|
|
// A rule counting per device, on a transaction with no device fingerprint. An
|
|
// empty window would report no velocity for exactly the transactions that
|
|
// withheld the evidence.
|
|
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
if err := e.SetRules([]types.Rule{rule("device", `Count("device", "24h") > 5`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 || action != types.ActionReview {
|
|
t.Fatalf("a rule needing an absent subject must route to review, got %d alerts action %q", len(alerts), action)
|
|
}
|
|
}
|
|
|
|
func TestUnknownSubjectAndDimensionRejectedAtEvaluation(t *testing.T) {
|
|
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
// These compile — the argument is a string — so they are caught when evaluated,
|
|
// and must produce a recorded failure rather than a zero count.
|
|
for _, dsl := range []string{
|
|
`Count("customer", "24h") > 0`,
|
|
`Distinct("user", "postcode", "24h") > 0`,
|
|
`Count("user", "7") > 0`,
|
|
`Count("user", "24x") > 0`,
|
|
} {
|
|
e2 := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
if err := e2.SetRules([]types.Rule{rule("r", dsl)}); err != nil {
|
|
t.Fatalf("%s: %v", dsl, err)
|
|
}
|
|
alerts, _, action := e2.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
|
t.Errorf("%s: want a recorded failure routed to review, got %d alerts action %q", dsl, len(alerts), action)
|
|
}
|
|
}
|
|
_ = e
|
|
}
|
|
|
|
func TestDayAggregatesTheBusinessDay(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
// 6,000 earlier the same day, and 6,000 now: the day totals 12,000.
|
|
store.Append("org", deposit("u1", 3, 6000, history.In))
|
|
e := New(providers(store))
|
|
if err := e.SetRules([]types.Rule{rule("report", `Day("user") > 10000.0`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, _ := e.Evaluate(context.Background(), tx(6000), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("two 6,000 transactions on one day exceed a 10,000 day threshold, got %d alerts", len(alerts))
|
|
}
|
|
|
|
// The same two amounts on different days do not.
|
|
store2 := history.NewMemory(func() time.Time { return ref })
|
|
store2.Append("org", deposit("u1", 14, 6000, history.In)) // previous calendar day
|
|
e2 := New(providers(store2))
|
|
if err := e2.SetRules([]types.Rule{rule("report", `Day("user") > 10000.0`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, _ = e2.Evaluate(context.Background(), tx(6000), types.Entity{ID: "u1"})
|
|
if len(alerts) != 0 {
|
|
t.Fatalf("amounts on separate calendar days must not aggregate, got %d alerts", len(alerts))
|
|
}
|
|
}
|
|
|
|
func TestDisabledRuleDoesNotFire(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
e := New(providers(store))
|
|
r := rule("off", `USD() > 1.0`)
|
|
r.Enabled = false
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if alerts, _, _ := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"}); len(alerts) != 0 {
|
|
t.Fatalf("a disabled rule must not fire, got %d alerts", len(alerts))
|
|
}
|
|
}
|
|
|
|
func TestRuleScopeFilters(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
e := New(providers(store))
|
|
r := rule("crypto", `USD() > 1.0`)
|
|
r.AssetClassFilter = []string{"crypto"}
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
equity := tx(100)
|
|
equity.AssetClass = "equity"
|
|
if alerts, _, _ := e.Evaluate(context.Background(), equity, types.Entity{ID: "u1"}); len(alerts) != 0 {
|
|
t.Fatal("a rule scoped to crypto must not evaluate an equity transaction")
|
|
}
|
|
crypto := tx(100)
|
|
crypto.AssetClass = "crypto"
|
|
if alerts, _, _ := e.Evaluate(context.Background(), crypto, types.Entity{ID: "u1"}); len(alerts) != 1 {
|
|
t.Fatal("a rule scoped to crypto must evaluate a crypto transaction")
|
|
}
|
|
}
|
|
|
|
func TestAlertCarriesTypologyAndCitations(t *testing.T) {
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
e := New(providers(store))
|
|
r := rule("cited", `USD() > 1.0`)
|
|
r.Typology = "structuring"
|
|
r.Citations = citationFixture()
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, _ := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatal("expected one alert")
|
|
}
|
|
if alerts[0].Typology != "structuring" {
|
|
t.Errorf("alert must carry the typology, got %q", alerts[0].Typology)
|
|
}
|
|
if len(alerts[0].Citations) == 0 {
|
|
t.Error("alert must carry the citations of the rule that raised it")
|
|
}
|
|
}
|
|
|
|
func TestWindowIsQueriedOncePerSubjectAndLookback(t *testing.T) {
|
|
// Several rules asking the same question of the same window must produce one
|
|
// query, not one per rule.
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
store.Append("org", deposit("u1", 1, 100, history.In))
|
|
counted := &counting{Store: store}
|
|
|
|
e := New(Providers{
|
|
History: counted,
|
|
Rate: reference.Rates{AsOf: ref},
|
|
Now: func() time.Time { return ref },
|
|
Zone: time.UTC,
|
|
})
|
|
if err := e.SetRules([]types.Rule{
|
|
rule("a", `Count("user", "24h") > 0`),
|
|
rule("b", `Sum("user", "24h") > 0.0`),
|
|
rule("c", `Max("user", "24h") > 0.0`),
|
|
rule("d", `Round("user", "24h", 100.0) > 0.5`),
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
|
if counted.calls != 1 {
|
|
t.Fatalf("four rules over one window must issue one query, got %d", counted.calls)
|
|
}
|
|
}
|
|
|
|
type counting struct {
|
|
history.Store
|
|
calls int
|
|
}
|
|
|
|
func (c *counting) Window(ctx context.Context, s history.Subject, d time.Duration) ([]history.Event, error) {
|
|
c.calls++
|
|
return c.Store.Window(ctx, s, d)
|
|
}
|
|
|
|
func TestFailingRuleIsEscalatedNotDowngraded(t *testing.T) {
|
|
// A rule that reached no verdict must go to review whatever it would otherwise
|
|
// have done. A failing flag-only rule must escalate, because nobody looks at a
|
|
// flag; and a failing block rule must not block, because an evidence outage
|
|
// would then decline every payment.
|
|
for _, configured := range []string{types.ActionFlag, types.ActionBlock, types.ActionReport, types.ActionAllow} {
|
|
e := New(Providers{
|
|
History: failing{},
|
|
Rate: reference.Rates{AsOf: ref},
|
|
Now: func() time.Time { return ref },
|
|
})
|
|
r := rule("velocity", `Sum("user", "24h") > 1000.0`)
|
|
r.Action = configured
|
|
if err := e.SetRules([]types.Rule{r}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(500), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("%s: want one alert, got %d", configured, len(alerts))
|
|
}
|
|
if alerts[0].ActionTaken != types.ActionReview {
|
|
t.Errorf("a rule configured to %s that failed must record review, got %q", configured, alerts[0].ActionTaken)
|
|
}
|
|
if action != types.ActionReview {
|
|
t.Errorf("a rule configured to %s that failed must resolve to review, got %q", configured, action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnknownScreeningClassIsRejected(t *testing.T) {
|
|
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
if err := e.SetRules([]types.Rule{rule("r", `Screened(Entity.Name, "watchlist")`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Name: "Ivan Petrov"})
|
|
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
|
t.Fatalf("an unrecognised screening class must be recorded as a failure, got %d alerts action %q", len(alerts), action)
|
|
}
|
|
}
|
|
|
|
func TestMalformedWindowUnitsRejected(t *testing.T) {
|
|
for _, w := range []string{"xh", "7", "24x", "-3d", "0d", "", "h"} {
|
|
e := New(providers(history.NewMemory(func() time.Time { return ref })))
|
|
if err := e.SetRules([]types.Rule{rule("r", `Count("user", "`+w+`") > 0`)}); err != nil {
|
|
t.Fatalf("%q: %v", w, err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1"})
|
|
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
|
t.Errorf("window %q must be rejected at evaluation, got %d alerts action %q", w, len(alerts), action)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestUnloadedJurisdictionListingFailsLoudly(t *testing.T) {
|
|
// The whole point of the reference provider. An empty listing answering
|
|
// "not listed" for every country reads as a world with no high-risk
|
|
// jurisdictions in it, and no operator would ever notice.
|
|
e := New(Providers{
|
|
History: history.NewMemory(func() time.Time { return ref }),
|
|
Reference: reference.Jurisdictions{},
|
|
Rate: reference.Rates{AsOf: ref},
|
|
Now: func() time.Time { return ref },
|
|
})
|
|
if err := e.SetRules([]types.Rule{rule("r", `Tier(Entity.Jurisdiction) == "action"`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, action := e.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Jurisdiction: "KP"})
|
|
if len(alerts) != 1 || alerts[0].EvalErr == "" || action != types.ActionReview {
|
|
t.Fatalf("an unloaded jurisdiction listing must fail loudly, got %d alerts action %q", len(alerts), action)
|
|
}
|
|
|
|
// A listing with entries but no date is equally unusable: its currency cannot
|
|
// be assessed, and a listing from two years ago is not the current one.
|
|
e2 := New(Providers{
|
|
History: history.NewMemory(func() time.Time { return ref }),
|
|
Reference: reference.Jurisdictions{Action: []string{"KP"}},
|
|
Rate: reference.Rates{AsOf: ref},
|
|
Now: func() time.Time { return ref },
|
|
})
|
|
if err := e2.SetRules([]types.Rule{rule("r", `Tier(Entity.Jurisdiction) == "action"`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
alerts, _, _ = e2.Evaluate(context.Background(), tx(100), types.Entity{ID: "u1", Jurisdiction: "KP"})
|
|
if len(alerts) != 1 || alerts[0].EvalErr == "" {
|
|
t.Fatal("an undated jurisdiction listing must fail loudly")
|
|
}
|
|
}
|
|
|
|
func TestSubjectValidationIsSharedByStoreAndScope(t *testing.T) {
|
|
// The scope builds Subjects and the stores consume them; both defer to the same
|
|
// validation, so there is one definition of what naming nobody means.
|
|
store := history.NewMemory(func() time.Time { return ref })
|
|
for _, s := range []history.Subject{
|
|
{OrgID: "org", Kind: "customer", ID: "u1"},
|
|
{OrgID: "", Kind: history.SubjectUser, ID: "u1"},
|
|
{OrgID: "org", Kind: history.SubjectUser, ID: ""},
|
|
} {
|
|
if _, err := store.Window(context.Background(), s, time.Hour); err == nil {
|
|
t.Errorf("subject %+v must be rejected by the store", s)
|
|
}
|
|
if err := s.Valid(); err == nil {
|
|
t.Errorf("subject %+v must be reported invalid", s)
|
|
}
|
|
}
|
|
ok := history.Subject{OrgID: "org", Kind: history.SubjectUser, ID: "u1"}
|
|
if err := ok.Valid(); err != nil {
|
|
t.Errorf("a complete subject must be valid: %v", err)
|
|
}
|
|
}
|