mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
1f9d390a64
- Move SecurityCopyFrom() before validateConfig() in PUT and PATCH handlers - Make SecurityCopyFrom() call applySecurityConfig() to populate private fields - Add tests for config save with security-only channel tokens Without this fix, saving config via the web UI fails with 'channels.pico.token is required' (and similar for Telegram/Discord) when tokens are stored in .security.yml, because the validation ran before security credentials were copied to the config struct.
285 lines
7.3 KiB
Go
285 lines
7.3 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",
|
|
}}
|
|
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())
|
|
}
|
|
}
|