package api import ( "bytes" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "github.com/sipeed/picoclaw/pkg/config" ) func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "version": 1, "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" } }, "model_list": [ { "model_name": "custom-default", "model": "openai/gpt-4o", "api_keys": ["sk-default"] } ] }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if !cfg.Tools.Exec.AllowRemote { t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") } } func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "agents": { "defaults": { "workspace": "~/.picoclaw/workspace" } }, "model_list": [ { "model_name": "custom-default", "model": "openai/gpt-4o", "api_key": "sk-default" } ] }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } cfg, err := config.LoadConfig(configPath) if err != nil { t.Fatalf("LoadConfig() error = %v", err) } if got := cfg.ModelList[0].APIBase; got != "" { t.Fatalf("model_list[0].api_base = %q, want empty string", got) } } func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "custom_deny_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) { t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String()) } } func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "enabled": false, "custom_deny_patterns": ["("], "custom_allow_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } // setupPicoEnabledEnv creates a test environment with Pico channel enabled and // its token stored only in .security.yml (not in the JSON payload). func setupPicoEnabledEnv(t *testing.T) (string, func()) { t.Helper() tmp := t.TempDir() oldHome := os.Getenv("HOME") oldPicoHome := os.Getenv("PICOCLAW_HOME") if err := os.Setenv("HOME", tmp); err != nil { t.Fatalf("set HOME: %v", err) } if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil { t.Fatalf("set PICOCLAW_HOME: %v", err) } cfg := config.DefaultConfig() cfg.ModelList = []*config.ModelConfig{{ ModelName: "custom-default", Model: "openai/gpt-4o", }} cfg.Agents.Defaults.ModelName = "custom-default" cfg.Channels.Pico.Enabled = true cfg.WithSecurity(&config.SecurityConfig{ ModelList: map[string]config.ModelSecurityEntry{ "custom-default": {APIKeys: []string{"sk-default"}}, }, Channels: &config.ChannelsSecurity{ Pico: &config.PicoSecurity{Token: "test-pico-token"}, }, }) configPath := filepath.Join(tmp, "config.json") if err := config.SaveConfig(configPath, cfg); err != nil { t.Fatalf("SaveConfig error: %v", err) } cleanup := func() { _ = os.Setenv("HOME", oldHome) if oldPicoHome == "" { _ = os.Unsetenv("PICOCLAW_HOME") } else { _ = os.Setenv("PICOCLAW_HOME", oldPicoHome) } } return configPath, cleanup } func TestHandleUpdateConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { configPath, cleanup := setupPicoEnabledEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) // PUT request with pico enabled but no token in JSON — token is in .security.yml req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ "version": 1, "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", "model_name": "custom-default" } }, "channels": { "pico": { "enabled": true, "ping_interval": 30, "read_timeout": 60, "write_timeout": 10, "max_connections": 100 } }, "model_list": [ { "model_name": "custom-default", "model": "openai/gpt-4o", "api_keys": ["sk-default"] } ] }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PUT /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) { configPath, cleanup := setupPicoEnabledEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) // PATCH request changing an unrelated field — pico token still in .security.yml req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "gateway": { "log_level": "info" } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } } func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{ "tools": { "exec": { "enabled": true, "enable_deny_patterns": false, "custom_deny_patterns": ["("] } } }`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } }