Files
aml/pkg/api/decider_test.go
zeekayandHanzo Dev 8f0cf7ac9f planes: the case plane signs with the credential too
Closing a case is the oldest governed act here and the one an obligation rests
on directly (AMLR Art. 69(2)): the retained assessment is what a supervisor
reads. Its decider was bound to the credential with the rest of them, and this
adds what was missing — the tests that say so, and the mutation that says the
tests can fail.

Two properties, both already true and neither proven: the retained assessment is
signed by the token's subject and not by the `by` a request body carried, and a
credential that names nobody cannot close a case at all. The second refuses at
the assessment, which is the record that matters, and it refuses as a REFUSAL
(400) rather than as an outage — a 503 reads as "retry", and this one never
clears.

The console still renders a "by" field on its resolve form. It is now ignored,
which is the point, but a form asking for something the API discards is a lie to
the analyst filling it in: ui/ owes a one-field deletion. That repo is another
track's.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-01 16:55:32 -07:00

298 lines
11 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"reflect"
"strings"
"testing"
"github.com/hanzoai/base/core"
"github.com/luxfi/aml/internal/instance"
"github.com/luxfi/aml/pkg/lists"
"github.com/luxfi/aml/pkg/models"
"github.com/luxfi/aml/pkg/retention"
"github.com/luxfi/aml/pkg/suppress"
"github.com/luxfi/aml/pkg/types"
"github.com/luxfi/aml/pkg/watch"
)
// Every plane asks a governed act for a reason and a decider so that a supervisor
// can be told who turned a control off. These are the tests that the answer is
// worth something: the decider is the verified subject of the credential and not
// a string the caller sent, and there is no way for a caller to send one.
// governed is a handler with the suppression and rung planes wired, authenticated
// as one subject.
func governed(t *testing.T, subject string) (*Handler, *suppress.Shelf, *watch.Shelf) {
t.Helper()
app := instance.New(t)
t.Cleanup(app.Cleanup)
for _, ensure := range []func(core.App) error{suppress.Ensure, watch.Ensure, lists.Ensure} {
if err := ensure(app); err != nil {
t.Fatalf("ensure: %v", err)
}
}
silence := suppress.NewBase(app)
monitor := watch.NewBase(app)
monitor.Cover = silence
h := plane(t)
h.Identity = func(*http.Request) (Caller, error) { return Caller{Tenant: acme, Subject: subject}, nil }
h.Planes = Planes{Suppress: silence, Watch: monitor, Lists: lists.NewBase(app)}
return h, silence, monitor
}
// TestTheDeciderIsTheCredentialAndNotTheBody.
//
// The request body names somebody else, in the field the old contract used, and
// the record names the token's subject. This is the whole of the finding: a
// suppression whose "by" is free text records that alerts stopped and attributes
// it to whoever the caller typed, including a real colleague who did not do it.
func TestTheDeciderIsTheCredentialAndNotTheBody(t *testing.T) {
h, silence, monitor := governed(t, "u-7")
ctx := context.Background()
e, rec := send(http.MethodPost, "/v1/aml/suppressions", map[string]any{
"rule": "ctr", "kind": "account", "value": "acct-1",
"reason": "treasury sweep, agreed with the MLRO",
"by": "the head of compliance", // the lie
})
if err := post(h, h.Planes.Suppress.Suppress, true)(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("declare = %d: %s", rec.Code, rec.Body.String())
}
ledger, err := silence.Ledger(ctx, acme, &suppress.LedgerIn{})
if err != nil {
t.Fatal(err)
}
if len(ledger.Suppressions) != 1 {
t.Fatalf("suppressions = %d", len(ledger.Suppressions))
}
if by := ledger.Suppressions[0].By; by != "u-7" {
t.Fatalf("the suppression is attributed to %q, want the token's subject u-7", by)
}
if strings.Contains(rec.Body.String(), "the head of compliance") {
t.Fatalf("the caller's own decider reached the record: %s", rec.Body.String())
}
// The same for a rung, which is the other control that changes what reaches a
// queue, and for lifting, which is the decision to make it noisy again.
e, rec = send(http.MethodPost, "/v1/aml/rungs", map[string]any{
"rule": "ctr", "kind": "account", "count": 2, "within": "1h", "to": "block",
"reason": "two reportable transactions in an hour", "by": "somebody else",
})
if err := post(h, h.Planes.Watch.Declare, true)(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("declare rung = %d: %s", rec.Code, rec.Body.String())
}
ladder, err := monitor.Ladder(ctx, acme, &watch.LadderIn{})
if err != nil {
t.Fatal(err)
}
if len(ladder.Rungs) != 1 || ladder.Rungs[0].By != "u-7" {
t.Fatalf("the rung is attributed to %+v, want u-7", ladder.Rungs)
}
lift, rec := send(http.MethodPost, "/v1/aml/suppressions/x/lift", map[string]any{
"reason": "the sweep ended", "by": "somebody else",
})
lift.Request.SetPathValue("id", ledger.Suppressions[0].ID)
if err := post(h, h.Planes.Suppress.Lift, false)(lift); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("lift = %d: %s", rec.Code, rec.Body.String())
}
var lifted suppress.Suppression
if err := json.Unmarshal(rec.Body.Bytes(), &lifted); err != nil {
t.Fatal(err)
}
if lifted.LiftedBy != "u-7" {
t.Fatalf("the lift is attributed to %q, want u-7", lifted.LiftedBy)
}
}
// TestACredentialThatNamesNobodyRecordsNoDecision.
//
// Fail secure, and fail where it costs a request rather than where it costs a
// payment: authentication still succeeds, so reads and ingest work, and every
// operation that would write a decision refuses with the plane's own ErrDecider.
func TestACredentialThatNamesNobodyRecordsNoDecision(t *testing.T) {
h, silence, _ := governed(t, "")
e, rec := send(http.MethodPost, "/v1/aml/suppressions", map[string]any{
"rule": "ctr", "reason": "no", "by": "the head of compliance",
})
if err := post(h, h.Planes.Suppress.Suppress, true)(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400 — a decision naming nobody must not be recorded", rec.Code)
}
ledger, err := silence.Ledger(context.Background(), acme, &suppress.LedgerIn{})
if err != nil {
t.Fatal(err)
}
if len(ledger.Suppressions) != 0 {
t.Fatalf("a suppression was recorded with no decider: %+v", ledger.Suppressions)
}
}
// TestNoDeciderIsOnTheWire.
//
// The binding above is only sound if the caller cannot reach the field at all. A
// decider is a distinct type carrying `json:"-"`, so the body decoder, the query
// binder and the path overlay all skip it — and there is no OTHER field named
// `by` on an input, because one would be a second, caller-writable decider that
// a plane might read instead.
func TestNoDeciderIsOnTheWire(t *testing.T) {
inputs := []any{
lists.DeclareIn{}, lists.AddIn{}, lists.RemoveIn{}, lists.EntriesIn{}, lists.LookupIn{}, lists.CatalogIn{},
suppress.SuppressIn{}, suppress.LiftIn{}, suppress.LedgerIn{}, suppress.CoverIn{},
watch.DeclareIn{}, watch.RetireIn{}, watch.RecordIn{}, watch.FeedIn{}, watch.RatesIn{}, watch.LadderIn{},
models.SearchIn{}, models.FitIn{}, models.AdoptIn{}, models.RunsIn{}, models.FitsIn{}, models.RefIn{},
resolution{},
}
deciders := 0
for _, in := range inputs {
typ := reflect.TypeOf(in)
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if f.Type == deciderType {
deciders++
if name != "-" {
t.Errorf("%s.%s is a decider tagged %q; it must be json:\"-\" or a caller can write it",
typ.Name(), f.Name, f.Tag.Get("json"))
}
continue
}
if name == "by" || strings.EqualFold(f.Name, "by") {
t.Errorf("%s.%s is a caller-writable decider: it must be a types.Decider with json:\"-\"",
typ.Name(), f.Name)
}
}
}
// A count, so that changing every decider to a plain string and deleting the
// type does not make this test pass by having nothing to check.
if deciders < 8 {
t.Fatalf("%d decider fields found across the planes, want every governed input to carry one", deciders)
}
}
// TestTheDeciderIsWrittenAfterTheBodyAndThePath. Order is the contract: the body
// is decoded, the path overlays it, and the credential overlays both. Anything
// else leaves a request whose meaning depends on which half the reader believes.
func TestTheDeciderIsWrittenAfterTheBodyAndThePath(t *testing.T) {
in := lists.AddIn{Name: "from-the-body", By: "from-the-body"}
req := reqWithPath(t, "name", "from-the-path")
if err := path(req, &in); err != nil {
t.Fatal(err)
}
decide(&in, "u-7")
if in.Name != "from-the-path" {
t.Fatalf("name = %q, want the path's", in.Name)
}
if in.By != "u-7" {
t.Fatalf("by = %q, want the credential's", in.By)
}
// And an input with no decider is untouched, so the overlay is not a
// requirement on every operation.
feed := watch.FeedIn{Rule: "ctr"}
decide(&feed, "u-7")
if feed.Rule != "ctr" {
t.Fatalf("feed = %+v", feed)
}
}
// TestTheDeciderTypeIsOneType. types.Decider.Trim is the one conversion, so a
// plane cannot half-trim a value into a decider that is a space.
func TestTheDeciderTypeIsOneType(t *testing.T) {
for raw, want := range map[types.Decider]string{
" u-7 ": "u-7",
"": "",
" ": "",
"u-7": "u-7",
} {
if got := raw.Trim(); got != want {
t.Errorf("%q.Trim() = %q, want %q", string(raw), got, want)
}
}
}
func reqWithPath(t *testing.T, key, value string) *http.Request {
t.Helper()
e, _ := send(http.MethodPost, "/v1/aml/lists/"+value+"/entries", nil)
e.Request.SetPathValue(key, value)
return e.Request
}
// TestClosingACaseNamesTheCredential.
//
// The case plane is the other governed act, and the oldest one: closing a case is
// the retained assessment a supervisor reads. The decider on it was free text too,
// and here the record that carries it is the one an obligation rests on
// (AMLR Art. 69(2)).
func TestClosingACaseNamesTheCredential(t *testing.T) {
h := plane(t)
h.Identity = func(*http.Request) (Caller, error) { return Caller{Tenant: acme, Subject: "u-7"}, nil }
c := h.Cases.Create(acme, types.SeverityHigh, []string{"alert-1"}, []string{"ivan-petrov-42"})
e, rec := send(http.MethodPost, "/v1/aml/cases/"+c.ID+"/resolve", map[string]any{
"resolution": types.ResolutionFalsePositive,
"considered": []string{"velocity over 30 days", "customer profile"},
"rationale": "salary payments consistent with the profile on file",
"by": "the head of compliance", // the lie
})
e.Request.SetPathValue("id", c.ID)
if err := h.resolveCase()(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusOK {
t.Fatalf("resolve = %d: %s", rec.Code, rec.Body.String())
}
record := only(t, h, retention.ClassAssessment)
if record.Assessment.By != "u-7" {
t.Fatalf("the retained assessment is signed %q, want the token's subject u-7", record.Assessment.By)
}
}
// TestACaseCannotBeClosedByNobody. Fail secure at the record that matters: the
// assessment refuses a decider it does not have, and it refuses as a REFUSAL
// (400) and not as an outage (503), because 503 reads as "retry" and this one
// never clears.
func TestACaseCannotBeClosedByNobody(t *testing.T) {
h := plane(t)
h.Identity = func(*http.Request) (Caller, error) { return Caller{Tenant: acme}, nil }
c := h.Cases.Create(acme, types.SeverityHigh, []string{"alert-1"}, []string{"ivan-petrov-42"})
e, rec := send(http.MethodPost, "/v1/aml/cases/"+c.ID+"/resolve", map[string]any{
"resolution": types.ResolutionFalsePositive,
"considered": []string{"velocity over 30 days"},
"rationale": "consistent with the profile on file",
"by": "the head of compliance",
})
e.Request.SetPathValue("id", c.ID)
if err := h.resolveCase()(e); err != nil {
t.Fatalf("transport: %v", err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve = %d: %s, want 400", rec.Code, rec.Body.String())
}
if got := h.Cases.Get(c.ID); got.Status == types.CaseClosed {
t.Fatal("a case was closed by nobody")
}
if held, err := h.Records.Len(); err != nil || held != 0 {
t.Fatalf("an assessment signed by nobody was retained: %d (%v)", held, err)
}
}