//go:build integration package integration import ( "context" "strconv" "strings" "testing" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/modules/ai" "github.com/missionworkforce/missionmatch/internal/platform/audit" ) // TestBenchRouteSuppressesSubKInAnswerPath is the live proof that the ANSWER path suppresses, not just the // executor. It drives a bench intent through the orchestrator (Answer to AnswerGraph to the suppressing // executor) over a two bucket skill coverage where one bucket is sub k, and asserts the answer renders the // sentinel for the withheld cells and NEVER a raw sub k headcount, and that the counts only audit records // the suppression with no PII. This is what stops the raw ExecuteIntent path from leaking unsuppressed // counts: the bench intent is routed to its suppressing executor and the rendering goes through RenderCount. func TestBenchRouteSuppressesSubKInAnswerPath(t *testing.T) { org := seedOrg(t) k := ai.ConfiguredSuppressionThreshold() skill := seedSkillByName(t, "RouteSuppress-"+uuid.NewString()) // holds = k+2 (a published worthy count), lacks = 3 (sub k). In a two bucket partition the sub k cell // triggers complementary suppression, so BOTH cells suppress and no number is rendered. holds := k + 2 for i := 0; i < holds; i++ { c := seedCandidate(t, org, "secret", "active", true) seedCandidateSkill(t, org, c.ID, skill) } for i := 0; i < 3; i++ { seedCandidate(t, org, "secret", "active", true) } orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) resp, err := orch.Answer(ctxOrg(org), "", ai.IntentBenchSkillCoverage, []any{skill.String()}) if err != nil { t.Fatalf("bench skill coverage answer: %v", err) } if resp.Refused { t.Fatalf("an authenticated bench question must not be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("a bench intent must dispatch to the graph route, got %q", resp.Route) } // The answer renders the sentinel for the suppressed cells and the withheld line. if !strings.Contains(resp.Answer, ai.SuppressionSentinel) { t.Errorf("the suppressed answer must render the sentinel, got %q", resp.Answer) } if !strings.Contains(resp.Answer, "withheld") { t.Errorf("the suppressed answer must name the withheld buckets, got %q", resp.Answer) } // CRITICAL: no raw sub k count or its complement may appear as a rendered headcount. A bypass of the // suppressing executor would render headcount=3 or headcount=12; neither may be present. for _, leak := range []string{"headcount=3", "headcount=" + strconv.Itoa(holds)} { if strings.Contains(resp.Answer, leak) { t.Errorf("the answer path leaked a raw headcount %q, suppression was bypassed: %q", leak, resp.Answer) } } // The counts only audit records the suppression and holds no PII. var payloads []string if err := appDB.RunInTenantTx(ctxOrg(org), func(ctx context.Context, tx pgx.Tx) error { rows, qerr := tx.Query(ctx, "SELECT new_values::text FROM audit_logs WHERE organization_id=$1 AND action='aedt.ai.bench_answered'", org) if qerr != nil { return qerr } defer rows.Close() for rows.Next() { var p string if serr := rows.Scan(&p); serr != nil { return serr } payloads = append(payloads, p) } return rows.Err() }); err != nil { t.Fatalf("read bench audit: %v", err) } if len(payloads) == 0 { t.Fatal("a bench answer must emit an aedt.ai.bench_answered audit event") } for _, p := range payloads { if !strings.Contains(p, "suppressed") || !strings.Contains(p, "bench_skill_coverage") { t.Errorf("the bench audit must record the intent and the suppressed count, got %q", p) } // NO PII and NO raw sub k count in the audit payload. for _, leak := range []string{skill.String(), "\"3\"", ": 3", "candidate"} { if strings.Contains(p, leak) { t.Errorf("the bench audit must hold no PII or raw count, leaked %q in %q", leak, p) } } } } // TestBenchRoutePublishesWhenSafe proves the path is not merely always suppressing: when BOTH buckets are at // or above k the real counts are published in the answer. func TestBenchRoutePublishesWhenSafe(t *testing.T) { org := seedOrg(t) k := ai.ConfiguredSuppressionThreshold() skill := seedSkillByName(t, "RoutePublish-"+uuid.NewString()) holds := k + 5 for i := 0; i < holds; i++ { c := seedCandidate(t, org, "secret", "active", true) seedCandidateSkill(t, org, c.ID, skill) } for i := 0; i < k; i++ { // lacks = k, also published seedCandidate(t, org, "secret", "active", true) } orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) resp, err := orch.Answer(ctxOrg(org), "", ai.IntentBenchSkillCoverage, []any{skill.String()}) if err != nil { t.Fatalf("bench skill coverage answer: %v", err) } if resp.Refused || resp.Route != ai.RouteGraph { t.Fatalf("a safe bench question must answer on the graph route, got %+v", resp) } // Both buckets are at or above k, so the real counts publish and the sentinel does NOT appear. if strings.Contains(resp.Answer, ai.SuppressionSentinel) { t.Errorf("a both buckets published answer must not suppress, got %q", resp.Answer) } if !strings.Contains(resp.Answer, "headcount="+strconv.Itoa(holds)) { t.Errorf("the published answer must report the exact holds count, got %q", resp.Answer) } } // TestBenchRouteRefusesWithoutTenantContext proves the auth gate: a bench intent with NO authenticated tenant // context refuses rather than running, instead of relying only on the RLS empty result fail safe. func TestBenchRouteRefusesWithoutTenantContext(t *testing.T) { orch := ai.NewOrchestrator(appDB, nil, audit.New(appDB)) // context.Background has no org from WithOrg, so OrgFromContext fails and the bench gate refuses. resp, err := orch.Answer(context.Background(), "", ai.IntentBenchSkillCoverage, []any{uuid.NewString()}) if err != nil { t.Fatalf("an unauthenticated bench question must refuse cleanly, got error %v", err) } if !resp.Refused { t.Fatalf("an unauthenticated bench question must be refused, got %+v", resp) } if resp.Route != ai.RouteGraph { t.Errorf("the refusal must carry route graph, got %q", resp.Route) } if resp.Answer != "" { t.Errorf("a refusal must ship no answer, got %q", resp.Answer) } } // TestExecuteIntentRejectsBenchIntentRawPath proves the defense in depth guard: the generic ExecuteIntent // (the raw path that returns database rows verbatim) hard rejects every bench intent, so an unsuppressed sub // k headcount can never be served even if some future caller tried the raw path. func TestExecuteIntentRejectsBenchIntentRawPath(t *testing.T) { for _, name := range []string{ ai.IntentBenchSkillCoverage, ai.IntentBenchReadinessDistribution, ai.IntentBenchGapForRequirement, ai.IntentBenchClearanceCoverage, } { intent, ok := ai.IntentByName(name) if !ok { t.Fatalf("bench intent %q must be registered", name) } _, err := ai.ExecuteIntent(ctxOrg(seedOrg(t)), appDB, intent, make([]any, len(intent.Params))) if err == nil { t.Errorf("ExecuteIntent must reject the bench intent %q on the raw path, got nil error", name) continue } if !strings.Contains(err.Error(), "suppressing executor") { t.Errorf("the raw path rejection of %q must name the suppressing executor, got %v", name, err) } } }