handlePutConfig was only saving to disk without updating s.cfg in memory, so GET /api/config returned stale values until the server restarted. Now holds a write lock and saves to file first, then copies to memory only on success, ensuring concurrent GETs block until both are done. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2468 lines
68 KiB
Go
2468 lines
68 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
|
)
|
|
|
|
// --- Server lifecycle tests ---
|
|
|
|
func TestNewServer_FieldsInitialized(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfgPath := "/tmp/config.json"
|
|
var restarted bool
|
|
|
|
s := NewServer(cfg, cfgPath, func() {
|
|
restarted = true
|
|
})
|
|
|
|
if s == nil {
|
|
t.Fatal("NewServer returned nil")
|
|
}
|
|
if s.cfg != cfg {
|
|
t.Error("server should keep config pointer")
|
|
}
|
|
if s.configPath != cfgPath {
|
|
t.Errorf("configPath = %q, want %q", s.configPath, cfgPath)
|
|
}
|
|
if s.onRestart == nil {
|
|
t.Fatal("onRestart should be set")
|
|
}
|
|
|
|
s.onRestart()
|
|
if !restarted {
|
|
t.Error("onRestart callback should be callable")
|
|
}
|
|
}
|
|
|
|
func TestServerStop_NoServer_NoError(t *testing.T) {
|
|
s := &Server{}
|
|
if err := s.Stop(context.Background()); err != nil {
|
|
t.Errorf("Stop() without server should not error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1 // invalid port prevents real listen but Start still builds handler
|
|
cfg.Gateway.APIKey = "test-key"
|
|
|
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
|
if err := s.Start(); err != nil {
|
|
t.Fatalf("Start() error = %v, want nil", err)
|
|
}
|
|
if s.server == nil {
|
|
t.Fatal("server should be initialized after Start")
|
|
}
|
|
|
|
// Missing auth header should be rejected by middleware.
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("GET /api/config without token status = %d, want 401", rr.Code)
|
|
}
|
|
|
|
// Correct token should allow handler execution.
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/api/config/schema", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GET /api/config/schema with token status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// Method pattern registration should reject unsupported methods.
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPost, "/api/config", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /api/config status = %d, want 405", rr.Code)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
if err := s.Stop(ctx); err != nil {
|
|
t.Errorf("Stop() returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Schema tests ---
|
|
|
|
func TestBuildSchema_SectionCount(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
// Config has exported fields: LLM, Agents, Channels, Gateway, Tools, Heartbeat, RateLimits
|
|
if len(schema.Sections) < 7 {
|
|
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_FieldKeys(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Find LLM section
|
|
var llm *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "llm" {
|
|
llm = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if llm == nil {
|
|
t.Fatal("LLM section not found")
|
|
}
|
|
|
|
keys := map[string]bool{}
|
|
for _, f := range llm.Fields {
|
|
keys[f.Key] = true
|
|
}
|
|
|
|
for _, k := range []string{"model", "api_key", "base_url"} {
|
|
if !keys[k] {
|
|
t.Errorf("expected LLM field %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_Types(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
fieldMap := allFields(schema)
|
|
|
|
tests := []struct {
|
|
key string
|
|
wantType string
|
|
}{
|
|
{"model", "string"},
|
|
{"api_key", "string"},
|
|
{"enabled", "bool"},
|
|
{"max_tokens", "int"},
|
|
{"temperature", "float"},
|
|
{"allow_from", "[]string"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
f, ok := fieldMap[tc.key]
|
|
if !ok {
|
|
// Try with prefix
|
|
continue
|
|
}
|
|
if f.Type != tc.wantType {
|
|
t.Errorf("field %q: want type %q, got %q", tc.key, tc.wantType, f.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_SecretFlag(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
fieldMap := allFields(schema)
|
|
|
|
secrets := []string{"api_key", "token", "bot_token", "app_token", "channel_secret", "channel_access_token"}
|
|
for _, k := range secrets {
|
|
f, ok := fieldMap[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !f.Secret {
|
|
t.Errorf("field %q should be marked as secret", k)
|
|
}
|
|
}
|
|
|
|
nonSecrets := []string{"model", "base_url", "enabled", "host", "port"}
|
|
for _, k := range nonSecrets {
|
|
f, ok := fieldMap[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if f.Secret {
|
|
t.Errorf("field %q should NOT be marked as secret", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_Labels(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"api_key", "API Key"},
|
|
{"base_url", "Base URL"},
|
|
{"max_tokens", "Max Tokens"},
|
|
{"bot_token", "Bot Token"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
got := snakeToTitle(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("snakeToTitle(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_MapType(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Tools section should have an mcp field of type "map"
|
|
var toolsSection *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "tools" {
|
|
toolsSection = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if toolsSection == nil {
|
|
t.Fatal("tools section not found")
|
|
}
|
|
|
|
found := false
|
|
for _, f := range toolsSection.Fields {
|
|
if f.Key == "mcp" {
|
|
found = true
|
|
if f.Type != "map" {
|
|
t.Errorf("mcp field type: want %q, got %q", "map", f.Type)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("mcp field not found in tools section")
|
|
}
|
|
}
|
|
|
|
// --- Schema: unexported fields and omitempty ---
|
|
|
|
func TestBuildSchema_UnexportedFieldsSkipped(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Config has an unexported field `mu sync.RWMutex` — it must not appear
|
|
for _, sec := range schema.Sections {
|
|
if sec.Key == "mu" {
|
|
t.Error("unexported field 'mu' should not appear as a section")
|
|
}
|
|
for _, f := range sec.Fields {
|
|
if f.Key == "mu" || strings.HasSuffix(f.Key, ".mu") {
|
|
t.Errorf("unexported field 'mu' should not appear as a field, got key %q", f.Key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_OmitemptyTagHandled(t *testing.T) {
|
|
// MCPServerConfig has json:"command,omitempty" — jsonKey should return "command"
|
|
got := jsonKey(reflect.TypeOf(config.MCPServerConfig{}).Field(0)) // Command field
|
|
if got != "command" {
|
|
t.Errorf("jsonKey for omitempty field = %q, want %q", got, "command")
|
|
}
|
|
}
|
|
|
|
// --- Schema: nested keys and defaults ---
|
|
|
|
func TestBuildSchema_NestedDotKeys(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Find channels section
|
|
var channels *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "channels" {
|
|
channels = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if channels == nil {
|
|
t.Fatal("channels section not found")
|
|
}
|
|
|
|
// Expect dot-separated keys like "telegram.enabled", "telegram.token"
|
|
wantKeys := []string{
|
|
"telegram.enabled",
|
|
"telegram.token",
|
|
"slack.bot_token",
|
|
"line.channel_secret",
|
|
"websocket.host",
|
|
"websocket.port",
|
|
}
|
|
|
|
keySet := map[string]bool{}
|
|
for _, f := range channels.Fields {
|
|
keySet[f.Key] = true
|
|
}
|
|
|
|
for _, k := range wantKeys {
|
|
if !keySet[k] {
|
|
t.Errorf("expected channels field %q, available keys: %v", k, keys(keySet))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_DefaultValues(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Collect all fields with full keys (section.field)
|
|
type fullField struct {
|
|
section string
|
|
field SchemaField
|
|
}
|
|
var all []fullField
|
|
for _, sec := range schema.Sections {
|
|
for _, f := range sec.Fields {
|
|
all = append(all, fullField{sec.Key, f})
|
|
}
|
|
}
|
|
|
|
findField := func(section, key string) *SchemaField {
|
|
for _, ff := range all {
|
|
if ff.section == section && ff.field.Key == key {
|
|
return &ff.field
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
tests := []struct {
|
|
section string
|
|
key string
|
|
want interface{}
|
|
}{
|
|
{"agents", "defaults.max_tokens", float64(8192)}, // JSON numbers → float64
|
|
{"agents", "defaults.context_window", float64(128000)},
|
|
{"agents", "defaults.restrict_to_workspace", true},
|
|
{"gateway", "host", "127.0.0.1"},
|
|
{"gateway", "port", float64(18790)},
|
|
{"heartbeat", "enabled", true},
|
|
{"heartbeat", "interval", float64(30)},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
f := findField(tc.section, tc.key)
|
|
if f == nil {
|
|
t.Errorf("field %s.%s not found", tc.section, tc.key)
|
|
continue
|
|
}
|
|
// Compare via JSON round-trip since reflect values may differ
|
|
gotJSON, _ := json.Marshal(f.Default)
|
|
wantJSON, _ := json.Marshal(tc.want)
|
|
if string(gotJSON) != string(wantJSON) {
|
|
t.Errorf("field %s.%s default = %s, want %s", tc.section, tc.key, gotJSON, wantJSON)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GET /api/config/schema handler test ---
|
|
|
|
func TestHandleGetSchema_Response(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
s := newTestServer(cfg)
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config/schema", nil)
|
|
s.handleGetSchema(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
ct := rr.Header().Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
|
|
}
|
|
|
|
var schema SchemaResponse
|
|
if err := json.NewDecoder(rr.Body).Decode(&schema); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if len(schema.Sections) < 7 {
|
|
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
|
|
}
|
|
|
|
// Verify each section has at least one field
|
|
for _, sec := range schema.Sections {
|
|
if sec.Key == "" {
|
|
t.Error("section key should not be empty")
|
|
}
|
|
if sec.Label == "" {
|
|
t.Errorf("section %q label should not be empty", sec.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GET /api/config tests ---
|
|
|
|
func TestHandleGetConfig_SecretsReturnedAsIs(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.APIKey = "secret-key-123"
|
|
cfg.Channels.Telegram.Token = "telegram-token-456"
|
|
cfg.Channels.Slack.BotToken = "slack-bot-token"
|
|
cfg.Channels.Slack.AppToken = "slack-app-token"
|
|
cfg.Channels.LINE.ChannelSecret = "line-secret"
|
|
cfg.Channels.LINE.ChannelAccessToken = "line-access-token"
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
// Secrets are returned as-is (no masking)
|
|
llm := result["llm"].(map[string]interface{})
|
|
if llm["api_key"] != "secret-key-123" {
|
|
t.Errorf("LLM api_key = %v, want %q", llm["api_key"], "secret-key-123")
|
|
}
|
|
|
|
telegram := result["channels"].(map[string]interface{})["telegram"].(map[string]interface{})
|
|
if telegram["token"] != "telegram-token-456" {
|
|
t.Errorf("Telegram token = %v, want %q", telegram["token"], "telegram-token-456")
|
|
}
|
|
|
|
slack := result["channels"].(map[string]interface{})["slack"].(map[string]interface{})
|
|
if slack["bot_token"] != "slack-bot-token" {
|
|
t.Errorf("Slack bot_token = %v, want %q", slack["bot_token"], "slack-bot-token")
|
|
}
|
|
|
|
line := result["channels"].(map[string]interface{})["line"].(map[string]interface{})
|
|
if line["channel_secret"] != "line-secret" {
|
|
t.Errorf("LINE channel_secret = %v, want %q", line["channel_secret"], "line-secret")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetConfig_NonSecretValues(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "test-model"
|
|
cfg.LLM.BaseURL = "https://example.com"
|
|
cfg.Gateway.Host = "0.0.0.0"
|
|
cfg.Gateway.Port = 9999
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
llm := result["llm"].(map[string]interface{})
|
|
if llm["model"] != "test-model" {
|
|
t.Errorf("model = %v, want %q", llm["model"], "test-model")
|
|
}
|
|
if llm["base_url"] != "https://example.com" {
|
|
t.Errorf("base_url = %v, want %q", llm["base_url"], "https://example.com")
|
|
}
|
|
|
|
gw := result["gateway"].(map[string]interface{})
|
|
if gw["host"] != "0.0.0.0" {
|
|
t.Errorf("gateway host = %v, want %q", gw["host"], "0.0.0.0")
|
|
}
|
|
if gw["port"] != float64(9999) {
|
|
t.Errorf("gateway port = %v, want %v", gw["port"], 9999)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetConfig_GatewayAPIKeyReturnedAsIs(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "my-gateway-secret"
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
gw := result["gateway"].(map[string]interface{})
|
|
if gw["api_key"] != "my-gateway-secret" {
|
|
t.Errorf("gateway api_key = %v, want %q", gw["api_key"], "my-gateway-secret")
|
|
}
|
|
}
|
|
|
|
func TestHandleGetConfig_EmptySecretsReturnedEmpty(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
// All secrets are empty by default
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
llm := result["llm"].(map[string]interface{})
|
|
if llm["api_key"] != "" {
|
|
t.Errorf("Empty api_key should be returned as empty string, got %v", llm["api_key"])
|
|
}
|
|
}
|
|
|
|
func TestHandleGetConfig_MarshalError_500(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Temperature = math.NaN() // json.Marshal should fail
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
if _, ok := result["error"]; !ok {
|
|
t.Error("expected error field in response")
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config tests ---
|
|
|
|
func TestHandlePutConfig_UpdateNonSecret(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "old-model"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"llm":{"model":"new-model"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// Verify saved config
|
|
saved, _ := config.LoadConfig(cfgPath)
|
|
if saved.LLM.Model != "new-model" {
|
|
t.Errorf("model = %q, want %q", saved.LLM.Model, "new-model")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_SecretPreservedViaPartialUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.APIKey = "real-secret"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Only send model — api_key should be preserved via partial update
|
|
body := `{"llm":{"model":"updated"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, _ := config.LoadConfig(cfgPath)
|
|
if saved.LLM.APIKey != "real-secret" {
|
|
t.Errorf("api_key = %q, want %q", saved.LLM.APIKey, "real-secret")
|
|
}
|
|
if saved.LLM.Model != "updated" {
|
|
t.Errorf("model = %q, want %q", saved.LLM.Model, "updated")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_SecretUpdatedWithNewValue(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.APIKey = "old-secret"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Send a new secret value — should save the new value
|
|
body := `{"llm":{"api_key":"brand-new-secret"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, _ := config.LoadConfig(cfgPath)
|
|
if saved.LLM.APIKey != "brand-new-secret" {
|
|
t.Errorf("api_key = %q, want %q", saved.LLM.APIKey, "brand-new-secret")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_NilOnRestart_NoPanic(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
// onRestart is nil
|
|
s := &Server{cfg: cfg, configPath: cfgPath, onRestart: nil}
|
|
body := `{"llm":{"model":"test"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
|
|
// Should not panic
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_InvalidJSON_400(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{invalid json`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
if _, ok := result["error"]; !ok {
|
|
t.Error("expected error field in response")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_InvalidFieldType_400(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// gateway.port expects int, so string should fail during unmarshal to Config
|
|
body := `{"gateway":{"port":"not-an-int"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
errMsg, ok := result["error"].(string)
|
|
if !ok || errMsg == "" {
|
|
t.Fatal("expected non-empty error message")
|
|
}
|
|
if !strings.Contains(errMsg, "invalid config") {
|
|
t.Errorf("error = %q, want message containing %q", errMsg, "invalid config")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_SaveError_500(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
// Using a directory as file path makes SaveConfig fail on WriteFile.
|
|
cfgPath := t.TempDir()
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
|
|
body := `{"llm":{"model":"new-model"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
errMsg, ok := result["error"].(string)
|
|
if !ok || errMsg == "" {
|
|
t.Fatal("expected non-empty error message")
|
|
}
|
|
if !strings.Contains(errMsg, "failed to save config") {
|
|
t.Errorf("error = %q, want message containing %q", errMsg, "failed to save config")
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "original-model"
|
|
cfg.Gateway.Host = "10.0.0.1"
|
|
cfg.Gateway.Port = 12345
|
|
cfg.Channels.Telegram.Enabled = true
|
|
cfg.Heartbeat.Interval = 60
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Only send LLM update — everything else should be preserved
|
|
body := `{"llm":{"model":"new-model"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to load saved config: %v", err)
|
|
}
|
|
|
|
if saved.LLM.Model != "new-model" {
|
|
t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model")
|
|
}
|
|
if saved.Gateway.Host != "10.0.0.1" {
|
|
t.Errorf("Gateway host = %q, want %q (should be preserved)", saved.Gateway.Host, "10.0.0.1")
|
|
}
|
|
if saved.Gateway.Port != 12345 {
|
|
t.Errorf("Gateway port = %d, want %d (should be preserved)", saved.Gateway.Port, 12345)
|
|
}
|
|
if !saved.Channels.Telegram.Enabled {
|
|
t.Error("Telegram enabled should be preserved as true")
|
|
}
|
|
if saved.Heartbeat.Interval != 60 {
|
|
t.Errorf("Heartbeat interval = %d, want %d (should be preserved)", saved.Heartbeat.Interval, 60)
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_ResponseBody(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"llm":{"model":"test"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
if result["status"] != "ok" {
|
|
t.Errorf("status = %v, want %q", result["status"], "ok")
|
|
}
|
|
if result["restart"] != true {
|
|
t.Errorf("restart = %v, want true", result["restart"])
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_OnRestartCalled(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
var mu sync.Mutex
|
|
restarted := false
|
|
s := &Server{
|
|
cfg: cfg,
|
|
configPath: cfgPath,
|
|
onRestart: func() {
|
|
mu.Lock()
|
|
restarted = true
|
|
mu.Unlock()
|
|
},
|
|
}
|
|
|
|
body := `{"llm":{"model":"test"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// Wait for async restart callback
|
|
time.Sleep(200 * time.Millisecond)
|
|
mu.Lock()
|
|
if !restarted {
|
|
t.Error("onRestart should have been called")
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
|
|
// --- Auth middleware tests ---
|
|
|
|
func TestAuthMiddleware_NoKey_Skips(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
// api_key is empty by default — auth should be skipped
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 (auth skipped)", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_MissingHeader_401(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_WrongToken_403(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_BasicScheme_401(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401 for Basic scheme", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_ErrorResponseJSON(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// Test 401 response
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
handler(rr, req)
|
|
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("401 Content-Type = %q, want %q", ct, "application/json")
|
|
}
|
|
var body401 map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&body401)
|
|
if _, ok := body401["error"]; !ok {
|
|
t.Error("401 response should have error field")
|
|
}
|
|
|
|
// Test 403 response
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong")
|
|
handler(rr, req)
|
|
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("403 Content-Type = %q, want %q", ct, "application/json")
|
|
}
|
|
var body403 map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&body403)
|
|
if _, ok := body403["error"]; !ok {
|
|
t.Error("403 response should have error field")
|
|
}
|
|
}
|
|
|
|
func TestAuthMiddleware_CorrectToken_200(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestSecretKeys(t *testing.T) {
|
|
wantSecret := []string{"api_key", "token", "bot_token", "app_token", "channel_secret", "channel_access_token"}
|
|
for _, k := range wantSecret {
|
|
if !secretKeys[k] {
|
|
t.Errorf("secretKeys[%q] = false, want true", k)
|
|
}
|
|
}
|
|
|
|
notSecret := []string{"model", "host", "port", "enabled"}
|
|
for _, k := range notSecret {
|
|
if secretKeys[k] {
|
|
t.Errorf("secretKeys[%q] = true, want false", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: nested secret preservation via partial update ---
|
|
|
|
func TestHandlePutConfig_NestedSecretPreservedViaPartialUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Channels.Telegram.Token = "real-tg-token"
|
|
cfg.Channels.Telegram.Enabled = false
|
|
cfg.Channels.Slack.BotToken = "real-slack-bot"
|
|
cfg.Channels.LINE.ChannelSecret = "real-line-secret"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Only send non-secret fields — secrets should be preserved via partial update
|
|
body := `{"channels":{"telegram":{"enabled":true}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.Channels.Telegram.Token != "real-tg-token" {
|
|
t.Errorf("telegram token = %q, want %q", saved.Channels.Telegram.Token, "real-tg-token")
|
|
}
|
|
if saved.Channels.Slack.BotToken != "real-slack-bot" {
|
|
t.Errorf("slack bot_token = %q, want %q", saved.Channels.Slack.BotToken, "real-slack-bot")
|
|
}
|
|
if saved.Channels.LINE.ChannelSecret != "real-line-secret" {
|
|
t.Errorf("line channel_secret = %q, want %q", saved.Channels.LINE.ChannelSecret, "real-line-secret")
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: empty body ---
|
|
|
|
func TestHandlePutConfig_EmptyBody(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "keep-this-model"
|
|
cfg.Gateway.Port = 12345
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
// All existing values should be preserved with empty update
|
|
if saved.LLM.Model != "keep-this-model" {
|
|
t.Errorf("model = %q, want %q", saved.LLM.Model, "keep-this-model")
|
|
}
|
|
if saved.Gateway.Port != 12345 {
|
|
t.Errorf("gateway port = %d, want %d", saved.Gateway.Port, 12345)
|
|
}
|
|
}
|
|
|
|
// --- GET /api/config: Content-Type verification ---
|
|
|
|
func TestHandleGetConfig_ContentType(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
s := newTestServer(cfg)
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
ct := rr.Header().Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: Content-Type verification ---
|
|
|
|
func TestHandlePutConfig_ContentType(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"llm":{"model":"test"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
ct := rr.Header().Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
|
|
}
|
|
}
|
|
|
|
// --- Schema: goTypeToSchema edge cases ---
|
|
|
|
func TestBuildSchema_BoolDefaultValues(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Find heartbeat section
|
|
var heartbeat *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "heartbeat" {
|
|
heartbeat = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if heartbeat == nil {
|
|
t.Fatal("heartbeat section not found")
|
|
}
|
|
|
|
for _, f := range heartbeat.Fields {
|
|
if f.Key == "enabled" {
|
|
if f.Default != true {
|
|
t.Errorf("heartbeat enabled default = %v, want true", f.Default)
|
|
}
|
|
if f.Type != "bool" {
|
|
t.Errorf("heartbeat enabled type = %q, want %q", f.Type, "bool")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("heartbeat enabled field not found")
|
|
}
|
|
|
|
// --- Auth middleware: Bearer with extra whitespace ---
|
|
|
|
func TestAuthMiddleware_BearerExtraSpace_401(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
// "Bearer test-key" (double space) — prefix check passes but token won't match
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key")
|
|
handler(rr, req)
|
|
|
|
if rr.Code == http.StatusOK {
|
|
t.Error("double-space Bearer should not authenticate successfully")
|
|
}
|
|
}
|
|
|
|
// --- Auth middleware: empty Bearer token ---
|
|
|
|
func TestAuthMiddleware_EmptyBearerToken_403(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer ")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for empty bearer token", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- Schema: section labels ---
|
|
|
|
func TestBuildSchema_SectionLabels(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
wantLabels := map[string]string{
|
|
"llm": "LLM",
|
|
"gateway": "Gateway",
|
|
"heartbeat": "Heartbeat",
|
|
"rate_limits": "Rate Limits",
|
|
}
|
|
|
|
for _, sec := range schema.Sections {
|
|
if want, ok := wantLabels[sec.Key]; ok {
|
|
if sec.Label != want {
|
|
t.Errorf("section %q label = %q, want %q", sec.Key, sec.Label, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config via routing with auth ---
|
|
|
|
func TestServerRouting_PutConfig_AuthRequired(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1
|
|
cfg.Gateway.APIKey = "route-test-key"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := NewServer(cfg, cfgPath, nil)
|
|
if err := s.Start(); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
defer s.Stop(context.Background())
|
|
|
|
// PUT without auth should be rejected
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{"llm":{"model":"x"}}`))
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("PUT /api/config without token status = %d, want 401", rr.Code)
|
|
}
|
|
|
|
// PUT with wrong token should be rejected
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{"llm":{"model":"x"}}`))
|
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("PUT /api/config with wrong token status = %d, want 403", rr.Code)
|
|
}
|
|
|
|
// PUT with correct token should succeed
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{"llm":{"model":"x"}}`))
|
|
req.Header.Set("Authorization", "Bearer route-test-key")
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("PUT /api/config with correct token status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: secret clearing ---
|
|
|
|
func TestHandlePutConfig_SecretClearedWithEmptyString(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.APIKey = "existing-secret"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Send empty string to clear the secret
|
|
body := `{"llm":{"api_key":""}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.LLM.APIKey != "" {
|
|
t.Errorf("api_key = %q, want empty (should be cleared)", saved.LLM.APIKey)
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: multiple sections in one request ---
|
|
|
|
func TestHandlePutConfig_MultipleSectionsSimultaneous(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "old-model"
|
|
cfg.Gateway.Port = 18790
|
|
cfg.Heartbeat.Interval = 30
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"llm":{"model":"new-model"},"gateway":{"port":9999},"heartbeat":{"interval":60}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.LLM.Model != "new-model" {
|
|
t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model")
|
|
}
|
|
if saved.Gateway.Port != 9999 {
|
|
t.Errorf("Gateway port = %d, want %d", saved.Gateway.Port, 9999)
|
|
}
|
|
if saved.Heartbeat.Interval != 60 {
|
|
t.Errorf("Heartbeat interval = %d, want %d", saved.Heartbeat.Interval, 60)
|
|
}
|
|
}
|
|
|
|
// --- PUT /api/config: in-memory config updated immediately ---
|
|
|
|
func TestHandlePutConfig_InMemoryConfigUpdated(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "original"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"llm":{"model":"updated"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// In-memory config should be updated immediately
|
|
if s.cfg.LLM.Model != "updated" {
|
|
t.Errorf("in-memory model = %q, want %q", s.cfg.LLM.Model, "updated")
|
|
}
|
|
|
|
// Disk should have new value
|
|
saved, _ := config.LoadConfig(cfgPath)
|
|
if saved.LLM.Model != "updated" {
|
|
t.Errorf("disk model = %q, want %q", saved.LLM.Model, "updated")
|
|
}
|
|
}
|
|
|
|
// --- GET /api/config: Discord token returned as-is ---
|
|
|
|
func TestHandleGetConfig_DiscordTokenReturnedAsIs(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Channels.Discord.Token = "discord-secret-token"
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
discord := result["channels"].(map[string]interface{})["discord"].(map[string]interface{})
|
|
if discord["token"] != "discord-secret-token" {
|
|
t.Errorf("Discord token = %v, want %q", discord["token"], "discord-secret-token")
|
|
}
|
|
}
|
|
|
|
// --- Schema: every section has at least one field ---
|
|
|
|
func TestBuildSchema_AllSectionsHaveFields(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
for _, sec := range schema.Sections {
|
|
if len(sec.Fields) == 0 {
|
|
t.Errorf("section %q has 0 fields, expected at least 1", sec.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Schema: deeply nested fields (3+ levels) ---
|
|
|
|
func TestBuildSchema_DeeplyNestedFields(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// tools section has web.brave.api_key, web.duckduckgo.enabled, etc.
|
|
var toolsSection *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "tools" {
|
|
toolsSection = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if toolsSection == nil {
|
|
t.Fatal("tools section not found")
|
|
}
|
|
|
|
keySet := map[string]bool{}
|
|
for _, f := range toolsSection.Fields {
|
|
keySet[f.Key] = true
|
|
}
|
|
|
|
wantKeys := []string{
|
|
"web.brave.api_key",
|
|
"web.brave.enabled",
|
|
"web.brave.max_results",
|
|
"web.duckduckgo.enabled",
|
|
"web.duckduckgo.max_results",
|
|
}
|
|
|
|
for _, k := range wantKeys {
|
|
if !keySet[k] {
|
|
t.Errorf("expected tools field %q, available: %v", k, keys(keySet))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildSchema_DeeplyNestedSecretFlag(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// tools.web.brave.api_key should be marked as secret
|
|
var toolsSection *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "tools" {
|
|
toolsSection = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if toolsSection == nil {
|
|
t.Fatal("tools section not found")
|
|
}
|
|
|
|
for _, f := range toolsSection.Fields {
|
|
if f.Key == "web.brave.api_key" {
|
|
if !f.Secret {
|
|
t.Error("web.brave.api_key should be marked as secret")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("web.brave.api_key field not found in tools section")
|
|
}
|
|
|
|
// --- Schema: []string default value ---
|
|
|
|
func TestBuildSchema_StringSliceDefault(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Find a []string field (e.g., channels.telegram.allow_from)
|
|
var channels *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "channels" {
|
|
channels = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if channels == nil {
|
|
t.Fatal("channels section not found")
|
|
}
|
|
|
|
for _, f := range channels.Fields {
|
|
if f.Key == "telegram.allow_from" {
|
|
if f.Type != "[]string" {
|
|
t.Errorf("allow_from type = %q, want %q", f.Type, "[]string")
|
|
}
|
|
// Default should be an empty slice (marshals to [])
|
|
defJSON, _ := json.Marshal(f.Default)
|
|
if string(defJSON) != "[]" {
|
|
t.Errorf("allow_from default = %s, want []", defJSON)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
t.Error("telegram.allow_from field not found")
|
|
}
|
|
|
|
// =============================================================================
|
|
// Additional tests: High priority
|
|
// =============================================================================
|
|
|
|
// --- #13: Intra-section partial update (fields not sent should be preserved) ---
|
|
|
|
func TestHandlePutConfig_IntraSectionPartialUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "gpt-4"
|
|
cfg.LLM.APIKey = "secret-key"
|
|
cfg.LLM.BaseURL = "https://example.com"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Only send model — api_key and base_url should be preserved
|
|
body := `{"llm":{"model":"claude"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.LLM.Model != "claude" {
|
|
t.Errorf("model = %q, want %q", saved.LLM.Model, "claude")
|
|
}
|
|
if saved.LLM.APIKey != "secret-key" {
|
|
t.Errorf("api_key = %q, want %q (should be preserved)", saved.LLM.APIKey, "secret-key")
|
|
}
|
|
if saved.LLM.BaseURL != "https://example.com" {
|
|
t.Errorf("base_url = %q, want %q (should be preserved)", saved.LLM.BaseURL, "https://example.com")
|
|
}
|
|
}
|
|
|
|
// --- #14: bool true → false via PUT ---
|
|
|
|
func TestHandlePutConfig_BoolTrueToFalse(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Channels.Telegram.Enabled = true
|
|
cfg.Heartbeat.Enabled = true
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"channels":{"telegram":{"enabled":false}},"heartbeat":{"enabled":false}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.Channels.Telegram.Enabled {
|
|
t.Error("telegram enabled should be false after update")
|
|
}
|
|
if saved.Heartbeat.Enabled {
|
|
t.Error("heartbeat enabled should be false after update")
|
|
}
|
|
}
|
|
|
|
// --- #15: int set to zero ---
|
|
|
|
func TestHandlePutConfig_IntSetToZero(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.RateLimits.MaxToolCallsPerMinute = 30
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"rate_limits":{"max_tool_calls_per_minute":0}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.RateLimits.MaxToolCallsPerMinute != 0 {
|
|
t.Errorf("max_tool_calls_per_minute = %d, want 0", saved.RateLimits.MaxToolCallsPerMinute)
|
|
}
|
|
}
|
|
|
|
// --- #16: float set to zero ---
|
|
|
|
func TestHandlePutConfig_FloatSetToZero(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Temperature = 0.7
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"agents":{"defaults":{"temperature":0}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.Agents.Defaults.Temperature != 0 {
|
|
t.Errorf("temperature = %v, want 0", saved.Agents.Defaults.Temperature)
|
|
}
|
|
}
|
|
|
|
// --- #18: MCP map PUT update ---
|
|
|
|
func TestHandlePutConfig_MCPMapUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.MCP = map[string]config.MCPServerConfig{
|
|
"server1": {Command: "cmd1", Enabled: true, Description: "first"},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"tools":{"mcp":{"server1":{"command":"cmd1","enabled":false,"description":"first"},"server2":{"command":"cmd2","enabled":true,"description":"second"}}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if len(saved.Tools.MCP) != 2 {
|
|
t.Fatalf("MCP count = %d, want 2", len(saved.Tools.MCP))
|
|
}
|
|
s1, ok := saved.Tools.MCP["server1"]
|
|
if !ok {
|
|
t.Fatal("server1 not found in saved MCP")
|
|
}
|
|
if s1.Enabled {
|
|
t.Error("server1 enabled should be false")
|
|
}
|
|
s2, ok := saved.Tools.MCP["server2"]
|
|
if !ok {
|
|
t.Fatal("server2 not found in saved MCP")
|
|
}
|
|
if !s2.Enabled || s2.Command != "cmd2" {
|
|
t.Errorf("server2 = %+v, want enabled=true command=cmd2", s2)
|
|
}
|
|
}
|
|
|
|
func TestHandlePutConfig_MCPMapInMemoryUpdated(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.MCP = map[string]config.MCPServerConfig{
|
|
"original": {Command: "orig", Enabled: true},
|
|
}
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"tools":{"mcp":{"original":{"command":"orig","enabled":true},"added":{"command":"new","enabled":true}}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// In-memory config should now contain the added server
|
|
if _, found := s.cfg.Tools.MCP["added"]; !found {
|
|
t.Error("in-memory MCP should contain 'added' after PUT")
|
|
}
|
|
if len(s.cfg.Tools.MCP) != 2 {
|
|
t.Errorf("in-memory MCP count = %d, want 2", len(s.cfg.Tools.MCP))
|
|
}
|
|
}
|
|
|
|
// --- #23: Concurrent PUT requests ---
|
|
|
|
func TestHandlePutConfig_ConcurrentPUT(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
body := fmt.Sprintf(`{"llm":{"model":"model-%d"}}`, n)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("concurrent PUT #%d status = %d, want 200", n, rr.Code)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Verify the file is valid JSON with one of the models
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error after concurrent PUTs: %v", err)
|
|
}
|
|
if !strings.HasPrefix(saved.LLM.Model, "model-") {
|
|
t.Errorf("model = %q, want model-N", saved.LLM.Model)
|
|
}
|
|
}
|
|
|
|
// --- #24: Concurrent GET + PUT ---
|
|
|
|
func TestHandlePutConfig_ConcurrentGETAndPUT(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.Model = "initial"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
|
|
var wg sync.WaitGroup
|
|
// Concurrent GETs
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("concurrent GET status = %d, want 200", rr.Code)
|
|
}
|
|
}()
|
|
}
|
|
// Concurrent PUTs
|
|
for i := 0; i < 5; i++ {
|
|
wg.Add(1)
|
|
go func(n int) {
|
|
defer wg.Done()
|
|
body := fmt.Sprintf(`{"llm":{"model":"put-%d"}}`, n)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("concurrent PUT #%d status = %d, want 200", n, rr.Code)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Additional tests: Medium priority
|
|
// =============================================================================
|
|
|
|
// --- #8: Brave api_key returned as-is in GET ---
|
|
|
|
func TestHandleGetConfig_BraveAPIKeyReturnedAsIs(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.Web.Brave.APIKey = "brave-secret"
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
tools := result["tools"].(map[string]interface{})
|
|
web := tools["web"].(map[string]interface{})
|
|
brave := web["brave"].(map[string]interface{})
|
|
if brave["api_key"] != "brave-secret" {
|
|
t.Errorf("brave api_key = %v, want %q", brave["api_key"], "brave-secret")
|
|
}
|
|
}
|
|
|
|
// --- #10: GET with MCP servers configured ---
|
|
|
|
func TestHandleGetConfig_WithMCPServers(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.MCP = map[string]config.MCPServerConfig{
|
|
"test-server": {
|
|
Command: "test-cmd",
|
|
Args: []string{"--flag"},
|
|
Env: map[string]string{"KEY": "value"},
|
|
Description: "test server",
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
tools := result["tools"].(map[string]interface{})
|
|
mcp, ok := tools["mcp"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("mcp should be a map in response")
|
|
}
|
|
srv, ok := mcp["test-server"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatal("test-server not found in mcp response")
|
|
}
|
|
if srv["command"] != "test-cmd" {
|
|
t.Errorf("command = %v, want %q", srv["command"], "test-cmd")
|
|
}
|
|
if srv["enabled"] != true {
|
|
t.Errorf("enabled = %v, want true", srv["enabled"])
|
|
}
|
|
}
|
|
|
|
// --- #11: FlexibleStringSlice serialization in GET ---
|
|
|
|
func TestHandleGetConfig_FlexibleStringSliceSerialization(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Channels.Telegram.AllowFrom = config.FlexibleStringSlice{"user1", "12345"}
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
channels := result["channels"].(map[string]interface{})
|
|
tg := channels["telegram"].(map[string]interface{})
|
|
af, ok := tg["allow_from"].([]interface{})
|
|
if !ok {
|
|
t.Fatal("allow_from should be a JSON array")
|
|
}
|
|
if len(af) != 2 {
|
|
t.Fatalf("allow_from length = %d, want 2", len(af))
|
|
}
|
|
if af[0] != "user1" || af[1] != "12345" {
|
|
t.Errorf("allow_from = %v, want [user1, 12345]", af)
|
|
}
|
|
}
|
|
|
|
// --- #17: FlexibleStringSlice PUT round-trip ---
|
|
|
|
func TestHandlePutConfig_FlexibleStringSliceUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"channels":{"telegram":{"allow_from":["user1",456,"user3"]}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
af := saved.Channels.Telegram.AllowFrom
|
|
if len(af) != 3 {
|
|
t.Fatalf("allow_from length = %d, want 3", len(af))
|
|
}
|
|
if af[0] != "user1" {
|
|
t.Errorf("allow_from[0] = %q, want %q", af[0], "user1")
|
|
}
|
|
// 456 (number) should be converted to "456"
|
|
if af[1] != "456" {
|
|
t.Errorf("allow_from[1] = %q, want %q", af[1], "456")
|
|
}
|
|
if af[2] != "user3" {
|
|
t.Errorf("allow_from[2] = %q, want %q", af[2], "user3")
|
|
}
|
|
}
|
|
|
|
// --- #19: Deep nested partial update (tools.web.brave) ---
|
|
|
|
func TestHandlePutConfig_DeepNestedPartialUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Tools.Web.Brave.Enabled = false
|
|
cfg.Tools.Web.Brave.APIKey = "brave-key"
|
|
cfg.Tools.Web.Brave.MaxResults = 5
|
|
cfg.Tools.Web.DuckDuckGo.Enabled = true
|
|
cfg.Tools.Web.DuckDuckGo.MaxResults = 10
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
// Only update brave.api_key — everything else should be preserved
|
|
body := `{"tools":{"web":{"brave":{"api_key":"new-brave-key"}}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.Tools.Web.Brave.APIKey != "new-brave-key" {
|
|
t.Errorf("brave api_key = %q, want %q", saved.Tools.Web.Brave.APIKey, "new-brave-key")
|
|
}
|
|
if saved.Tools.Web.Brave.Enabled != false {
|
|
t.Error("brave enabled should be preserved as false")
|
|
}
|
|
if saved.Tools.Web.Brave.MaxResults != 5 {
|
|
t.Errorf("brave max_results = %d, want 5 (preserved)", saved.Tools.Web.Brave.MaxResults)
|
|
}
|
|
if !saved.Tools.Web.DuckDuckGo.Enabled {
|
|
t.Error("duckduckgo enabled should be preserved as true")
|
|
}
|
|
if saved.Tools.Web.DuckDuckGo.MaxResults != 10 {
|
|
t.Errorf("duckduckgo max_results = %d, want 10 (preserved)", saved.Tools.Web.DuckDuckGo.MaxResults)
|
|
}
|
|
}
|
|
|
|
// --- #20: agents.defaults intra-section partial update ---
|
|
|
|
func TestHandlePutConfig_AgentsDefaultsPartialUpdate(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.MaxTokens = 8192
|
|
cfg.Agents.Defaults.Workspace = "/my/workspace"
|
|
cfg.Agents.Defaults.Temperature = 0.5
|
|
cfg.Agents.Defaults.ContextWindow = 128000
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
body := `{"agents":{"defaults":{"max_tokens":4096}}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(body))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
saved, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
if saved.Agents.Defaults.MaxTokens != 4096 {
|
|
t.Errorf("max_tokens = %d, want 4096", saved.Agents.Defaults.MaxTokens)
|
|
}
|
|
if saved.Agents.Defaults.Workspace != "/my/workspace" {
|
|
t.Errorf("workspace = %q, want preserved", saved.Agents.Defaults.Workspace)
|
|
}
|
|
if saved.Agents.Defaults.Temperature != 0.5 {
|
|
t.Errorf("temperature = %v, want 0.5 (preserved)", saved.Agents.Defaults.Temperature)
|
|
}
|
|
if saved.Agents.Defaults.ContextWindow != 128000 {
|
|
t.Errorf("context_window = %d, want 128000 (preserved)", saved.Agents.Defaults.ContextWindow)
|
|
}
|
|
}
|
|
|
|
// --- #21: Empty body (0 bytes, not {}) ---
|
|
|
|
func TestHandlePutConfig_EmptyBodyZeroBytes(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(""))
|
|
s.handlePutConfig(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status = %d, want 400 for empty body", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- #22: PUT → GET round-trip ---
|
|
|
|
func TestHandlePutConfig_ThenGetRoundTrip(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.LLM.APIKey = "my-secret"
|
|
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
|
config.SaveConfig(cfgPath, cfg)
|
|
|
|
s := &Server{cfg: cfg, configPath: cfgPath}
|
|
|
|
// PUT to change model (only send model — api_key preserved via partial update)
|
|
putBody := `{"llm":{"model":"new-model"}}`
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(putBody))
|
|
s.handlePutConfig(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("PUT status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
// Load saved config into a new server to simulate restart
|
|
savedCfg, err := config.LoadConfig(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig error: %v", err)
|
|
}
|
|
s2 := &Server{cfg: savedCfg, configPath: cfgPath}
|
|
|
|
// GET the config
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s2.handleGetConfig(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("GET status = %d, want 200", rr.Code)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
llm := result["llm"].(map[string]interface{})
|
|
if llm["model"] != "new-model" {
|
|
t.Errorf("GET model = %v, want %q", llm["model"], "new-model")
|
|
}
|
|
if llm["api_key"] != "my-secret" {
|
|
t.Errorf("GET api_key = %v, want %q", llm["api_key"], "my-secret")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Additional tests: Low priority
|
|
// =============================================================================
|
|
|
|
// --- #2: Stop() called twice ---
|
|
|
|
func TestServerStop_CalledTwice(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1
|
|
|
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
|
s.Start()
|
|
|
|
ctx := context.Background()
|
|
if err := s.Stop(ctx); err != nil {
|
|
t.Errorf("first Stop() error: %v", err)
|
|
}
|
|
// Second stop — server is already shut down
|
|
if err := s.Stop(ctx); err != nil {
|
|
// Acceptable: may return ErrServerClosed or nil
|
|
t.Logf("second Stop() returned: %v (acceptable)", err)
|
|
}
|
|
}
|
|
|
|
// --- #3: DELETE and PATCH → 405 ---
|
|
|
|
func TestServerRouting_UnsupportedMethods(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1
|
|
|
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
|
s.Start()
|
|
defer s.Stop(context.Background())
|
|
|
|
methods := []string{http.MethodDelete, http.MethodPatch}
|
|
for _, method := range methods {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(method, "/api/config", nil)
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("%s /api/config status = %d, want 405", method, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #4: Unknown path → 404 ---
|
|
|
|
func TestServerRouting_UnknownPath(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1
|
|
|
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
|
s.Start()
|
|
defer s.Stop(context.Background())
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/unknown", nil)
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Errorf("GET /api/unknown status = %d, want 404", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- #5: "bearer " lowercase → 401 ---
|
|
|
|
func TestAuthMiddleware_LowercaseBearer_401(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "bearer test-key")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401 for lowercase 'bearer'", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- #6: Token with surrounding whitespace ---
|
|
|
|
func TestAuthMiddleware_TokenWithWhitespace_403(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.APIKey = "test-key"
|
|
s := newTestServer(cfg)
|
|
|
|
handler := s.authMiddleware(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
|
req.Header.Set("Authorization", "Bearer test-key ")
|
|
handler(rr, req)
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
t.Errorf("status = %d, want 403 for token with trailing space", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- #7: Auth applied to GET /api/config/schema via routing ---
|
|
|
|
func TestServerRouting_SchemaAuthRequired(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Gateway.Host = "127.0.0.1"
|
|
cfg.Gateway.Port = -1
|
|
cfg.Gateway.APIKey = "schema-auth-key"
|
|
|
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
|
s.Start()
|
|
defer s.Stop(context.Background())
|
|
|
|
// Without auth
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config/schema", nil)
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Errorf("GET /api/config/schema without auth status = %d, want 401", rr.Code)
|
|
}
|
|
|
|
// With correct auth
|
|
rr = httptest.NewRecorder()
|
|
req = httptest.NewRequest(http.MethodGet, "/api/config/schema", nil)
|
|
req.Header.Set("Authorization", "Bearer schema-auth-key")
|
|
s.server.Handler.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Errorf("GET /api/config/schema with auth status = %d, want 200", rr.Code)
|
|
}
|
|
}
|
|
|
|
// --- #9: WhatsApp bridge_url not masked ---
|
|
|
|
func TestHandleGetConfig_WhatsAppBridgeURLNotMasked(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
cfg.Channels.WhatsApp.BridgeURL = "ws://localhost:3001"
|
|
|
|
s := newTestServer(cfg)
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
wa := result["channels"].(map[string]interface{})["whatsapp"].(map[string]interface{})
|
|
if wa["bridge_url"] != "ws://localhost:3001" {
|
|
t.Errorf("bridge_url = %v, should NOT be masked", wa["bridge_url"])
|
|
}
|
|
}
|
|
|
|
// --- #12: GET returns all 7 sections ---
|
|
|
|
func TestHandleGetConfig_AllSectionsPresent(t *testing.T) {
|
|
cfg := config.DefaultConfig()
|
|
s := newTestServer(cfg)
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/config", nil)
|
|
s.handleGetConfig(rr, req)
|
|
|
|
var result map[string]interface{}
|
|
json.NewDecoder(rr.Body).Decode(&result)
|
|
|
|
wantSections := []string{"llm", "agents", "channels", "gateway", "tools", "heartbeat", "rate_limits"}
|
|
for _, sec := range wantSections {
|
|
if _, ok := result[sec]; !ok {
|
|
t.Errorf("section %q missing from GET /api/config response", sec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #28: agents section all fields in schema ---
|
|
|
|
func TestBuildSchema_AgentsSectionFields(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
var agents *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "agents" {
|
|
agents = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if agents == nil {
|
|
t.Fatal("agents section not found")
|
|
}
|
|
|
|
keySet := map[string]bool{}
|
|
for _, f := range agents.Fields {
|
|
keySet[f.Key] = true
|
|
}
|
|
|
|
wantKeys := []string{
|
|
"defaults.workspace",
|
|
"defaults.data_dir",
|
|
"defaults.restrict_to_workspace",
|
|
"defaults.max_tokens",
|
|
"defaults.context_window",
|
|
"defaults.temperature",
|
|
"defaults.max_tool_iterations",
|
|
}
|
|
for _, k := range wantKeys {
|
|
if !keySet[k] {
|
|
t.Errorf("agents field %q not found, available: %v", k, keys(keySet))
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #28b: directory type override ---
|
|
|
|
func TestBuildSchema_DirectoryType(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
// Find the agents section which contains the defaults sub-fields
|
|
var agentsFields []SchemaField
|
|
for _, sec := range schema.Sections {
|
|
if sec.Key == "agents" {
|
|
agentsFields = sec.Fields
|
|
break
|
|
}
|
|
}
|
|
if agentsFields == nil {
|
|
t.Fatal("agents section not found in schema")
|
|
}
|
|
|
|
fieldByKey := make(map[string]SchemaField)
|
|
for _, f := range agentsFields {
|
|
fieldByKey[f.Key] = f
|
|
}
|
|
|
|
dirFields := []string{"defaults.workspace", "defaults.data_dir"}
|
|
for _, k := range dirFields {
|
|
f, ok := fieldByKey[k]
|
|
if !ok {
|
|
t.Errorf("field %q not found in agents section", k)
|
|
continue
|
|
}
|
|
if f.Type != "directory" {
|
|
t.Errorf("field %q: want type %q, got %q", k, "directory", f.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #29: rate_limits section fields in schema ---
|
|
|
|
func TestBuildSchema_RateLimitsSectionFields(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
var rl *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "rate_limits" {
|
|
rl = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if rl == nil {
|
|
t.Fatal("rate_limits section not found")
|
|
}
|
|
|
|
keySet := map[string]bool{}
|
|
for _, f := range rl.Fields {
|
|
keySet[f.Key] = true
|
|
}
|
|
|
|
wantKeys := []string{"max_tool_calls_per_minute", "max_requests_per_minute"}
|
|
for _, k := range wantKeys {
|
|
if !keySet[k] {
|
|
t.Errorf("rate_limits field %q not found", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #30: gateway section all fields in schema ---
|
|
|
|
func TestBuildSchema_GatewaySectionFields(t *testing.T) {
|
|
schema := BuildSchema(config.DefaultConfig())
|
|
|
|
var gw *SchemaSection
|
|
for i := range schema.Sections {
|
|
if schema.Sections[i].Key == "gateway" {
|
|
gw = &schema.Sections[i]
|
|
break
|
|
}
|
|
}
|
|
if gw == nil {
|
|
t.Fatal("gateway section not found")
|
|
}
|
|
|
|
keySet := map[string]bool{}
|
|
for _, f := range gw.Fields {
|
|
keySet[f.Key] = true
|
|
}
|
|
|
|
wantKeys := []string{"host", "port", "api_key"}
|
|
for _, k := range wantKeys {
|
|
if !keySet[k] {
|
|
t.Errorf("gateway field %q not found", k)
|
|
}
|
|
}
|
|
|
|
// api_key should be secret
|
|
for _, f := range gw.Fields {
|
|
if f.Key == "api_key" && !f.Secret {
|
|
t.Error("gateway api_key should be marked as secret")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #31: snakeToTitle with long compound words ---
|
|
|
|
func TestSnakeToTitle_LongCompound(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"channel_access_token", "Channel Access Token"},
|
|
{"max_tool_calls_per_minute", "Max Tool Calls Per Minute"},
|
|
{"restrict_to_workspace", "Restrict To Workspace"},
|
|
}
|
|
for _, tc := range tests {
|
|
got := snakeToTitle(tc.input)
|
|
if got != tc.want {
|
|
t.Errorf("snakeToTitle(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- #32: snakeToTitle with empty string ---
|
|
|
|
func TestSnakeToTitle_EmptyString(t *testing.T) {
|
|
got := snakeToTitle("")
|
|
if got != "" {
|
|
t.Errorf("snakeToTitle(\"\") = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
// --- #33: snakeToTitle with "mcp" acronym ---
|
|
|
|
func TestSnakeToTitle_MCPAcronym(t *testing.T) {
|
|
got := snakeToTitle("mcp")
|
|
if got != "MCP" {
|
|
t.Errorf("snakeToTitle(\"mcp\") = %q, want %q", got, "MCP")
|
|
}
|
|
}
|
|
|
|
// --- #34: goTypeToSchema unknown type ---
|
|
|
|
func TestGoTypeToSchema_UnknownType(t *testing.T) {
|
|
// chan int should return "any"
|
|
ct := reflect.TypeOf(make(chan int))
|
|
got := goTypeToSchema(ct)
|
|
if got != "any" {
|
|
t.Errorf("goTypeToSchema(chan int) = %q, want %q", got, "any")
|
|
}
|
|
}
|
|
|
|
// --- #35: goTypeToSchema []int → "[]any" ---
|
|
|
|
func TestGoTypeToSchema_IntSlice(t *testing.T) {
|
|
ct := reflect.TypeOf([]int{})
|
|
got := goTypeToSchema(ct)
|
|
if got != "[]any" {
|
|
t.Errorf("goTypeToSchema([]int) = %q, want %q", got, "[]any")
|
|
}
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func newTestServer(cfg *config.Config) *Server {
|
|
return &Server{
|
|
cfg: cfg,
|
|
configPath: "/tmp/test-config.json",
|
|
}
|
|
}
|
|
|
|
// allFields collects all fields from all sections, using the leaf key for lookup.
|
|
func allFields(schema SchemaResponse) map[string]SchemaField {
|
|
m := make(map[string]SchemaField)
|
|
for _, sec := range schema.Sections {
|
|
for _, f := range sec.Fields {
|
|
// Use the leaf key (after last dot)
|
|
key := f.Key
|
|
if idx := lastDot(key); idx >= 0 {
|
|
key = key[idx+1:]
|
|
}
|
|
m[key] = f
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func keys(m map[string]bool) []string {
|
|
var out []string
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func lastDot(s string) int {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
if s[i] == '.' {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|