mirror of
https://github.com/luxfi/aml.git
synced 2026-09-24 00:25:20 +00:00
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]>
298 lines
11 KiB
Go
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)
|
|
}
|
|
}
|