diff --git a/internal/handlers/claude_config_test.go b/internal/handlers/claude_config_test.go new file mode 100644 index 0000000..95e9dc4 --- /dev/null +++ b/internal/handlers/claude_config_test.go @@ -0,0 +1,1185 @@ +package handlers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/orchard9/rdev/internal/executor" + "github.com/orchard9/rdev/internal/projects" +) + +// 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{} +} + +// mockExecutorWrapper wraps MockSimpleExecutor to implement the full Executor interface. +// This is needed because ClaudeConfigHandler expects *executor.Executor. +// We'll use a test-specific approach instead. + +// testClaudeConfigHandler creates a handler with mock capabilities for testing. +type testClaudeConfigHandler struct { + registry *projects.Registry + mock *MockSimpleExecutor +} + +func newTestClaudeConfigHandler() *testClaudeConfigHandler { + reg := projects.NewRegistry("test-namespace") + mock := NewMockSimpleExecutor() + return &testClaudeConfigHandler{ + registry: reg, + mock: mock, + } +} + +// Since ClaudeConfigHandler uses *executor.Executor directly, we need to refactor +// the test approach. Let's create tests that work with the actual handler structure +// but use dependency injection for the executor calls. + +// --- Tests for isValidName --- + +func TestIsValidName(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid names + {"simple lowercase", "mycommand", true}, + {"with dashes", "my-command", true}, + {"with underscores", "my_command", true}, + {"with numbers", "command123", true}, + {"mixed case", "MyCommand", true}, + {"complex valid", "My-Command_123", true}, + {"single char", "a", true}, + {"numbers only", "123", true}, + {"64 chars", strings.Repeat("a", 64), true}, + + // Invalid names + {"empty string", "", false}, + {"65 chars", strings.Repeat("a", 65), false}, + {"100 chars", strings.Repeat("a", 100), false}, + {"with spaces", "my command", false}, + {"with dots", "my.command", false}, + {"path traversal", "../etc", false}, + {"double path traversal", "../../etc", false}, + {"with slash", "path/to/file", false}, + {"with backslash", "path\\to\\file", false}, + {"with semicolon", "cmd;rm", false}, + {"with pipe", "cmd|cat", false}, + {"with backtick", "cmd`whoami`", false}, + {"with dollar", "$HOME", false}, + {"with ampersand", "cmd&cmd", false}, + {"with newline", "cmd\ncmd", false}, + {"with tab", "cmd\tcmd", false}, + {"with null byte", "cmd\x00cmd", false}, + {"unicode chars", "command\u00e9", false}, + {"emoji", "command\U0001F600", false}, + {"leading dash", "-command", true}, // Actually valid per regex + {"leading underscore", "_command", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidName(tt.input) + if got != tt.want { + t.Errorf("isValidName(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// --- 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, *projects.Registry) { + t.Helper() + + // Create a registry with test projects + reg := projects.NewRegistry("test-namespace") + reg.Register(&projects.Project{ + ID: "test-project", + Name: "Test Project", + Description: "A test project", + PodName: "test-pod-0", + Status: "running", + Workspace: "/workspace", + }) + + // Create executor (will fail on actual kubectl calls in tests, but + // we can test validation logic that happens before executor calls) + exec := executor.New("test-namespace") + + handler := NewClaudeConfigHandler(reg, exec) + router := chi.NewRouter() + handler.Mount(router) + + return router, reg +} + +// --- 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()) + } + + if !strings.Contains(rec.Body.String(), "invalid name") { + t.Errorf("Body = %q, want to contain 'invalid 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: "alphanumeric", + }, + { + 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 !isValidName(name) { + t.Errorf("isValidName(%q) = false, want true", name) + } + }) + } +} + +// --- 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", "content"}, + } + + 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) { + reg := projects.NewRegistry("test-namespace") + exec := executor.New("test-namespace") + + handler := NewClaudeConfigHandler(reg, exec) + + if handler == nil { + t.Fatal("NewClaudeConfigHandler returned nil") + } + if handler.registry != reg { + t.Error("Handler registry 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 validNameRegex pattern --- + +func TestValidNameRegex(t *testing.T) { + // Test that the regex is compiled and available + if validNameRegex == nil { + t.Fatal("validNameRegex 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 := validNameRegex.MatchString(tt.input) + if got != tt.want { + t.Errorf("validNameRegex.MatchString(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +// --- Benchmark tests --- + +func BenchmarkIsValidName(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 { + isValidName(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: "invalid name", + }, + } + + 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 { + // isValidName should reject all of these + if isValidName(attack) { + t.Errorf("isValidName 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 isValidName(attack) { + t.Errorf("isValidName 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 *executor.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 { + registry *projects.Registry + 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) { + reg := projects.NewRegistry("test") + reg.Register(&projects.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{ + registry: reg, + 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 +}