195 lines
8.0 KiB
Go
195 lines
8.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|