Files
pulse/internal/ai/qualification/client_test.go
T

441 lines
18 KiB
Go

package qualification
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestTriggerAndWaitAssociatesExactNewScopedRun(t *testing.T) {
var triggered atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/ai/patrol/run":
triggered.Store(true)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"success":true}`))
case "/api/ai/patrol/runs":
if !triggered.Load() {
_, _ = w.Write([]byte(`[{"id":"old","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T00:00:01Z"}]`))
return
}
_, _ = w.Write([]byte(`[{"id":"new","started_at":"2099-01-01T00:00:00Z","completed_at":"2099-01-01T00:00:01Z","scope_resource_ids":["r1"]},{"id":"old","started_at":"2026-01-01T00:00:00Z","completed_at":"2026-01-01T00:00:01Z"}]`))
case "/api/ai/patrol/runs/new":
_, _ = w.Write([]byte(`{"id":"new","started_at":"2099-01-01T00:00:00Z","completed_at":"2099-01-01T00:00:01Z","scope_resource_ids":["r1"],"tool_calls":[]}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL, Timeout: 5 * time.Second})
if err != nil {
t.Fatal(err)
}
run, err := client.TriggerAndWait(context.Background(), []string{"r1"}, "test", 3*time.Second)
if err != nil {
t.Fatal(err)
}
if run.ID != "new" {
t.Fatalf("run id = %q", run.ID)
}
}
func TestValidateCollectedScenarioProjectionUsesFaultOracle(t *testing.T) {
manifest := validTestManifest()
manifest.Faults = []FaultSpec{
{ID: "stopped", Target: "dependency", Oracle: []Predicate{{Probe: "docker.running", Target: "dependency", Operator: "eq", Value: json.RawMessage("false")}}},
{ID: "unhealthy", Target: "client", Oracle: []Predicate{{Probe: "docker.health", Target: "client", Operator: "eq", Value: json.RawMessage(`"unhealthy"`)}}},
{ID: "restart-loop", Target: "worker", Oracle: []Predicate{{Probe: "docker.restart_count", Target: "worker", Operator: "gte", Value: json.RawMessage("4")}}},
}
resources := map[string]Resource{
"dependency": {Docker: &DockerResource{ContainerState: "running"}},
"client": {Docker: &DockerResource{Health: "healthy"}},
"worker": {Docker: &DockerResource{RestartCount: 1}},
}
if err := validateCollectedScenarioProjection(manifest, resources); err == nil {
t.Fatal("expected pre-fault collected projection to be rejected")
}
resources["dependency"] = Resource{Docker: &DockerResource{ContainerState: "exited"}}
resources["client"] = Resource{Docker: &DockerResource{Health: "unhealthy"}}
resources["worker"] = Resource{Docker: &DockerResource{RestartCount: 4}}
if err := validateCollectedScenarioProjection(manifest, resources); err != nil {
t.Fatalf("expected collected projection to satisfy scenario-owned oracles: %v", err)
}
}
func TestValidateCollectedScenarioProjectionRequiresNegativeControlBaseline(t *testing.T) {
manifest := validTestManifest()
manifest.Faults = nil
manifest.NegativeControls = []NegativeControl{{Resource: "healthy", Reason: "healthy control"}}
manifest.Baseline = []Predicate{{Probe: "docker.health", Target: "healthy", Operator: "eq", Value: json.RawMessage(`"healthy"`)}}
resources := map[string]Resource{"healthy": {Docker: &DockerResource{ContainerState: "running", Health: "starting"}}}
if err := validateCollectedScenarioProjection(manifest, resources); err == nil {
t.Fatal("expected stale starting projection to be rejected for a healthy negative control")
}
resources["healthy"] = Resource{Docker: &DockerResource{ContainerState: "running", Health: "healthy"}}
if err := validateCollectedScenarioProjection(manifest, resources); err != nil {
t.Fatalf("expected collected negative control to satisfy scenario-owned baseline: %v", err)
}
}
func TestWaitForResourcesMatchingPollsPastStaleState(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
call := calls.Add(1)
health := "healthy"
if call >= 2 {
health = "unhealthy"
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{{
"id": "r1", "type": "app-container", "name": "fixture", "docker": map[string]any{"health": health},
}}})
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
resources, err := client.WaitForResourcesMatching(context.Background(), map[string]string{"target": "fixture"}, time.Second, time.Millisecond, func(resources map[string]Resource) error {
if resources["target"].Docker == nil || resources["target"].Docker.Health != "unhealthy" {
return errors.New("fault state is not collected yet")
}
return nil
})
if err != nil {
t.Fatal(err)
}
if calls.Load() < 2 || resources["target"].Docker.Health != "unhealthy" {
t.Fatalf("collection returned before fault projection converged: calls=%d resources=%+v", calls.Load(), resources)
}
}
func TestNewPulseClientRejectsCredentialsInReportableBaseURL(t *testing.T) {
if _, err := NewPulseClient(ClientConfig{BaseURL: "https://admin:secret@example.test"}); err == nil {
t.Fatal("base URL credentials must be rejected rather than persisted in reports")
}
}
func TestOverridePatrolModelTemporarilyEnablesAndRestoresSubscriptionRoute(t *testing.T) {
requests := make([]map[string]any, 0, 2)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/settings/ai":
_, _ = w.Write([]byte(`{"enabled":true,"model":"ollama:qwen3:8b","patrol_model":"ollama:qwen3:8b","codex_subscription_enabled":false}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/settings/ai/update":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
requests = append(requests, body)
_, _ = w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
restore, err := client.OverridePatrolModel(context.Background(), "codex-subscription:gpt-5.6-luna")
if err != nil {
t.Fatal(err)
}
if err := restore(context.Background()); err != nil {
t.Fatal(err)
}
if len(requests) != 2 || requests[0]["codex_subscription_enabled"] != true || requests[0]["patrol_model"] != "codex-subscription:gpt-5.6-luna" {
t.Fatalf("override request = %#v", requests)
}
if requests[1]["codex_subscription_enabled"] != false || requests[1]["patrol_model"] != "ollama:qwen3:8b" {
t.Fatalf("restore request = %#v", requests[1])
}
}
func TestAcquirePatrolModelSuitePinsOneRouteAndUsesFreshAsyncPreflight(t *testing.T) {
currentModel := "ollama:qwen3:8b"
codexEnabled := false
preflight := map[string]any{
"success": true, "provider": "ollama", "model": "qwen3:8b", "tool_call_observed": true,
"summary": "old preflight", "recorded_at_unix": int64(100),
}
var updates []map[string]any
preflightPosts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/settings/ai":
_ = json.NewEncoder(w).Encode(map[string]any{
"enabled": true, "model": "ollama:qwen3:8b", "patrol_model": currentModel,
"patrol_enabled": true, "codex_subscription_enabled": codexEnabled,
"patrol_preflight": preflight,
})
case r.Method == http.MethodPut && r.URL.Path == "/api/settings/ai/update":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
updates = append(updates, body)
if value, ok := body["patrol_model"].(string); ok {
currentModel = value
}
if value, ok := body["codex_subscription_enabled"].(bool); ok {
codexEnabled = value
}
if currentModel == "codex-subscription:gpt-5.6-sol" {
preflight = map[string]any{
"success": true, "provider": "codex-subscription", "model": "gpt-5.6-sol", "tool_call_observed": true,
"summary": "tool calling verified", "recorded_at_unix": int64(200),
}
}
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/ai/patrol/preflight":
preflightPosts++
_, _ = w.Write([]byte(`{"success":true,"provider":"codex-subscription","model":"gpt-5.6-sol","tool_call_observed":true}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/ai/patrol/status":
_ = json.NewEncoder(w).Encode(map[string]any{"readiness": map[string]any{
"ready": true, "provider": "codex-subscription", "model": "codex-subscription:gpt-5.6-sol",
}})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
lease, err := client.AcquirePatrolModelSuite(context.Background(), "codex-subscription:gpt-5.6-sol", time.Second, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if lease.Model != "codex-subscription:gpt-5.6-sol" || preflightPosts != 0 {
t.Fatalf("lease = %+v, synchronous preflight posts = %d", lease, preflightPosts)
}
if len(updates) != 1 {
t.Fatalf("route updates before close = %#v", updates)
}
if err := lease.Close(context.Background()); err != nil {
t.Fatal(err)
}
if len(updates) != 2 || updates[1]["patrol_model"] != "ollama:qwen3:8b" || updates[1]["codex_subscription_enabled"] != false {
t.Fatalf("route updates after close = %#v", updates)
}
}
func TestPatrolModelSuiteAcquisitionTimeoutIsRouteAware(t *testing.T) {
requested := 45 * time.Second
if got := patrolModelSuiteAcquisitionTimeout("openai", requested); got != requested {
t.Fatalf("API route acquisition timeout = %s, want %s", got, requested)
}
wantSubscription := 2*time.Minute + subscriptionAgentPreflightEvidenceGrace
for _, provider := range []string{"codex-subscription", "claude-subscription"} {
if got := patrolModelSuiteAcquisitionTimeout(provider, requested); got != wantSubscription {
t.Fatalf("%s acquisition timeout = %s, want %s", provider, got, wantSubscription)
}
}
longer := 4 * time.Minute
if got := patrolModelSuiteAcquisitionTimeout("claude-subscription", longer); got != longer {
t.Fatalf("long configured acquisition timeout = %s, want %s", got, longer)
}
}
func TestAcquirePatrolModelSuitePreflightsConfiguredRouteOnceWithoutSettingsWrite(t *testing.T) {
updates := 0
preflightPosts := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/settings/ai":
_, _ = w.Write([]byte(`{"enabled":true,"model":"codex-subscription:gpt-5.6-sol","patrol_model":"codex-subscription:gpt-5.6-sol","patrol_enabled":true,"codex_subscription_enabled":true}`))
case r.Method == http.MethodPut && r.URL.Path == "/api/settings/ai/update":
updates++
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/ai/patrol/preflight":
preflightPosts++
_, _ = w.Write([]byte(`{"success":true,"provider":"codex-subscription","model":"gpt-5.6-sol","tool_call_observed":true,"message":"verified"}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/ai/patrol/status":
_, _ = w.Write([]byte(`{"readiness":{"ready":true,"provider":"codex-subscription","model":"codex-subscription:gpt-5.6-sol"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
lease, err := client.AcquirePatrolModelSuite(context.Background(), "", time.Second, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if updates != 0 || preflightPosts != 1 {
t.Fatalf("settings updates = %d, preflight posts = %d", updates, preflightPosts)
}
if err := lease.Close(context.Background()); err != nil {
t.Fatal(err)
}
if updates != 0 {
t.Fatalf("no-op lease close wrote settings %d time(s)", updates)
}
}
func TestAcquirePatrolModelSuiteRequiresFreshEvidenceWhenEnablingSameSubscriptionRoute(t *testing.T) {
codexEnabled := false
preflightSuccess := false
updates := 0
oldRecordedAt := time.Now().Add(-time.Second).Unix()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/settings/ai":
_ = json.NewEncoder(w).Encode(map[string]any{
"enabled": true, "model": "codex-subscription:gpt-5.6-sol", "patrol_model": "codex-subscription:gpt-5.6-sol",
"patrol_enabled": true, "codex_subscription_enabled": codexEnabled,
"patrol_preflight": map[string]any{
"success": preflightSuccess, "provider": "codex-subscription", "model": "gpt-5.6-sol",
"tool_call_observed": preflightSuccess, "summary": "fresh result",
"recorded_at_unix": map[bool]int64{false: oldRecordedAt, true: time.Now().Unix()}[preflightSuccess],
},
})
case r.Method == http.MethodPut && r.URL.Path == "/api/settings/ai/update":
updates++
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if value, ok := body["codex_subscription_enabled"].(bool); ok {
codexEnabled = value
preflightSuccess = value
}
_, _ = w.Write([]byte(`{}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/ai/patrol/status":
_, _ = w.Write([]byte(`{"readiness":{"ready":true,"provider":"codex-subscription","model":"codex-subscription:gpt-5.6-sol"}}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
lease, err := client.AcquirePatrolModelSuite(context.Background(), "codex-subscription:gpt-5.6-sol", time.Second, time.Millisecond)
if err != nil {
t.Fatal(err)
}
if !lease.Preflight.Success || updates != 1 {
t.Fatalf("lease preflight = %+v, updates = %d", lease.Preflight, updates)
}
if err := lease.Close(context.Background()); err != nil {
t.Fatal(err)
}
if updates != 2 || codexEnabled {
t.Fatalf("restored updates = %d, codex enabled = %t", updates, codexEnabled)
}
}
func TestAcquirePatrolModelSuiteReturnsOneFreshFailureAndRestoresRoute(t *testing.T) {
currentModel := "ollama:qwen3:8b"
updates := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/settings/ai":
response := map[string]any{
"enabled": true, "model": "ollama:qwen3:8b", "patrol_model": currentModel,
"patrol_enabled": true, "codex_subscription_enabled": currentModel == "codex-subscription:gpt-5.6-sol",
}
if currentModel == "codex-subscription:gpt-5.6-sol" {
response["patrol_preflight"] = map[string]any{
"success": false, "provider": "codex-subscription", "model": "gpt-5.6-sol",
"cause": "provider_connection", "summary": "Provider connection issue",
"recorded_at_unix": int64(200),
}
}
_ = json.NewEncoder(w).Encode(response)
case r.Method == http.MethodPut && r.URL.Path == "/api/settings/ai/update":
updates++
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if value, ok := body["patrol_model"].(string); ok {
currentModel = value
}
_, _ = w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
_, err = client.AcquirePatrolModelSuite(context.Background(), "codex-subscription:gpt-5.6-sol", time.Second, time.Millisecond)
if err == nil || !strings.Contains(err.Error(), "Provider connection issue") {
t.Fatalf("acquire error = %v", err)
}
if updates != 2 || currentModel != "ollama:qwen3:8b" {
t.Fatalf("updates = %d, current model = %q", updates, currentModel)
}
}
func TestDecideActionBindsExactPlanHash(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/actions/action-1/decision" || r.Method != http.MethodPost {
http.NotFound(w, r)
return
}
payload, _ := io.ReadAll(r.Body)
var body map[string]any
if err := json.Unmarshal(payload, &body); err != nil {
t.Fatal(err)
}
if body["outcome"] != "rejected" || body["planHash"] != "sha256:exact" {
t.Fatalf("decision body = %s", payload)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"actionId":"action-1","state":"rejected","audit":{"id":"action-1","state":"rejected","plan":{"planHash":"sha256:exact"}}}`))
}))
defer server.Close()
client, err := NewPulseClient(ClientConfig{BaseURL: server.URL})
if err != nil {
t.Fatal(err)
}
audit, err := client.DecideAction(context.Background(), "action-1", "rejected", "operator rejected", "sha256:exact")
if err != nil {
t.Fatal(err)
}
if audit.ID != "action-1" || audit.Plan.PlanHash != "sha256:exact" {
t.Fatalf("audit = %+v", audit)
}
}
func TestFilterRunFindingsUsesRunIDsOrFreshExactResources(t *testing.T) {
now := time.Now().UTC()
before := []Finding{{ID: "updated", ResourceID: "r1", LastSeenAt: now.Add(-time.Minute)}}
after := []Finding{
{ID: "run-owned", ResourceID: "other", LastSeenAt: now},
{ID: "updated", ResourceID: "r1", LastSeenAt: now},
{ID: "stale", ResourceID: "r1", LastSeenAt: now.Add(-2 * time.Minute)},
}
got := filterRunFindings(before, after, PatrolRun{FindingIDs: []string{"run-owned"}}, map[string]Resource{"target": {ID: "r1"}}, now.Add(-time.Second))
encoded, _ := json.Marshal(got)
if len(got) != 2 || got[0].ID != "run-owned" || got[1].ID != "updated" {
t.Fatalf("findings = %s", encoded)
}
}