rdev/internal/handlers/projects_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

482 lines
12 KiB
Go

package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
)
// newTestProjectsHandler creates a ProjectsHandler for testing.
func newTestProjectsHandler() *ProjectsHandler {
repo := kubernetes.NewProjectRepository("test-namespace")
exec := kubernetes.NewExecutor("test-namespace")
return NewProjectsHandler(repo, exec)
}
// TestProjectsHandler_List tests the List endpoint.
func TestProjectsHandler_List(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
req := httptest.NewRequest("GET", "/projects", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Status = %d, want 200", rec.Code)
}
var resp map[string]any
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if _, ok := resp["data"]; !ok {
t.Error("Response missing 'data' field")
}
if _, ok := resp["meta"]; !ok {
t.Error("Response missing 'meta' field")
}
}
// TestProjectsHandler_Get tests the Get endpoint.
func TestProjectsHandler_Get(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
name string
projectID string
wantStatus int
}{
{"existing project", "pantheon", http.StatusOK},
{"non-existent project", "nonexistent", http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/projects/"+tt.projectID, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("Status = %d, want %d", rec.Code, tt.wantStatus)
}
})
}
}
// TestProjectsHandler_RunClaude tests the RunClaude endpoint.
func TestProjectsHandler_RunClaude(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
name string
projectID string
body any
wantStatus int
wantErr string
}{
{
name: "valid request",
projectID: "pantheon",
body: ClaudeRequest{
Prompt: "Hello, world!",
},
wantStatus: http.StatusCreated,
},
{
name: "missing prompt",
projectID: "pantheon",
body: ClaudeRequest{
Prompt: "",
},
wantStatus: http.StatusBadRequest,
wantErr: "prompt: is required",
},
{
name: "project not found",
projectID: "nonexistent",
body: ClaudeRequest{Prompt: "test"},
wantStatus: http.StatusNotFound,
},
{
name: "null byte in prompt",
projectID: "pantheon",
body: ClaudeRequest{
Prompt: "Hello\x00World",
},
wantStatus: http.StatusBadRequest,
wantErr: "null byte",
},
{
name: "invalid stream ID",
projectID: "pantheon",
body: ClaudeRequest{
Prompt: "Hello",
StreamID: "invalid stream id with spaces",
},
wantStatus: http.StatusBadRequest,
wantErr: "alphanumeric",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/projects/"+tt.projectID+"/claude", bytes.NewReader(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 tt.wantErr != "" {
if !strings.Contains(rec.Body.String(), tt.wantErr) {
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
}
}
})
}
}
// TestProjectsHandler_RunShell tests the RunShell endpoint.
func TestProjectsHandler_RunShell(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
name string
projectID string
body any
wantStatus int
wantErr string
}{
{
name: "valid command",
projectID: "pantheon",
body: ShellRequest{
Command: "ls -la",
},
wantStatus: http.StatusCreated,
},
{
name: "missing command",
projectID: "pantheon",
body: ShellRequest{
Command: "",
},
wantStatus: http.StatusBadRequest,
wantErr: "command: is required",
},
{
name: "dangerous command with semicolon",
projectID: "pantheon",
body: ShellRequest{
Command: "ls; rm -rf /",
},
wantStatus: http.StatusBadRequest,
wantErr: "command chaining",
},
{
name: "dangerous command with pipe",
projectID: "pantheon",
body: ShellRequest{
Command: "cat /etc/passwd | grep root",
},
wantStatus: http.StatusBadRequest,
wantErr: "command chaining",
},
{
name: "command substitution",
projectID: "pantheon",
body: ShellRequest{
Command: "echo $(whoami)",
},
wantStatus: http.StatusBadRequest,
wantErr: "command chaining",
},
{
name: "redirect",
projectID: "pantheon",
body: ShellRequest{
Command: "ls > /tmp/out.txt",
},
wantStatus: http.StatusBadRequest,
wantErr: "redirect",
},
{
name: "rm rf root",
projectID: "pantheon",
body: ShellRequest{
Command: "rm -rf /",
},
wantStatus: http.StatusBadRequest,
wantErr: "destructive rm",
},
{
name: "project not found",
projectID: "nonexistent",
body: ShellRequest{Command: "ls"},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/projects/"+tt.projectID+"/shell", bytes.NewReader(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 tt.wantErr != "" {
if !strings.Contains(rec.Body.String(), tt.wantErr) {
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
}
}
})
}
}
// TestProjectsHandler_RunGit tests the RunGit endpoint.
func TestProjectsHandler_RunGit(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
tests := []struct {
name string
projectID string
body any
wantStatus int
wantErr string
}{
{
name: "valid git status",
projectID: "pantheon",
body: GitRequest{
Args: []string{"status"},
},
wantStatus: http.StatusCreated,
},
{
name: "valid git log",
projectID: "pantheon",
body: GitRequest{
Args: []string{"log", "--oneline", "-10"},
},
wantStatus: http.StatusCreated,
},
{
name: "missing args",
projectID: "pantheon",
body: GitRequest{
Args: []string{},
},
wantStatus: http.StatusBadRequest,
wantErr: "args: is required",
},
{
name: "git config blocked",
projectID: "pantheon",
body: GitRequest{
Args: []string{"config", "--global", "user.name", "attacker"},
},
wantStatus: http.StatusBadRequest,
wantErr: "git config",
},
{
name: "git remote blocked",
projectID: "pantheon",
body: GitRequest{
Args: []string{"remote", "add", "evil", "https://evil.com/repo"},
},
wantStatus: http.StatusBadRequest,
wantErr: "git remote",
},
{
name: "force push blocked",
projectID: "pantheon",
body: GitRequest{
Args: []string{"push", "-f", "origin", "main"},
},
wantStatus: http.StatusBadRequest,
wantErr: "force push",
},
{
name: "project not found",
projectID: "nonexistent",
body: GitRequest{Args: []string{"status"}},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest("POST", "/projects/"+tt.projectID+"/git", bytes.NewReader(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 tt.wantErr != "" {
if !strings.Contains(rec.Body.String(), tt.wantErr) {
t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr)
}
}
})
}
}
// TestProjectsHandler_Events tests the Events SSE endpoint.
func TestProjectsHandler_Events(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
// Note: SSE tests with headers are difficult in httptest because the
// handler blocks waiting for events. We test what we can without blocking.
t.Run("project not found", func(t *testing.T) {
req := httptest.NewRequest("GET", "/projects/nonexistent/events", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("Status = %d, want 404", rec.Code)
}
})
}
// TestProjectsHandler_InvalidJSON tests handling of invalid JSON bodies.
func TestProjectsHandler_InvalidJSON(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
endpoints := []struct {
method string
path string
}{
{"POST", "/projects/pantheon/claude"},
{"POST", "/projects/pantheon/shell"},
{"POST", "/projects/pantheon/git"},
}
for _, ep := range endpoints {
t.Run(ep.path, func(t *testing.T) {
req := httptest.NewRequest(ep.method, ep.path, strings.NewReader("invalid json{"))
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") {
t.Errorf("Body = %q, want to contain 'invalid'", rec.Body.String())
}
})
}
}
// TestCommandIDGeneration tests that command IDs are generated correctly.
func TestCommandIDGeneration(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
// Send two requests and verify they get different command IDs
body := ClaudeRequest{Prompt: "test"}
bodyBytes, _ := json.Marshal(body)
req1 := httptest.NewRequest("POST", "/projects/pantheon/claude", bytes.NewReader(bodyBytes))
req1.Header.Set("Content-Type", "application/json")
rec1 := httptest.NewRecorder()
router.ServeHTTP(rec1, req1)
req2 := httptest.NewRequest("POST", "/projects/pantheon/claude", bytes.NewReader(bodyBytes))
req2.Header.Set("Content-Type", "application/json")
rec2 := httptest.NewRecorder()
router.ServeHTTP(rec2, req2)
// Parse both responses
var resp1, resp2 map[string]any
json.NewDecoder(bytes.NewReader(rec1.Body.Bytes())).Decode(&resp1)
json.NewDecoder(bytes.NewReader(rec2.Body.Bytes())).Decode(&resp2)
data1, _ := resp1["data"].(map[string]any)
data2, _ := resp2["data"].(map[string]any)
if data1["id"] == data2["id"] {
t.Error("Two requests should have different command IDs")
}
}
// TestCustomStreamID tests that custom stream IDs are used when provided.
func TestCustomStreamID(t *testing.T) {
h := newTestProjectsHandler()
router := chi.NewRouter()
router.Use(testAdminAuth)
h.Mount(router)
body := ClaudeRequest{
Prompt: "test",
StreamID: "my-custom-stream-id",
}
bodyBytes, _ := json.Marshal(body)
req := httptest.NewRequest("POST", "/projects/pantheon/claude", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
var resp map[string]any
json.NewDecoder(rec.Body).Decode(&resp)
data, _ := resp["data"].(map[string]any)
if data["id"] != "my-custom-stream-id" {
t.Errorf("Command ID = %v, want my-custom-stream-id", data["id"])
}
}