Files

448 lines
14 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"maps"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type validationSystemConfigStore struct {
mu sync.Mutex
data map[string]string
setErr error
setKeys []string
}
func (s *validationSystemConfigStore) Get(ctx context.Context, key string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data[key], nil
}
func (s *validationSystemConfigStore) Set(ctx context.Context, key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.setKeys = append(s.setKeys, key)
if s.setErr != nil {
return s.setErr
}
if s.data == nil {
s.data = map[string]string{}
}
s.data[key] = value
return nil
}
func (s *validationSystemConfigStore) Delete(ctx context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return nil
}
func (s *validationSystemConfigStore) List(ctx context.Context) (map[string]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]string, len(s.data))
maps.Copy(out, s.data)
return out, nil
}
type validationSecretsStore struct {
mu sync.Mutex
data map[string]string
}
func (s *validationSecretsStore) Get(ctx context.Context, key string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.data[key], nil
}
func (s *validationSecretsStore) Set(ctx context.Context, key, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.data == nil {
s.data = map[string]string{}
}
s.data[key] = value
return nil
}
func (s *validationSecretsStore) Delete(ctx context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.data, key)
return nil
}
func (s *validationSecretsStore) GetAll(ctx context.Context) (map[string]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]string, len(s.data))
maps.Copy(out, s.data)
return out, nil
}
func newValidationTTSConfigMux(sc store.SystemConfigStore, cs store.ConfigSecretsStore) *http.ServeMux {
h := NewTTSConfigHandler(sc, cs)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func newValidationTTSConfigMuxWithTenants(sc store.SystemConfigStore, cs store.ConfigSecretsStore, ts store.TenantStore) *http.ServeMux {
h := NewTTSConfigHandler(sc, cs, ts)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
func TestTTSConfigRequiresTenantAdminForReadAndWrite(t *testing.T) {
setupTestToken(t, "gateway-token")
setupTestNoAuthFallback(t, false)
ts := newMockTenantStore()
tenantID := uuid.New()
ts.addTenant(tenantID, "acme")
ts.setUserRole(tenantID, "viewer-user", store.TenantRoleViewer)
ts.setUserRole(tenantID, "admin-user", store.TenantRoleAdmin)
setupTestTenantStore(t, ts)
sc := &validationSystemConfigStore{data: map[string]string{}}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMuxWithTenants(sc, cs, ts)
viewerGet := httptest.NewRequest("GET", "/v1/tts/config", nil)
viewerGet.Header.Set("Authorization", "Bearer gateway-token")
viewerGet.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerGet.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerGetRR := httptest.NewRecorder()
mux.ServeHTTP(viewerGetRR, viewerGet)
if viewerGetRR.Code != http.StatusForbidden {
t.Fatalf("viewer GET status = %d, want 403", viewerGetRR.Code)
}
viewerPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`))
viewerPost.Header.Set("Authorization", "Bearer gateway-token")
viewerPost.Header.Set("X-GoClaw-User-Id", "viewer-user")
viewerPost.Header.Set("X-GoClaw-Tenant-Id", "acme")
viewerPostRR := httptest.NewRecorder()
mux.ServeHTTP(viewerPostRR, viewerPost)
if viewerPostRR.Code != http.StatusForbidden {
t.Fatalf("viewer POST status = %d, want 403", viewerPostRR.Code)
}
adminPost := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"edge"}`))
adminPost.Header.Set("Authorization", "Bearer gateway-token")
adminPost.Header.Set("X-GoClaw-User-Id", "admin-user")
adminPost.Header.Set("X-GoClaw-Tenant-Id", "acme")
adminPostRR := httptest.NewRecorder()
mux.ServeHTTP(adminPostRR, adminPost)
if adminPostRR.Code != http.StatusOK {
t.Fatalf("tenant admin POST status = %d, want 200: %s", adminPostRR.Code, adminPostRR.Body.String())
}
}
func TestTTSConfigSave_AcceptsLegacyAndUISchemaAliases(t *testing.T) {
setupTestToken(t, "")
sc := &validationSystemConfigStore{data: map[string]string{}}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMux(sc, cs)
req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{
"provider": "elevenlabs",
"timeout_ms": 1234,
"elevenlabs": map[string]string{
"base_url": "https://custom.elevenlabs.invalid",
"voice_id": "voice-123",
"model_id": "model-123",
"api_key": "xi-test",
},
"edge": map[string]any{
"voice": "en-US-AvaMultilingualNeural",
"rate": "+15%",
"enabled": false,
},
}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if got := sc.data["tts.elevenlabs.api_base"]; got != "https://custom.elevenlabs.invalid" {
t.Fatalf("want elevenlabs api_base persisted, got %q", got)
}
if got := sc.data["tts.elevenlabs.voice"]; got != "voice-123" {
t.Fatalf("want elevenlabs voice persisted, got %q", got)
}
if got := sc.data["tts.elevenlabs.model"]; got != "model-123" {
t.Fatalf("want elevenlabs model persisted, got %q", got)
}
if got := sc.data["tts.timeout_ms"]; got != "1234" {
t.Fatalf("want timeout_ms persisted, got %q", got)
}
if got := sc.data["tts.edge.voice"]; got != "en-US-AvaMultilingualNeural" {
t.Fatalf("want edge voice persisted, got %q", got)
}
if got := sc.data["tts.edge.rate"]; got != "+15%" {
t.Fatalf("want edge rate persisted, got %q", got)
}
if got := sc.data["tts.edge.enabled"]; got != "false" {
t.Fatalf("want edge enabled persisted, got %q", got)
}
if got := cs.data["tts.elevenlabs.api_key"]; got != "xi-test" {
t.Fatalf("want elevenlabs api_key persisted, got %q", got)
}
}
func TestTTSConfigSave_ProviderPresenceSemantics(t *testing.T) {
setupTestToken(t, "")
t.Run("explicit empty provider disables TTS without clearing provider config", func(t *testing.T) {
sc := &validationSystemConfigStore{data: map[string]string{
"tts.provider": "edge",
"tts.edge.voice": "en-US-AvaMultilingualNeural",
"tts.edge.rate": "+15%",
"tts.edge.params": `{"pitch":10,"volume":20}`,
}}
cs := &validationSecretsStore{data: map[string]string{
"tts.openai.api_key": "sk-existing",
}}
mux := newValidationTTSConfigMux(sc, cs)
req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{
"provider": "",
"edge": map[string]any{
"voice": "",
"rate": "",
"params": nil,
},
"openai": map[string]string{
"api_key": "***",
},
}))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("save: want 200, got %d: %s", rr.Code, rr.Body.String())
}
if got := sc.data["tts.provider"]; got != "" {
t.Fatalf("tts.provider = %q, want empty", got)
}
if got := sc.data["tts.edge.voice"]; got != "en-US-AvaMultilingualNeural" {
t.Errorf("tts.edge.voice = %q, want existing value preserved", got)
}
if got := sc.data["tts.edge.rate"]; got != "+15%" {
t.Errorf("tts.edge.rate = %q, want existing value preserved", got)
}
if got := sc.data["tts.edge.params"]; got != `{"pitch":10,"volume":20}` {
t.Errorf("tts.edge.params = %q, want existing value preserved", got)
}
if got := cs.data["tts.openai.api_key"]; got != "sk-existing" {
t.Errorf("tts.openai.api_key = %q, want existing secret preserved", got)
}
getReq := httptest.NewRequest("GET", "/v1/tts/config", nil)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
if getRR.Code != http.StatusOK {
t.Fatalf("get: want 200, got %d: %s", getRR.Code, getRR.Body.String())
}
var response ttsConfigResponse
if err := json.Unmarshal(getRR.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.Provider != "" {
t.Fatalf("GET provider = %q, want empty", response.Provider)
}
})
t.Run("omitted provider leaves existing value unchanged", func(t *testing.T) {
sc := &validationSystemConfigStore{data: map[string]string{"tts.provider": "edge"}}
mux := newValidationTTSConfigMux(sc, &validationSecretsStore{data: map[string]string{}})
req := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{}`))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if got := sc.data["tts.provider"]; got != "edge" {
t.Fatalf("tts.provider = %q, want edge", got)
}
})
t.Run("non-empty provider still replaces existing value", func(t *testing.T) {
sc := &validationSystemConfigStore{data: map[string]string{"tts.provider": "edge"}}
mux := newValidationTTSConfigMux(sc, &validationSecretsStore{data: map[string]string{}})
req := httptest.NewRequest("POST", "/v1/tts/config", strings.NewReader(`{"provider":"openai"}`))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
if got := sc.data["tts.provider"]; got != "openai" {
t.Fatalf("tts.provider = %q, want openai", got)
}
})
}
func TestTTSConfigGet_RoundTripsCompatibilityFields(t *testing.T) {
setupTestToken(t, "")
sc := &validationSystemConfigStore{data: map[string]string{
"tts.provider": "elevenlabs",
"tts.timeout_ms": "1234",
"tts.elevenlabs.api_base": "https://custom.elevenlabs.invalid",
"tts.elevenlabs.voice": "voice-123",
"tts.elevenlabs.model": "model-123",
"tts.edge.voice": "en-US-AvaMultilingualNeural",
"tts.edge.rate": "+15%",
"tts.edge.enabled": "false",
}}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMux(sc, cs)
req := httptest.NewRequest("GET", "/v1/tts/config", nil)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if got, _ := body["timeout_ms"].(float64); got != 1234 {
t.Fatalf("want timeout_ms 1234, got %v", body["timeout_ms"])
}
elevenlabs, _ := body["elevenlabs"].(map[string]any)
if got, _ := elevenlabs["api_base"].(string); got != "https://custom.elevenlabs.invalid" {
t.Fatalf("want elevenlabs api_base, got %q", got)
}
if got, _ := elevenlabs["base_url"].(string); got != "https://custom.elevenlabs.invalid" {
t.Fatalf("want elevenlabs base_url alias, got %q", got)
}
if got, _ := elevenlabs["voice_id"].(string); got != "voice-123" {
t.Fatalf("want elevenlabs voice_id alias, got %q", got)
}
if got, _ := elevenlabs["model_id"].(string); got != "model-123" {
t.Fatalf("want elevenlabs model_id alias, got %q", got)
}
edge, _ := body["edge"].(map[string]any)
if got, _ := edge["voice"].(string); got != "en-US-AvaMultilingualNeural" {
t.Fatalf("want edge voice, got %q", got)
}
if got, _ := edge["rate"].(string); got != "+15%" {
t.Fatalf("want edge rate, got %q", got)
}
if got, ok := edge["enabled"].(bool); !ok || got {
t.Fatalf("want edge enabled=false, got %#v", edge["enabled"])
}
}
// TestSaveAndLoad_Gemini verifies that Gemini config round-trips through save/load.
func TestSaveAndLoad_Gemini(t *testing.T) {
setupTestToken(t, "")
sc := &validationSystemConfigStore{data: map[string]string{}}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMux(sc, cs)
// Save
req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{
"provider": "gemini",
"gemini": map[string]string{
"api_key": "gm-test-key",
"voice": "Kore",
"model": "gemini-2.5-flash-preview-tts",
"speakers": `[{"speaker":"Joe","voice_id":"Kore"}]`,
},
}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("save: want 200, got %d: %s", rr.Code, rr.Body.String())
}
// Verify system_configs
if got := sc.data["tts.gemini.voice"]; got != "Kore" {
t.Errorf("tts.gemini.voice = %q, want Kore", got)
}
if got := sc.data["tts.gemini.model"]; got != "gemini-2.5-flash-preview-tts" {
t.Errorf("tts.gemini.model = %q, want gemini-2.5-flash-preview-tts", got)
}
if got := sc.data["tts.gemini.speakers"]; got == "" {
t.Error("tts.gemini.speakers not persisted")
}
// Verify secret stored
if got := cs.data["tts.gemini.api_key"]; got != "gm-test-key" {
t.Errorf("tts.gemini.api_key = %q, want gm-test-key", got)
}
// Load (GET)
getReq := httptest.NewRequest("GET", "/v1/tts/config", nil)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
if getRR.Code != http.StatusOK {
t.Fatalf("get: want 200, got %d: %s", getRR.Code, getRR.Body.String())
}
var body map[string]any
if err := json.Unmarshal(getRR.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
geminiResp, _ := body["gemini"].(map[string]any)
if geminiResp == nil {
t.Fatal("gemini block missing in response")
}
if got, _ := geminiResp["api_key"].(string); got != "***" {
t.Errorf("gemini api_key = %q, want masked '***'", got)
}
if got, _ := geminiResp["voice"].(string); got != "Kore" {
t.Errorf("gemini voice = %q, want Kore", got)
}
}
func TestTTSConfigSave_WriteFailuresReturnError(t *testing.T) {
setupTestToken(t, "")
sc := &validationSystemConfigStore{data: map[string]string{}, setErr: errors.New("boom")}
cs := &validationSecretsStore{data: map[string]string{}}
mux := newValidationTTSConfigMux(sc, cs)
req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{
"provider": "openai",
"openai": map[string]string{
"api_base": "https://example.invalid/v1",
},
}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("want 500, got %d: %s", rr.Code, rr.Body.String())
}
if rr.Body.String() == "{\"ok\":true}\n" {
t.Fatalf("unexpected success body: %q", rr.Body.String())
}
}