rdev/internal/handlers/claude_config_test.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

1168 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()
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", "<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
}