package ai import ( "context" "errors" "net/http" "net/http/httptest" "strings" "testing" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // failingAuditOrchestrator builds an orchestrator whose audit write is guaranteed to fail: it has an audit // writer and a tenant DB, so the audit is CONFIGURED and expected to happen, but the DB carries no pool, so // RunInTenantTx cannot open a transaction. That is the shape of a real audit outage (the pool is gone, the // audit_logs insert is rejected), and it is the situation where the old code returned an answer anyway. func failingAuditOrchestrator() *Orchestrator { db := &tenant.DB{} return NewOrchestrator(db, nil, audit.New(db)) } // orgCtx supplies a validated tenant, so the failure under test is the audit write and not the missing // tenant guard in front of it. func orgCtx() context.Context { return tenant.WithOrg(context.Background(), "11111111-1111-1111-1111-111111111111") } // TestAnswerIsNotServedWhenTheAEDTAuditCannotBeWritten is the regression this fix exists for. Every AI route // records its AEDT event through Orchestrator.audit, which used to discard the write error, so an answer // whose audit row was never written was served regardless. internal/platform/audit and // matching.auditOnTx both state the opposite contract: a decision that cannot be recorded is not served. func TestAnswerIsNotServedWhenTheAEDTAuditCannotBeWritten(t *testing.T) { o := failingAuditOrchestrator() resp, err := o.finalize(orgCtx(), RouteDirect, "", ErrNoModel, "the deterministic answer", []string{"evidence"}) if err == nil { t.Fatal("an answer whose AEDT audit could not be written must not be served") } if resp != nil { t.Fatalf("no response may be handed back when the decision was not recorded, got %+v", resp) } if !errors.Is(err, ErrAuditFailed) { t.Fatalf("the failure must be the typed ErrAuditFailed so the handler can answer 503, got %v", err) } } // TestRefusalIsNotServedWhenTheAEDTAuditCannotBeWritten covers the deliberate half of the choice. A refusal // grants nothing either way, so failing it closed leaks nothing; what it buys is that an EEO probe cannot be // repeated invisibly. Serving a clean refusal while recording none of them is exactly the combination a // prober wants. func TestRefusalIsNotServedWhenTheAEDTAuditCannotBeWritten(t *testing.T) { o := failingAuditOrchestrator() resp, err := o.refuse(orgCtx(), RouteRefuse, "the question references prohibited demographic data") if err == nil { t.Fatal("a refusal whose audit could not be written must surface an error, not a silent refusal") } if resp != nil { t.Fatalf("no response may be handed back when the refusal was not recorded, got %+v", resp) } if !errors.Is(err, ErrAuditFailed) { t.Fatalf("the failure must be the typed ErrAuditFailed, got %v", err) } } // TestUnconfiguredAuditIsNotAFailure pins the one case that must stay a no op. An orchestrator with no audit // writer or no database is a deliberate configuration (the unit tests, and a deployment with no audit pool // wired), not a failed write, so answers still flow. Without this the fail closed change would take every // test orchestrator and every unconfigured deployment offline. func TestUnconfiguredAuditIsNotAFailure(t *testing.T) { o := NewOrchestrator(nil, nil, nil) resp, err := o.refuse(orgCtx(), RouteRefuse, "the question references prohibited demographic data") if err != nil { t.Fatalf("an orchestrator with no audit configured must still answer: %v", err) } if resp == nil || !resp.Refused { t.Fatalf("the refusal must still be produced, got %+v", resp) } } // TestAuditFailureMapsTo503 proves the caller sees the right thing: not a 4xx blaming a request that was // fine, and not a raw internal error, but a service unavailable that invites a retry once the audit path is // healthy again. func TestAuditFailureMapsTo503(t *testing.T) { rec := httptest.NewRecorder() mapErr(rec, ErrAuditFailed) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("an unrecordable decision must be a 503, got %d", rec.Code) } if body := rec.Body.String(); !strings.Contains(body, "audit_unavailable") { t.Fatalf("the envelope must name the audit as the cause, got %s", body) } }