Major refactoring to hexagonal (ports & adapters) architecture: - Add service layer (apikey_service, project_service) for business logic - Add webhook system with dispatcher and delivery tracking - Add command queue with priority-based processing - Add rate limiting with sliding window algorithm - Add audit logging for command execution - Add OpenTelemetry integration (traces, metrics, spans) - Add circuit breaker for fault tolerance - Add cached repository wrapper for performance - Add comprehensive validation package - Add Kubernetes client integration for pod management - Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks) - Add network policy and PodDisruptionBudget for k8s - Remove legacy executor and projects/registry packages - Untrack secrets.yaml (now managed via envault) - Add coverage.out to .gitignore - Add e2e test infrastructure with docker-compose - Add comprehensive documentation (API, architecture, operations, plans) - Add golangci-lint config and pre-commit hook Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1167 lines
33 KiB
Go
1167 lines
33 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/validate"
|
|
)
|
|
|
|
// MockSimpleExecutor mocks the executor for testing ClaudeConfigHandler.
|
|
// It focuses on ExecSimple which is the method used by the handler.
|
|
type MockSimpleExecutor struct {
|
|
execSimpleCalls []ExecSimpleCall
|
|
execSimpleResults map[string]ExecSimpleResult
|
|
defaultResult ExecSimpleResult
|
|
}
|
|
|
|
// ExecSimpleCall records the parameters of an ExecSimple call.
|
|
type ExecSimpleCall struct {
|
|
PodName string
|
|
Command string
|
|
}
|
|
|
|
// ExecSimpleResult represents the result of an ExecSimple call.
|
|
type ExecSimpleResult struct {
|
|
Output string
|
|
Err error
|
|
}
|
|
|
|
// NewMockSimpleExecutor creates a new mock executor.
|
|
func NewMockSimpleExecutor() *MockSimpleExecutor {
|
|
return &MockSimpleExecutor{
|
|
execSimpleResults: make(map[string]ExecSimpleResult),
|
|
}
|
|
}
|
|
|
|
// ExecSimple mocks command execution.
|
|
func (m *MockSimpleExecutor) ExecSimple(podName, command string) (string, error) {
|
|
m.execSimpleCalls = append(m.execSimpleCalls, ExecSimpleCall{
|
|
PodName: podName,
|
|
Command: command,
|
|
})
|
|
|
|
// Check for specific command result first
|
|
key := podName + ":" + command
|
|
if result, ok := m.execSimpleResults[key]; ok {
|
|
return result.Output, result.Err
|
|
}
|
|
|
|
// Check for pattern match (e.g., for cat commands with any path)
|
|
for pattern, result := range m.execSimpleResults {
|
|
if strings.Contains(command, pattern) {
|
|
return result.Output, result.Err
|
|
}
|
|
}
|
|
|
|
return m.defaultResult.Output, m.defaultResult.Err
|
|
}
|
|
|
|
// SetResult sets the result for a specific pod+command combination.
|
|
func (m *MockSimpleExecutor) SetResult(podName, command string, output string, err error) {
|
|
key := podName + ":" + command
|
|
m.execSimpleResults[key] = ExecSimpleResult{Output: output, Err: err}
|
|
}
|
|
|
|
// SetPatternResult sets the result for any command containing the pattern.
|
|
func (m *MockSimpleExecutor) SetPatternResult(pattern string, output string, err error) {
|
|
m.execSimpleResults[pattern] = ExecSimpleResult{Output: output, Err: err}
|
|
}
|
|
|
|
// SetDefaultResult sets the default result for any command not explicitly configured.
|
|
func (m *MockSimpleExecutor) SetDefaultResult(output string, err error) {
|
|
m.defaultResult = ExecSimpleResult{Output: output, Err: err}
|
|
}
|
|
|
|
// GetCalls returns all recorded ExecSimple calls.
|
|
func (m *MockSimpleExecutor) GetCalls() []ExecSimpleCall {
|
|
return m.execSimpleCalls
|
|
}
|
|
|
|
// Reset clears all recorded calls and results.
|
|
func (m *MockSimpleExecutor) Reset() {
|
|
m.execSimpleCalls = nil
|
|
m.execSimpleResults = make(map[string]ExecSimpleResult)
|
|
m.defaultResult = ExecSimpleResult{}
|
|
}
|
|
|
|
// --- Tests for validate.Name ---
|
|
|
|
func TestValidateName(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
wantErr bool
|
|
}{
|
|
// Valid names
|
|
{"simple lowercase", "mycommand", false},
|
|
{"with dashes", "my-command", false},
|
|
{"with underscores", "my_command", false},
|
|
{"with numbers", "command123", false},
|
|
{"mixed case", "MyCommand", false},
|
|
{"complex valid", "My-Command_123", false},
|
|
{"single char", "a", false},
|
|
{"numbers only", "123", false},
|
|
{"64 chars", strings.Repeat("a", 64), false},
|
|
|
|
// Invalid names
|
|
{"empty string", "", true},
|
|
{"65 chars", strings.Repeat("a", 65), true},
|
|
{"100 chars", strings.Repeat("a", 100), true},
|
|
{"with spaces", "my command", true},
|
|
{"with dots", "my.command", true},
|
|
{"path traversal", "../etc", true},
|
|
{"double path traversal", "../../etc", true},
|
|
{"with slash", "path/to/file", true},
|
|
{"with backslash", "path\\to\\file", true},
|
|
{"with semicolon", "cmd;rm", true},
|
|
{"with pipe", "cmd|cat", true},
|
|
{"with backtick", "cmd`whoami`", true},
|
|
{"with dollar", "$HOME", true},
|
|
{"with ampersand", "cmd&cmd", true},
|
|
{"with newline", "cmd\ncmd", true},
|
|
{"with tab", "cmd\tcmd", true},
|
|
{"with null byte", "cmd\x00cmd", true},
|
|
{"unicode chars", "command\u00e9", true},
|
|
{"emoji", "command\U0001F600", true},
|
|
{"leading dash", "-command", false}, // Actually valid per regex
|
|
{"leading underscore", "_command", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validate.Name(tt.input, "name")
|
|
gotErr := err != nil
|
|
if gotErr != tt.wantErr {
|
|
t.Errorf("validate.Name(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Integration tests for HTTP handlers ---
|
|
|
|
// setupTestRouter creates a chi router with the handler mounted.
|
|
// Since we can't easily mock the executor in the current design,
|
|
// these tests will verify the validation and error handling paths.
|
|
func setupTestRouter(t *testing.T) (*chi.Mux, *kubernetes.ProjectRepository) {
|
|
t.Helper()
|
|
|
|
// Create a repository with test projects
|
|
repo := kubernetes.NewProjectRepository("test-namespace")
|
|
_ = repo.Register(context.Background(), &domain.Project{
|
|
ID: "test-project",
|
|
Name: "Test Project",
|
|
Description: "A test project",
|
|
PodName: "test-pod-0",
|
|
Status: domain.ProjectStatusRunning,
|
|
Workspace: "/workspace",
|
|
})
|
|
|
|
// Create executor (will fail on actual kubectl calls in tests, but
|
|
// we can test validation logic that happens before executor calls)
|
|
exec := kubernetes.NewExecutor("test-namespace")
|
|
|
|
handler := NewClaudeConfigHandler(repo, exec)
|
|
router := chi.NewRouter()
|
|
handler.Mount(router)
|
|
|
|
return router, repo
|
|
}
|
|
|
|
// --- Tests for project not found scenarios ---
|
|
|
|
func TestClaudeConfigHandler_ProjectNotFound(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"Overview", "GET", "/projects/nonexistent/claude-config", ""},
|
|
{"ListCommands", "GET", "/projects/nonexistent/claude-config/commands", ""},
|
|
{"ListSkills", "GET", "/projects/nonexistent/claude-config/skills", ""},
|
|
{"ListAgents", "GET", "/projects/nonexistent/claude-config/agents", ""},
|
|
{"CreateCommand", "POST", "/projects/nonexistent/claude-config/commands", `{"name":"test","content":"test"}`},
|
|
{"GetCommand", "GET", "/projects/nonexistent/claude-config/commands/test", ""},
|
|
{"UpdateCommand", "PUT", "/projects/nonexistent/claude-config/commands/test", `{"content":"test"}`},
|
|
{"DeleteCommand", "DELETE", "/projects/nonexistent/claude-config/commands/test", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var body *bytes.Reader
|
|
if tt.body != "" {
|
|
body = bytes.NewReader([]byte(tt.body))
|
|
} else {
|
|
body = bytes.NewReader(nil)
|
|
}
|
|
|
|
req := httptest.NewRequest(tt.method, tt.path, body)
|
|
if tt.body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("Status = %d, want 404. Body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), "project not found") {
|
|
t.Errorf("Body = %q, want to contain 'project not found'", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for invalid name validation ---
|
|
|
|
func TestClaudeConfigHandler_InvalidName(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
// These invalid names will reach the handler validation
|
|
// (names with slashes or empty get rejected by the router first with 404)
|
|
handlerRejectedNames := []string{
|
|
strings.Repeat("a", 65), // Too long
|
|
"cmd;injection", // Invalid characters
|
|
"$variable", // Invalid characters
|
|
}
|
|
|
|
endpoints := []struct {
|
|
method string
|
|
pathPattern string
|
|
needsBody bool
|
|
body string
|
|
}{
|
|
{"GET", "/projects/test-project/claude-config/commands/%s", false, ""},
|
|
{"PUT", "/projects/test-project/claude-config/commands/%s", true, `{"content":"test"}`},
|
|
{"DELETE", "/projects/test-project/claude-config/commands/%s", false, ""},
|
|
{"GET", "/projects/test-project/claude-config/skills/%s", false, ""},
|
|
{"PUT", "/projects/test-project/claude-config/skills/%s", true, `{"content":"test"}`},
|
|
{"DELETE", "/projects/test-project/claude-config/skills/%s", false, ""},
|
|
{"GET", "/projects/test-project/claude-config/agents/%s", false, ""},
|
|
{"PUT", "/projects/test-project/claude-config/agents/%s", true, `{"content":"test"}`},
|
|
{"DELETE", "/projects/test-project/claude-config/agents/%s", false, ""},
|
|
}
|
|
|
|
for _, invalidName := range handlerRejectedNames {
|
|
for _, ep := range endpoints {
|
|
testName := fmt.Sprintf("%s %s with name=%q", ep.method, strings.Split(ep.pathPattern, "/")[4], invalidName)
|
|
t.Run(testName, func(t *testing.T) {
|
|
path := fmt.Sprintf(ep.pathPattern, invalidName)
|
|
|
|
var body *bytes.Reader
|
|
if ep.needsBody {
|
|
body = bytes.NewReader([]byte(ep.body))
|
|
} else {
|
|
body = bytes.NewReader(nil)
|
|
}
|
|
|
|
req := httptest.NewRequest(ep.method, path, body)
|
|
if ep.needsBody {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, want 400. Body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// validate.Name returns errors like "name: must be at most 64 characters" or "name: must be alphanumeric..."
|
|
if !strings.Contains(rec.Body.String(), "name:") {
|
|
t.Errorf("Body = %q, want to contain 'name:'", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestClaudeConfigHandler_RouterRejectedNames tests that names with slashes
|
|
// or empty names are rejected at the router level (404 not found).
|
|
// This is expected behavior - chi router doesn't match paths with slashes in params.
|
|
func TestClaudeConfigHandler_RouterRejectedNames(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
// These names get rejected by the chi router before reaching the handler
|
|
routerRejectedNames := []string{
|
|
"", // Empty - doesn't match route
|
|
"../../etc", // Path traversal with slashes
|
|
"path/traversal", // Contains slash
|
|
}
|
|
|
|
for _, name := range routerRejectedNames {
|
|
t.Run("router rejects: "+name, func(t *testing.T) {
|
|
path := fmt.Sprintf("/projects/test-project/claude-config/commands/%s", name)
|
|
req := httptest.NewRequest("GET", path, nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
// Router returns 404 for unmatched paths
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("Status = %d, want 404", rec.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for createItem validation ---
|
|
|
|
func TestClaudeConfigHandler_CreateValidation(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantStatus int
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name",
|
|
body: `{"content":"test content"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "name: is required",
|
|
},
|
|
{
|
|
name: "empty name",
|
|
body: `{"name":"","content":"test content"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "name: is required",
|
|
},
|
|
{
|
|
name: "missing content",
|
|
body: `{"name":"test-command"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "content: is required",
|
|
},
|
|
{
|
|
name: "empty content",
|
|
body: `{"name":"test-command","content":""}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "content: is required",
|
|
},
|
|
{
|
|
name: "invalid name characters",
|
|
body: `{"name":"../etc/passwd","content":"test"}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "alphanumeric",
|
|
},
|
|
{
|
|
name: "name too long",
|
|
body: fmt.Sprintf(`{"name":"%s","content":"test"}`, strings.Repeat("a", 65)),
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "must be at most 64 characters",
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
body: `{"name": invalid}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "invalid",
|
|
},
|
|
}
|
|
|
|
itemTypes := []string{"commands", "skills", "agents"}
|
|
|
|
for _, itemType := range itemTypes {
|
|
for _, tt := range tests {
|
|
testName := fmt.Sprintf("%s/%s", itemType, tt.name)
|
|
t.Run(testName, func(t *testing.T) {
|
|
path := fmt.Sprintf("/projects/test-project/claude-config/%s", itemType)
|
|
req := httptest.NewRequest("POST", path, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("Status = %d, want %d. Body: %s", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), tt.wantErr) {
|
|
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tests for updateItem validation ---
|
|
|
|
func TestClaudeConfigHandler_UpdateValidation(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
itemName string
|
|
body string
|
|
wantStatus int
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing content",
|
|
itemName: "valid-name",
|
|
body: `{}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "content: is required",
|
|
},
|
|
{
|
|
name: "empty content",
|
|
itemName: "valid-name",
|
|
body: `{"content":""}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "content: is required",
|
|
},
|
|
{
|
|
name: "invalid JSON",
|
|
itemName: "valid-name",
|
|
body: `{content: invalid}`,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantErr: "invalid",
|
|
},
|
|
}
|
|
|
|
itemTypes := []string{"commands", "skills", "agents"}
|
|
|
|
for _, itemType := range itemTypes {
|
|
for _, tt := range tests {
|
|
testName := fmt.Sprintf("%s/%s", itemType, tt.name)
|
|
t.Run(testName, func(t *testing.T) {
|
|
path := fmt.Sprintf("/projects/test-project/claude-config/%s/%s", itemType, tt.itemName)
|
|
req := httptest.NewRequest("PUT", path, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != tt.wantStatus {
|
|
t.Errorf("Status = %d, want %d. Body: %s", rec.Code, tt.wantStatus, rec.Body.String())
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), tt.wantErr) {
|
|
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tests for content size limits ---
|
|
|
|
func TestClaudeConfigHandler_ContentSizeLimit(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
// Create content just over 1MB
|
|
largeContent := strings.Repeat("x", maxContentSize+1)
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{
|
|
name: "create command with oversized content",
|
|
method: "POST",
|
|
path: "/projects/test-project/claude-config/commands",
|
|
body: fmt.Sprintf(`{"name":"test","content":"%s"}`, largeContent),
|
|
},
|
|
{
|
|
name: "update command with oversized content",
|
|
method: "PUT",
|
|
path: "/projects/test-project/claude-config/commands/test",
|
|
body: fmt.Sprintf(`{"content":"%s"}`, largeContent),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, want 400", rec.Code)
|
|
}
|
|
|
|
if !strings.Contains(rec.Body.String(), "too large") && !strings.Contains(rec.Body.String(), "invalid") {
|
|
t.Errorf("Body = %q, want to contain 'too large' or 'invalid'", rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for valid names that should be accepted ---
|
|
|
|
func TestClaudeConfigHandler_ValidNames(t *testing.T) {
|
|
validNames := []string{
|
|
"my-command",
|
|
"skill_123",
|
|
"AgentOne",
|
|
"a",
|
|
"ABC",
|
|
"test-skill-v2",
|
|
"_private",
|
|
"123",
|
|
"cmd-v1_beta",
|
|
strings.Repeat("a", 64), // Max length
|
|
}
|
|
|
|
for _, name := range validNames {
|
|
t.Run("valid: "+name, func(t *testing.T) {
|
|
if err := validate.Name(name, "name"); err != nil {
|
|
t.Errorf("validate.Name(%q) returned error: %v, want nil", name, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for base64 encoding of content ---
|
|
|
|
func TestBase64Encoding(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"simple text", "Hello, world!"},
|
|
{"with quotes", `Say "hello"`},
|
|
{"with single quotes", "It's working"},
|
|
{"with backticks", "Run `command`"},
|
|
{"with dollar sign", "$HOME/path"},
|
|
{"with newlines", "line1\nline2\nline3"},
|
|
{"with shell chars", "echo $(whoami); rm -rf /"},
|
|
{"with heredoc terminator", "EOF\nContent\nEOF"},
|
|
{"with unicode", "Hello \u00e9\u00e8\u00ea"},
|
|
{"with null-like", "before\\x00after"},
|
|
{"complex markdown", "# Title\n\n```bash\necho 'test'\n```\n"},
|
|
{"json content", `{"key": "value", "nested": {"a": 1}}`},
|
|
{"xml-like", "<tag>content</tag>"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Encode
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(tt.content))
|
|
|
|
// Verify no shell-dangerous characters in encoded string
|
|
dangerousChars := []string{"`", "$", "(", ")", ";", "|", "&", ">", "<", "\n", "'", "\""}
|
|
for _, c := range dangerousChars {
|
|
if strings.Contains(encoded, c) {
|
|
t.Errorf("Encoded string contains dangerous char %q: %s", c, encoded)
|
|
}
|
|
}
|
|
|
|
// Verify we can decode back
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Errorf("Failed to decode: %v", err)
|
|
}
|
|
if string(decoded) != tt.content {
|
|
t.Errorf("Decoded = %q, want %q", string(decoded), tt.content)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for ConfigItem JSON serialization ---
|
|
|
|
func TestConfigItem_JSON(t *testing.T) {
|
|
item := ConfigItem{
|
|
Name: "test-command",
|
|
Type: "commands",
|
|
Content: "# Test Command\n\nThis is a test.",
|
|
}
|
|
|
|
data, err := json.Marshal(item)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal: %v", err)
|
|
}
|
|
|
|
var decoded ConfigItem
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if decoded.Name != item.Name {
|
|
t.Errorf("Name = %q, want %q", decoded.Name, item.Name)
|
|
}
|
|
if decoded.Type != item.Type {
|
|
t.Errorf("Type = %q, want %q", decoded.Type, item.Type)
|
|
}
|
|
if decoded.Content != item.Content {
|
|
t.Errorf("Content = %q, want %q", decoded.Content, item.Content)
|
|
}
|
|
}
|
|
|
|
// --- Tests for ConfigItemRequest JSON serialization ---
|
|
|
|
func TestConfigItemRequest_JSON(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
want ConfigItemRequest
|
|
}{
|
|
{
|
|
name: "full request",
|
|
json: `{"name":"test","content":"# Content"}`,
|
|
want: ConfigItemRequest{Name: "test", Content: "# Content"},
|
|
},
|
|
{
|
|
name: "content only",
|
|
json: `{"content":"# Content"}`,
|
|
want: ConfigItemRequest{Name: "", Content: "# Content"},
|
|
},
|
|
{
|
|
name: "empty strings",
|
|
json: `{"name":"","content":""}`,
|
|
want: ConfigItemRequest{Name: "", Content: ""},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var req ConfigItemRequest
|
|
if err := json.Unmarshal([]byte(tt.json), &req); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
if req.Name != tt.want.Name {
|
|
t.Errorf("Name = %q, want %q", req.Name, tt.want.Name)
|
|
}
|
|
if req.Content != tt.want.Content {
|
|
t.Errorf("Content = %q, want %q", req.Content, tt.want.Content)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for ConfigOverview JSON serialization ---
|
|
|
|
func TestConfigOverview_JSON(t *testing.T) {
|
|
overview := ConfigOverview{
|
|
Project: "test-project",
|
|
Path: "/workspace/.claude",
|
|
Commands: []string{"cmd1", "cmd2"},
|
|
Skills: []string{"skill1"},
|
|
Agents: []string{},
|
|
}
|
|
|
|
data, err := json.Marshal(overview)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal: %v", err)
|
|
}
|
|
|
|
var decoded ConfigOverview
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
|
|
if decoded.Project != overview.Project {
|
|
t.Errorf("Project = %q, want %q", decoded.Project, overview.Project)
|
|
}
|
|
if decoded.Path != overview.Path {
|
|
t.Errorf("Path = %q, want %q", decoded.Path, overview.Path)
|
|
}
|
|
if len(decoded.Commands) != len(overview.Commands) {
|
|
t.Errorf("Commands length = %d, want %d", len(decoded.Commands), len(overview.Commands))
|
|
}
|
|
if len(decoded.Skills) != len(overview.Skills) {
|
|
t.Errorf("Skills length = %d, want %d", len(decoded.Skills), len(overview.Skills))
|
|
}
|
|
if len(decoded.Agents) != len(overview.Agents) {
|
|
t.Errorf("Agents length = %d, want %d", len(decoded.Agents), len(overview.Agents))
|
|
}
|
|
}
|
|
|
|
// --- Tests for NewClaudeConfigHandler ---
|
|
|
|
func TestNewClaudeConfigHandler(t *testing.T) {
|
|
repo := kubernetes.NewProjectRepository("test-namespace")
|
|
exec := kubernetes.NewExecutor("test-namespace")
|
|
|
|
handler := NewClaudeConfigHandler(repo, exec)
|
|
|
|
if handler == nil {
|
|
t.Fatal("NewClaudeConfigHandler returned nil")
|
|
}
|
|
if handler.projectRepo != repo {
|
|
t.Error("Handler projectRepo not set correctly")
|
|
}
|
|
if handler.executor != exec {
|
|
t.Error("Handler executor not set correctly")
|
|
}
|
|
}
|
|
|
|
// --- Tests for route mounting ---
|
|
|
|
func TestClaudeConfigHandler_Mount(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
// Define all expected routes
|
|
routes := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
// Overview
|
|
{"GET", "/projects/test-project/claude-config"},
|
|
// Commands
|
|
{"GET", "/projects/test-project/claude-config/commands"},
|
|
{"POST", "/projects/test-project/claude-config/commands"},
|
|
{"GET", "/projects/test-project/claude-config/commands/test"},
|
|
{"PUT", "/projects/test-project/claude-config/commands/test"},
|
|
{"DELETE", "/projects/test-project/claude-config/commands/test"},
|
|
// Skills
|
|
{"GET", "/projects/test-project/claude-config/skills"},
|
|
{"POST", "/projects/test-project/claude-config/skills"},
|
|
{"GET", "/projects/test-project/claude-config/skills/test"},
|
|
{"PUT", "/projects/test-project/claude-config/skills/test"},
|
|
{"DELETE", "/projects/test-project/claude-config/skills/test"},
|
|
// Agents
|
|
{"GET", "/projects/test-project/claude-config/agents"},
|
|
{"POST", "/projects/test-project/claude-config/agents"},
|
|
{"GET", "/projects/test-project/claude-config/agents/test"},
|
|
{"PUT", "/projects/test-project/claude-config/agents/test"},
|
|
{"DELETE", "/projects/test-project/claude-config/agents/test"},
|
|
}
|
|
|
|
for _, rt := range routes {
|
|
t.Run(rt.method+" "+rt.path, func(t *testing.T) {
|
|
var body *strings.Reader
|
|
if rt.method == "POST" || rt.method == "PUT" {
|
|
body = strings.NewReader(`{"name":"test","content":"test content"}`)
|
|
} else {
|
|
body = strings.NewReader("")
|
|
}
|
|
|
|
req := httptest.NewRequest(rt.method, rt.path, body)
|
|
if rt.method == "POST" || rt.method == "PUT" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
// We expect either success, not found (project/item), or internal error (executor fails)
|
|
// but NOT 404 for route not found or 405 method not allowed
|
|
if rec.Code == http.StatusMethodNotAllowed {
|
|
t.Errorf("Route not mounted: %s %s", rt.method, rt.path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Tests for maxContentSize constant ---
|
|
|
|
func TestMaxContentSize(t *testing.T) {
|
|
// Verify maxContentSize is 1MB
|
|
expectedSize := 1 << 20 // 1MB = 1048576 bytes
|
|
if maxContentSize != expectedSize {
|
|
t.Errorf("maxContentSize = %d, want %d", maxContentSize, expectedSize)
|
|
}
|
|
}
|
|
|
|
// --- Tests for validate.AlphanumericDashUnderscore pattern ---
|
|
|
|
func TestAlphanumericDashUnderscorePattern(t *testing.T) {
|
|
// Test that the regex is compiled and available in validate package
|
|
if validate.AlphanumericDashUnderscore == nil {
|
|
t.Fatal("validate.AlphanumericDashUnderscore is nil")
|
|
}
|
|
|
|
// Test pattern matching directly
|
|
tests := []struct {
|
|
input string
|
|
want bool
|
|
}{
|
|
{"abc", true},
|
|
{"ABC", true},
|
|
{"a1b2c3", true},
|
|
{"a-b-c", true},
|
|
{"a_b_c", true},
|
|
{"", false},
|
|
{"a b", false},
|
|
{"a.b", false},
|
|
{"a/b", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := validate.AlphanumericDashUnderscore.MatchString(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("validate.AlphanumericDashUnderscore.MatchString(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Benchmark tests ---
|
|
|
|
func BenchmarkValidateName(b *testing.B) {
|
|
names := []string{
|
|
"my-command",
|
|
"skill_123",
|
|
"AgentOne",
|
|
"../../etc/passwd",
|
|
strings.Repeat("a", 64),
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
for _, name := range names {
|
|
_ = validate.Name(name, "name")
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkBase64Encode(b *testing.B) {
|
|
content := strings.Repeat("Test content with special chars: $()`, ", 100)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
base64.StdEncoding.EncodeToString([]byte(content))
|
|
}
|
|
}
|
|
|
|
// --- Edge case tests ---
|
|
|
|
func TestClaudeConfigHandler_EdgeCases(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
t.Run("empty request body", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/projects/test-project/claude-config/commands", strings.NewReader(""))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, want 400", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("null body", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/projects/test-project/claude-config/commands", strings.NewReader("null"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, want 400", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("array instead of object", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/projects/test-project/claude-config/commands", strings.NewReader(`["test"]`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Errorf("Status = %d, want 400", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("numeric name", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/projects/test-project/claude-config/commands/123", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
// 123 is a valid name, so we should get past validation
|
|
// (may fail at executor level but not at validation)
|
|
if rec.Code == http.StatusBadRequest {
|
|
t.Errorf("Status = 400, numeric names should be valid")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- Tests for error messages ---
|
|
|
|
func TestClaudeConfigHandler_ErrorMessages(t *testing.T) {
|
|
router, _ := setupTestRouter(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "project not found message",
|
|
method: "GET",
|
|
path: "/projects/xyz/claude-config",
|
|
wantMessage: "project not found: xyz",
|
|
},
|
|
{
|
|
name: "name required message",
|
|
method: "POST",
|
|
path: "/projects/test-project/claude-config/commands",
|
|
body: `{"content":"test"}`,
|
|
wantMessage: "name: is required",
|
|
},
|
|
{
|
|
name: "content required message",
|
|
method: "POST",
|
|
path: "/projects/test-project/claude-config/commands",
|
|
body: `{"name":"test"}`,
|
|
wantMessage: "content: is required",
|
|
},
|
|
{
|
|
name: "invalid name message",
|
|
method: "GET",
|
|
path: "/projects/test-project/claude-config/commands/" + strings.Repeat("x", 65),
|
|
wantMessage: "name: must be at most 64 characters",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var body *strings.Reader
|
|
if tt.body != "" {
|
|
body = strings.NewReader(tt.body)
|
|
} else {
|
|
body = strings.NewReader("")
|
|
}
|
|
|
|
req := httptest.NewRequest(tt.method, tt.path, body)
|
|
if tt.body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if !strings.Contains(rec.Body.String(), tt.wantMessage) {
|
|
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantMessage)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Security-focused tests ---
|
|
|
|
func TestClaudeConfigHandler_Security(t *testing.T) {
|
|
t.Run("path traversal in name parameter", func(t *testing.T) {
|
|
attacks := []string{
|
|
"../",
|
|
"..%2f",
|
|
"..%252f",
|
|
"....//",
|
|
"..;/",
|
|
".../",
|
|
"%2e%2e/",
|
|
}
|
|
|
|
for _, attack := range attacks {
|
|
// validate.Name should reject all of these
|
|
if err := validate.Name(attack, "name"); err == nil {
|
|
t.Errorf("validate.Name accepted path traversal: %q", attack)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("command injection in name", func(t *testing.T) {
|
|
attacks := []string{
|
|
"test; rm -rf /",
|
|
"test && whoami",
|
|
"test | cat /etc/passwd",
|
|
"test`whoami`",
|
|
"$(whoami)",
|
|
"${PATH}",
|
|
"test\nmalicious",
|
|
}
|
|
|
|
for _, attack := range attacks {
|
|
if err := validate.Name(attack, "name"); err == nil {
|
|
t.Errorf("validate.Name accepted command injection: %q", attack)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("base64 prevents heredoc injection", func(t *testing.T) {
|
|
// This content would break heredoc if not base64 encoded
|
|
maliciousContent := `EOF
|
|
rm -rf /
|
|
EOF`
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(maliciousContent))
|
|
|
|
// Verify encoded content is safe for shell command
|
|
if strings.Contains(encoded, "EOF") {
|
|
t.Error("Base64 encoded content still contains 'EOF'")
|
|
}
|
|
if strings.Contains(encoded, "\n") {
|
|
t.Error("Base64 encoded content contains newline")
|
|
}
|
|
})
|
|
}
|
|
|
|
// --- MockableClaudeConfigHandler for testing with mock executor ---
|
|
|
|
// Since the actual handler uses *kubernetes.Executor which calls kubectl,
|
|
// we create a version that can use a mock for comprehensive testing.
|
|
|
|
// MockExecSimpler is an interface for the ExecSimple method only.
|
|
type MockExecSimpler interface {
|
|
ExecSimple(podName, command string) (string, error)
|
|
}
|
|
|
|
// testableClaudeConfigHandler wraps the logic for testing with a mock.
|
|
type testableClaudeConfigHandler struct {
|
|
projectRepo *kubernetes.ProjectRepository
|
|
execFn func(podName, command string) (string, error)
|
|
}
|
|
|
|
func (h *testableClaudeConfigHandler) listItems(pod, itemType string) []string {
|
|
cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType)
|
|
output, err := h.execFn(pod, cmd)
|
|
if err != nil {
|
|
return []string{}
|
|
}
|
|
|
|
items := []string{}
|
|
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
|
if line != "" {
|
|
items = append(items, line)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
|
|
func TestListItems_WithMock(t *testing.T) {
|
|
repo := kubernetes.NewProjectRepository("test")
|
|
_ = repo.Register(context.Background(), &domain.Project{ID: "test", PodName: "test-pod"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
output string
|
|
err error
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "empty directory",
|
|
output: "",
|
|
err: nil,
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: "single item",
|
|
output: "command1",
|
|
err: nil,
|
|
expected: []string{"command1"},
|
|
},
|
|
{
|
|
name: "multiple items",
|
|
output: "cmd1\ncmd2\ncmd3",
|
|
err: nil,
|
|
expected: []string{"cmd1", "cmd2", "cmd3"},
|
|
},
|
|
{
|
|
name: "with empty lines",
|
|
output: "cmd1\n\ncmd2\n",
|
|
err: nil,
|
|
expected: []string{"cmd1", "cmd2"},
|
|
},
|
|
{
|
|
name: "executor error",
|
|
output: "",
|
|
err: errors.New("pod not found"),
|
|
expected: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
h := &testableClaudeConfigHandler{
|
|
projectRepo: repo,
|
|
execFn: func(podName, command string) (string, error) {
|
|
return tt.output, tt.err
|
|
},
|
|
}
|
|
|
|
result := h.listItems("test-pod", "commands")
|
|
|
|
if len(result) != len(tt.expected) {
|
|
t.Errorf("len(result) = %d, want %d", len(result), len(tt.expected))
|
|
return
|
|
}
|
|
|
|
for i, item := range result {
|
|
if item != tt.expected[i] {
|
|
t.Errorf("result[%d] = %q, want %q", i, item, tt.expected[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Additional validation tests ---
|
|
|
|
func TestClaudeConfigHandler_ContentWithSpecialChars(t *testing.T) {
|
|
// Test that various special characters in content are properly handled
|
|
specialContents := []string{
|
|
"Content with 'single quotes'",
|
|
`Content with "double quotes"`,
|
|
"Content with `backticks`",
|
|
"Content with $variables",
|
|
"Content with $(command substitution)",
|
|
"Content with ${parameter expansion}",
|
|
"Content with\nnewlines\n",
|
|
"Content with\ttabs",
|
|
"Content with ; semicolons",
|
|
"Content with | pipes",
|
|
"Content with & ampersands",
|
|
"Content with > redirects",
|
|
"Content with < input redirects",
|
|
"Content with \\ backslashes",
|
|
"Content with emoji: \U0001F600",
|
|
}
|
|
|
|
for _, content := range specialContents {
|
|
t.Run("encoding: "+content[:min(20, len(content))], func(t *testing.T) {
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(content))
|
|
|
|
// Verify the encoded string is shell-safe
|
|
unsafeChars := []string{"'", "\"", "`", "$", "(", ")", "{", "}", ";", "|", "&", ">", "<", "\n", "\t"}
|
|
for _, c := range unsafeChars {
|
|
if strings.Contains(encoded, c) {
|
|
t.Errorf("Encoded string contains unsafe char %q", c)
|
|
}
|
|
}
|
|
|
|
// Verify round-trip
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
if err != nil {
|
|
t.Errorf("Decode error: %v", err)
|
|
}
|
|
if string(decoded) != content {
|
|
t.Errorf("Round-trip failed: got %q, want %q", string(decoded), content)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|