package configread import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" ) func example(t *testing.T, name string) []byte { t.Helper() root := filepath.Join("..", "..", "contracts", "schema") if strings.HasPrefix(name, "invalid/") { root = filepath.Join("..", "..", "contracts") } raw, err := os.ReadFile(filepath.Join(root, "examples", name+".json")) if err != nil { t.Fatal(err) } return raw } func approvedSIP(t *testing.T) SIP { t.Helper() var sip SIP if err := json.Unmarshal(example(t, "config-read-sip"), &sip); err != nil { t.Fatal(err) } return sip } func TestReadTaskSnapshot(t *testing.T) { for _, mode := range []string{"asr", "full"} { for _, tc := range []struct { name string omit []string }{ {"explicit schedule fields", nil}, {"omitted end", []string{"ends_at"}}, {"omitted exclusions", []string{"excluded_dates"}}, {"both omitted", []string{"ends_at", "excluded_dates"}}, } { t.Run(mode+"/"+tc.name, func(t *testing.T) { var task map[string]any if err := json.Unmarshal(example(t, "config-read-task-"+mode), &task); err != nil { t.Fatal(err) } for _, field := range tc.omit { delete(task["schedule"].(map[string]any), field) } taskBody, err := json.Marshal(task) if err != nil { t.Fatal(err) } taskBody = []byte(strings.Replace(string(taskBody), `"temperature":0.7`, `"temperature":0`, 1)) responses := map[string][]byte{ "/internal/v1/dispatcher/sip": example(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-" + mode: taskBody, "/internal/v1/dispatcher/tenant/1001/quota": example(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) } providers, err := client.ReadProviders(context.Background()) if err != nil { t.Fatal(err) } snapshot, err := client.ReadTask(context.Background(), "task-"+mode, 1001, approvedSIP(t), providers) if err != nil { t.Fatal(err) } if string(snapshot.Task.Raw) != string(taskBody) { t.Fatal("task snapshot changed the HTTP response or inserted omitted schedule fields") } if calls != 3 || 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["volcengine"].Credential; got != "sk-******" { 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 { Params struct { Temperature *float64 `json:"temperature"` } `json:"params"` } `json:"llm"` Conversation struct { AllowInterrupt *bool `json:"allow_interrupt"` } `json:"conversation"` } if err := json.Unmarshal(snapshot.Task.Agent.Raw, &agent); err != nil || agent.LLM.Params.Temperature == nil || *agent.LLM.Params.Temperature != 0 || agent.Conversation.AllowInterrupt == nil || *agent.Conversation.AllowInterrupt { t.Fatalf("explicit zero/false lost: %v, %s", err, snapshot.Task.Agent.Raw) } } }) } } } func TestReadTaskReusesBootProviders(t *testing.T) { id := "c046b893-8628-4589-ae50-619d049248a6" providerReads := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/internal/v1/dispatcher/ai-providers": providerReads++ if providerReads != 1 { t.Errorf("AI providers fetched again after bootstrap") w.WriteHeader(http.StatusServiceUnavailable) return } _, _ = w.Write(example(t, "config-read-providers")) case "/internal/v1/dispatcher/task/task-asr": _, _ = w.Write(example(t, "config-read-task-asr")) case "/internal/v1/dispatcher/tenant/1001/quota": _, _ = w.Write(example(t, "config-read-quota")) default: t.Errorf("unexpected path %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer server.Close() client, err := NewClient(server.URL, id, "test-secret", server.Client()) if err != nil { t.Fatal(err) } providers, err := client.ReadProviders(context.Background()) if err != nil { t.Fatal(err) } for range 2 { if _, err := client.ReadTask(context.Background(), "task-asr", 1001, approvedSIP(t), providers); err != nil { t.Fatal(err) } } if providerReads != 1 { t.Fatalf("provider reads=%d, want 1", providerReads) } } func TestReadSIPWithoutTasks(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(example(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.ReadSIP(context.Background()) if err != nil || sip.Revision != 8 || requests != 1 { t.Fatalf("startup SIP config: %+v, %v, requests=%d", sip, err, requests) } } func TestReadTaskDoesNotReuseConfigAfterHTTPFailure(t *testing.T) { const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6" responses := map[string][]byte{ "/internal/v1/dispatcher/sip": example(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-asr": example(t, "config-read-task-asr"), "/internal/v1/dispatcher/tenant/1001/quota": example(t, "config-read-quota"), } for _, tc := range []struct{ path, owner string }{ {"/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) } providers, err := client.ReadProviders(context.Background()) if err != nil { t.Fatal(err) } if _, err := client.ReadTask(context.Background(), "task-asr", 1001, approvedSIP(t), providers); err != nil { t.Fatalf("approved resources failed before the outage: %v", err) } unavailable.Store(true) _, err = client.ReadTask(context.Background(), "task-asr", 1001, approvedSIP(t), providers) 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 TestReadTaskRejectsMismatchedOwnerAndMalformedSIP(t *testing.T) { for _, mode := range []string{"owner", "provider-ref", "provider-empty-key", "provider-missing-id"} { t.Run(mode, func(t *testing.T) { responses := map[string][]byte{ "/internal/v1/dispatcher/sip": example(t, "config-read-sip"), "/internal/v1/dispatcher/ai-providers": example(t, "config-read-providers"), "/internal/v1/dispatcher/task/task-asr": example(t, "config-read-task-asr"), "/internal/v1/dispatcher/tenant/1001/quota": example(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 "provider-ref": responses["/internal/v1/dispatcher/ai-providers"] = example(t, "invalid/config-read-provider-ref") case "provider-empty-key": responses["/internal/v1/dispatcher/ai-providers"] = []byte(strings.Replace(string(responses["/internal/v1/dispatcher/ai-providers"]), `"api_key":"sk-******"`, `"api_key":""`, 1)) case "provider-missing-id": responses["/internal/v1/dispatcher/ai-providers"] = []byte(strings.Replace(string(responses["/internal/v1/dispatcher/ai-providers"]), `"provider_id":"3",`, ``, 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) } providers, err := client.ReadProviders(context.Background()) if err == nil { _, err = client.ReadTask(context.Background(), "task-asr", 1001, approvedSIP(t), providers) } if err == nil || strings.Contains(err.Error(), "example-only-not-a-real-secret") { t.Fatalf("expected redacted fail-closed error, got %v", err) } }) } }