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() router.Use(testAdminAuth) // Add auth middleware for tests 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", "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) { 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 }