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()) } } // testCommandPatterns is a helper that sets up a handler and sends a test-command-patterns request. func testCommandPatterns(t *testing.T, configPath string, body string) *httptest.ResponseRecorder { t.Helper() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest(http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() mux.ServeHTTP(rec, req) return rec } func TestHandleTestCommandPatterns_MatchesWhitelist(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": ["^echo\\s+hello"], "deny_patterns": ["^rm\\s+-rf"], "command": "echo hello world" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=true, body=%s", rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { t.Fatalf("expected blocked=false when whitelist matches, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_MatchesBlacklistNotWhitelist(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": ["^echo\\s+hello"], "deny_patterns": ["^rm\\s+-rf"], "command": "rm -rf /tmp" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=false when blacklist matches but not whitelist, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_MatchesNeither(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": ["^echo\\s+hello"], "deny_patterns": ["^rm\\s+-rf"], "command": "ls -la" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=false, body=%s", rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { t.Fatalf("expected blocked=false, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_CaseInsensitiveWithGoFlag(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": ["(?i)^ECHO"], "deny_patterns": [], "command": "echo hello" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=true with Go (?i) flag, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_EmptyPatterns(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": [], "deny_patterns": [], "command": "rm -rf /tmp" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=false with empty patterns, body=%s", rec.Body.String()) } if bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { t.Fatalf("expected blocked=false with empty patterns, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_InvalidRegexSkipped(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": ["([[", "^echo"], "deny_patterns": [], "command": "echo hello" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"allowed":true`)) { t.Fatalf("expected allowed=true, invalid pattern skipped and valid one matched, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_ReturnsMatchedPattern(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() rec := testCommandPatterns(t, configPath, `{ "allow_patterns": [], "deny_patterns": ["\\$(?i)[a-zA-Z_]*(SECRET|KEY|PASSWORD|TOKEN|AUTH)[a-zA-Z0-9_]*"], "command": "echo $GITHUB_API_KEY" }`) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`"blocked":true`)) { t.Fatalf("expected blocked=true, body=%s", rec.Body.String()) } if !bytes.Contains(rec.Body.Bytes(), []byte(`matched_blacklist`)) { t.Fatalf("expected matched_blacklist field, body=%s", rec.Body.String()) } } func TestHandleTestCommandPatterns_InvalidJSON(t *testing.T) { configPath, cleanup := setupOAuthTestEnv(t) defer cleanup() h := NewHandler(configPath) mux := http.NewServeMux() h.RegisterRoutes(mux) req := httptest.NewRequest( http.MethodPost, "/api/config/test-command-patterns", bytes.NewBufferString(`{invalid json}`), ) 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()) } }