package configread import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" ) func currentExample(t *testing.T, name string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json")) if err != nil { t.Fatal(err) } return raw } func TestReadCurrentTaskSnapshot(t *testing.T) { for _, mode := range []string{"asr", "full"} { t.Run(mode, func(t *testing.T) { responses := map[string][]byte{ "/internal/v1/dispatcher/sip": currentExample(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": currentExample(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-" + mode: currentExample(t, "config-read-task-"+mode), "/internal/v1/dispatcher/tenant/1001/quota": currentExample(t, "config-read-quota"), } calls := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ if r.Method != http.MethodGet || r.Header.Get("X-DISPATCHER-ID") != "c046b893-8628-4589-ae50-619d049248a6" || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "test-secret" { t.Errorf("unexpected method/headers: %s", r.URL.Path) w.WriteHeader(http.StatusBadRequest) return } body, ok := responses[r.URL.Path] if !ok { t.Errorf("unexpected path %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) })) defer server.Close() client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client()) if err != nil { t.Fatal(err) } snapshot, err := client.ReadCurrentTask(context.Background(), "task-"+mode, 1001) if err != nil { t.Fatal(err) } if calls != 4 || snapshot.SIP.Revision != 8 || snapshot.Task.TenantID != 1001 || snapshot.Quota.TenantID != 1001 { t.Fatalf("unexpected request count or identity: calls=%d snapshot=%+v", calls, snapshot) } if got := snapshot.Providers["asr-example"].Credential; got != "example-only-not-a-real-secret" { t.Fatalf("credential not passed unchanged: %q", got) } if mode == "asr" && (snapshot.Task.Agent.Mode != "asr_only" || strings.Contains(string(snapshot.Task.Agent.Raw), `"llm"`)) { t.Fatalf("ASR-only Agent picked up an LLM: %s", snapshot.Task.Agent.Raw) } if mode == "full" { var agent struct { LLM struct { Temperature *float64 `json:"temperature"` } `json:"llm"` Conversation struct { AllowInterrupt *bool `json:"allow_interrupt"` } `json:"conversation"` } if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Temperature == nil || *agent.LLM.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt { t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw) } } }) } } func TestReadCurrentSIPWithoutTasks(t *testing.T) { id := "c046b893-8628-4589-ae50-619d049248a6" requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests++ if r.URL.Path != "/internal/v1/dispatcher/sip" { t.Errorf("startup SIP read requested unexpected path %q", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(currentExample(t, "config-read-sip")) })) defer server.Close() client, err := NewClient(server.URL, id, "test-secret", server.Client()) if err != nil { t.Fatal(err) } sip, err := client.ReadCurrentSIP(context.Background()) if err != nil || sip.Revision != 8 || requests != 1 { t.Fatalf("startup SIP config: %+v, %v, requests=%d", sip, err, requests) } } func TestReadCurrentTaskDoesNotReuseConfigAfterHTTPFailure(t *testing.T) { const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6" responses := map[string][]byte{ "/internal/v1/dispatcher/sip": currentExample(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": currentExample(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-asr": currentExample(t, "config-read-task-asr"), "/internal/v1/dispatcher/tenant/1001/quota": currentExample(t, "config-read-quota"), } for _, tc := range []struct{ path, owner string }{ {"/internal/v1/dispatcher/sip", "SIP"}, {"/internal/v1/dispatcher/ai-providers", "AI providers"}, {"/internal/v1/dispatcher/task/task-asr", "task configuration"}, {"/internal/v1/dispatcher/tenant/1001/quota", "tenant quota"}, } { t.Run(tc.owner, func(t *testing.T) { var unavailable atomic.Bool server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if unavailable.Load() && r.URL.Path == tc.path { w.WriteHeader(http.StatusServiceUnavailable) _, _ = w.Write([]byte(`{"resource":"error","error":{"code":"unavailable","message":"try later"}}`)) return } body, ok := responses[r.URL.Path] if !ok { http.NotFound(w, r) return } _, _ = w.Write(body) })) defer server.Close() client, err := NewClient(server.URL, dispatcherID, "isolated-secret", server.Client()) if err != nil { t.Fatal(err) } if _, err := client.ReadCurrentTask(context.Background(), "task-asr", 1001); err != nil { t.Fatalf("approved resources failed before the outage: %v", err) } unavailable.Store(true) _, err = client.ReadCurrentTask(context.Background(), "task-asr", 1001) var httpErr *HTTPError if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusServiceUnavailable { t.Fatalf("unavailable %s reused cached configuration or lost status: %v", tc.owner, err) } if !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.owner)) || strings.Contains(err.Error(), "isolated-secret") { t.Fatalf("HTTP failure lost resource ownership or leaked credentials: %v", err) } }) } } func TestReadCurrentTaskRejectsMismatchedOwnerAndMalformedSIP(t *testing.T) { for _, mode := range []string{"owner", "sip-revision", "provider-ref", "provider-disabled", "provider-wrong-role"} { t.Run(mode, func(t *testing.T) { responses := map[string][]byte{ "/internal/v1/dispatcher/sip": currentExample(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": currentExample(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-asr": currentExample(t, "config-read-task-asr"), "/internal/v1/dispatcher/tenant/1001/quota": currentExample(t, "config-read-quota"), } switch mode { case "owner": responses["/internal/v1/dispatcher/task/task-asr"] = []byte(strings.Replace(string(responses["/internal/v1/dispatcher/task/task-asr"]), `"tenant_id":1001`, `"tenant_id":1002`, 1)) case "sip-revision": responses["/internal/v1/dispatcher/sip"] = currentExample(t, "invalid/config-read-sip-missing-revision") case "provider-ref": responses["/internal/v1/dispatcher/ai-providers"] = currentExample(t, "invalid/config-read-provider-ref") case "provider-disabled": responses["/internal/v1/dispatcher/ai-providers"] = []byte(strings.Replace(string(responses["/internal/v1/dispatcher/ai-providers"]), `"enabled":true`, `"enabled":false`, 1)) case "provider-wrong-role": responses["/internal/v1/dispatcher/ai-providers"] = []byte(strings.Replace(string(responses["/internal/v1/dispatcher/ai-providers"]), `"role":"asr"`, `"role":"tts"`, 1)) } server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(responses[r.URL.Path]) })) defer server.Close() client, err := NewClient(server.URL, "c046b893-8628-4589-ae50-619d049248a6", "test-secret", server.Client()) if err != nil { t.Fatal(err) } if _, err := client.ReadCurrentTask(context.Background(), "task-asr", 1001); err == nil || strings.Contains(err.Error(), "example-only-not-a-real-secret") { t.Fatalf("expected redacted fail-closed error, got %v", err) } }) } }