mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
a4574f72a3
* fix: save Discord token updates from channel settings - preserve secret fields from PUT/PATCH /api/config payloads via setters - include _token edit fields in channel save payload construction - add regression test for Discord token patch flow (issue #2005) * fix: resolve shadow lint warnings in config secret mapping * fix(web/api): adapt config secret patch path after #2068 --------- Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
482 lines
14 KiB
Go
482 lines
14 KiB
Go
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",
|
|
APIKeys: config.SimpleSecureStrings("sk-default"),
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "custom-default"
|
|
cfg.Channels.Pico.Enabled = true
|
|
cfg.Channels.Pico.Token = *config.NewSecureString("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_SavesDiscordTokenFromPayload(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(`{
|
|
"channels": {
|
|
"discord": {
|
|
"enabled": true,
|
|
"token": "discord-test-token"
|
|
}
|
|
}
|
|
}`))
|
|
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())
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
if !cfg.Channels.Discord.Enabled {
|
|
t.Fatal("discord should be enabled after PATCH")
|
|
}
|
|
if got := cfg.Channels.Discord.Token.String(); got != "discord-test-token" {
|
|
t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
}
|