Files
aml/pkg/workflows/continuous_kyc_test.go
zeekayandHanzo Dev 644d8be410 wire the engine to one screening store, and serve the catalog
The refresh filled a Base-backed sanctions store and the search endpoint queried
a separate empty in-memory one. Neither was reachable from the other, so
/v1/aml/sanctions/search returned no matches for every name, permanently, and
nothing in the system reported it. There is now one store, shared by the
endpoints and by the engine's screening term.

Screening refuses rather than reports clean. An unloaded or stale list set, a
list that parsed to zero entries, or a class the deployment cannot answer all
produce an error, which the engine turns into a review. Political exposure is
refused outright: there is no authoritative public list of politically exposed
persons and none of the four sanctions publishers is one, so answering from
sanctions data would report every customer as not politically exposed.

Each publisher is fetched and parsed by its own parser, and a failure for one
leaves the other three loaded rather than clearing them.

Transactions carry a direction and the customer's name and jurisdiction. The
previous ingestion path synthesised a customer from the identifier alone, so
every screening and jurisdiction rule evaluated against an empty name and an
empty country and could not fire whatever the engine did.

Case events check that the case belongs to the requesting organisation. A case
identifier is returned to whoever submitted the transaction, so possession of it
was authority to write on another tenant's investigation.

Health reports function rather than process: whether the lists are ready, which
loaded when, and how many terms this deployment can answer. Currency rates and
the higher-risk jurisdiction listing are loaded, never compiled in.

/v1/aml/catalog publishes the rules with their citations, the obligations claimed,
and the obligations outstanding.

Screening 31,338 real designations across the four published lists produced two
false positives, both a one-word list entry matching an unrelated three-part name
on a shared prefix. Raising the threshold could not separate them from genuine
transliterations, which score lower; the asymmetry could. A single-token name now
has to match a multi-token name near-exactly. Zero false positives after.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 11:17:45 -07:00

186 lines
5.3 KiB
Go

package workflows
import (
"context"
"log/slog"
"os"
"testing"
"github.com/luxfi/aml/pkg/sanctions"
"github.com/luxfi/aml/pkg/types"
)
func testLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
}
func TestContinuousKYCScreen_NoMatch(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Alice Smith"},
{ID: "e2", OrgID: "org1", Name: "Bob Jones"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
// Sanctions list with no matching names.
entries := []sanctions.Entry{
{RefID: "Vladimir Petrov", Group: "Vladimir Petrov", List: "ofac", Kind: sanctions.Individual, Names: []sanctions.Name{{Full: "Vladimir Petrov", Type: sanctions.Primary, Strong: true}}},
}
cfg := DefaultConfig("org1")
screened, matches, cases := ContinuousKYCScreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if screened != 2 {
t.Errorf("screened = %d, want 2", screened)
}
if matches != 0 {
t.Errorf("matches = %d, want 0", matches)
}
if cases != 0 {
t.Errorf("cases = %d, want 0", cases)
}
// Verify all entities were marked as screened.
for _, e := range entities {
if _, ok := source.LastScreened(e.ID); !ok {
t.Errorf("entity %s not marked as screened", e.ID)
}
}
}
func TestContinuousKYCScreen_WithMatch(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Vladimir Petrov"},
{ID: "e2", OrgID: "org1", Name: "Clean Name"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
entries := []sanctions.Entry{
{RefID: "Vladimir Petrov", Group: "Vladimir Petrov", List: "ofac", Kind: sanctions.Individual, Names: []sanctions.Name{{Full: "Vladimir Petrov", Type: sanctions.Primary, Strong: true}}},
}
cfg := DefaultConfig("org1")
screened, matches, cases := ContinuousKYCScreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if screened != 2 {
t.Errorf("screened = %d, want 2", screened)
}
if matches != 1 {
t.Errorf("matches = %d, want 1", matches)
}
if cases != 1 {
t.Errorf("cases = %d, want 1", cases)
}
if len(sink.Alerts) != 1 {
t.Errorf("alerts = %d, want 1", len(sink.Alerts))
}
}
func TestContinuousKYCScreen_OrgFilter(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Test User"},
{ID: "e2", OrgID: "org2", Name: "Other Org"},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
cfg := DefaultConfig("org1")
screened, _, _ := ContinuousKYCScreen(
context.Background(), cfg, source, sink, nil, testLogger(),
)
if screened != 1 {
t.Errorf("screened = %d, want 1 (org-filtered)", screened)
}
}
func TestHighRiskRescreen(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Normal User", RiskScore: 0.3},
{ID: "e2", OrgID: "org1", Name: "Risky User", RiskScore: 0.9},
{ID: "e3", OrgID: "org1", Name: "PEP User", RiskScore: 0.4, PEP: true},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
cfg := DefaultConfig("org1")
screened, _, _ := HighRiskRescreen(
context.Background(), cfg, source, sink, nil, testLogger(),
)
// Only e2 (risk 0.9 > 0.7) and e3 (PEP) should be screened.
if screened != 2 {
t.Errorf("screened = %d, want 2 (high-risk + PEP only)", screened)
}
}
func TestHighRiskRescreen_MatchCreatesCriticalCase(t *testing.T) {
entities := []types.Entity{
{ID: "e1", OrgID: "org1", Name: "Sanctioned Person", RiskScore: 0.9},
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
entries := []sanctions.Entry{
{RefID: "Sanctioned Person", Group: "Sanctioned Person", List: "ofac", Kind: sanctions.Individual, Names: []sanctions.Name{{Full: "Sanctioned Person", Type: sanctions.Primary, Strong: true}}},
}
cfg := DefaultConfig("org1")
_, matches, cases := HighRiskRescreen(
context.Background(), cfg, source, sink, entries, testLogger(),
)
if matches != 1 {
t.Errorf("matches = %d, want 1", matches)
}
if cases != 1 {
t.Errorf("cases = %d, want 1", cases)
}
if len(sink.Cases) > 0 && sink.Cases[0].Severity != types.SeverityCritical {
t.Errorf("case severity = %q, want %q", sink.Cases[0].Severity, types.SeverityCritical)
}
}
func TestContinuousKYCScreen_Cancellation(t *testing.T) {
entities := make([]types.Entity, 100)
for i := range entities {
entities[i] = types.Entity{ID: "e-many", OrgID: "org1", Name: "Test"}
}
source := NewMemEntitySource(entities)
sink := &MemAlertSink{}
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately.
cfg := DefaultConfig("org1")
screened, _, _ := ContinuousKYCScreen(ctx, cfg, source, sink, nil, testLogger())
// Should stop quickly due to cancellation.
if screened > 1 {
t.Errorf("screened = %d, expected early exit due to context cancellation", screened)
}
}
func TestScreenEntity_AliasMatch(t *testing.T) {
entity := types.Entity{ID: "e1", Name: "Vlad Petrov"}
entries := []sanctions.Entry{
{RefID: "1", Group: "1", List: "ofac", Kind: sanctions.Individual, Names: []sanctions.Name{
{Full: "Vladimir Petrovich Petrov", Type: sanctions.Primary, Strong: true},
{Full: "Vlad Petrov", Type: sanctions.Also, Strong: true},
}},
}
matched, result := screenEntity(entity, entries, 0.85)
if !matched {
t.Error("expected alias match")
}
if result.Score < 0.85 {
t.Errorf("score = %.2f, expected >= 0.85", result.Score)
}
}