mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 00:14:33 +00:00
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
448 lines
14 KiB
Go
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())
|
|
}
|
|
}
|