package api import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/thepeterstone/claudomator/internal/config" ) // signBody computes the HMAC-SHA256 signature for a webhook payload. func signBody(t *testing.T, secret, body string) string { t.Helper() mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(body)) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } func webhookPost(t *testing.T, srv *Server, eventType, payload, sig string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString(payload)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-GitHub-Event", eventType) if sig != "" { req.Header.Set("X-Hub-Signature-256", sig) } w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) return w } const checkRunFailurePayload = `{ "action": "completed", "check_run": { "name": "CI Build", "conclusion": "failure", "html_url": "https://github.com/owner/myrepo/runs/123", "head_sha": "abc123def", "check_suite": { "head_branch": "main" } }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` const checkRunSuccessPayload = `{ "action": "completed", "check_run": { "name": "CI Build", "conclusion": "success", "html_url": "https://github.com/owner/myrepo/runs/123", "head_sha": "abc123def", "check_suite": { "head_branch": "main" } }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` const workflowRunFailurePayload = `{ "action": "completed", "workflow_run": { "name": "CI Pipeline", "conclusion": "failure", "html_url": "https://github.com/owner/myrepo/actions/runs/789", "head_sha": "def456abc", "head_branch": "feature-branch" }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` const workflowRunTimedOutPayload = `{ "action": "completed", "workflow_run": { "name": "CI Pipeline", "conclusion": "timed_out", "html_url": "https://github.com/owner/myrepo/actions/runs/789", "head_sha": "def456abc", "head_branch": "main" }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` func TestGitHubWebhook_CheckRunFailure_CreatesTask(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) taskID, ok := resp["task_id"] if !ok || taskID == "" { t.Fatal("expected task_id in response") } tk, err := store.GetTask(taskID) if err != nil { t.Fatalf("task not found in store: %v", err) } if !strings.Contains(tk.Name, "CI Build") { t.Errorf("task name %q does not contain check name", tk.Name) } if !strings.Contains(tk.Name, "main") { t.Errorf("task name %q does not contain branch", tk.Name) } if tk.Agent.ProjectDir != "/workspace/myrepo" { t.Errorf("task project dir = %q, want /workspace/myrepo", tk.Agent.ProjectDir) } if !contains(tk.Tags, "ci") || !contains(tk.Tags, "auto") { t.Errorf("task tags %v missing expected ci/auto tags", tk.Tags) } } func TestGitHubWebhook_WorkflowRunFailure_CreatesTask(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "workflow_run", workflowRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) taskID, ok := resp["task_id"] if !ok || taskID == "" { t.Fatal("expected task_id in response") } tk, err := store.GetTask(taskID) if err != nil { t.Fatalf("task not found in store: %v", err) } if !strings.Contains(tk.Name, "CI Pipeline") { t.Errorf("task name %q does not contain workflow name", tk.Name) } if !strings.Contains(tk.Name, "feature-branch") { t.Errorf("task name %q does not contain branch", tk.Name) } } func TestGitHubWebhook_WorkflowRunTimedOut_CreatesTask(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "workflow_run", workflowRunTimedOutPayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) taskID := resp["task_id"] if taskID == "" { t.Fatal("expected task_id in response") } if _, err := store.GetTask(taskID); err != nil { t.Fatalf("task not found in store: %v", err) } } func TestGitHubWebhook_CheckRunSuccess_Returns204(t *testing.T) { srv, _ := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunSuccessPayload, "") if w.Code != http.StatusNoContent { t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) } } func TestGitHubWebhook_InvalidHMAC_Returns401(t *testing.T) { srv, _ := testServer(t) srv.webhookSecret = "correct-secret" srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "sha256=badhash") if w.Code != http.StatusUnauthorized { t.Fatalf("want 401, got %d; body: %s", w.Code, w.Body.String()) } } func TestGitHubWebhook_ValidHMAC_AcceptsRequest(t *testing.T) { srv, store := testServer(t) srv.webhookSecret = "my-secret" srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} sig := signBody(t, "my-secret", checkRunFailurePayload) w := webhookPost(t, srv, "check_run", checkRunFailurePayload, sig) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if _, err := store.GetTask(resp["task_id"]); err != nil { t.Fatal("task not found after valid HMAC request") } } func TestGitHubWebhook_NoSecretConfigured_SkipsHMACCheck(t *testing.T) { srv, store := testServer(t) // No webhook secret configured — even a missing signature should pass. srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) if _, err := store.GetTask(resp["task_id"]); err != nil { t.Fatal("task not found") } } func TestGitHubWebhook_CreatesTask_WithDefaultModel(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatalf("task not found: %v", err) } if tk.Agent.Model == "" { t.Error("expected model to be set, got empty string") } } const checkRunNullBranchPayload = `{ "action": "completed", "check_run": { "name": "CI Build", "conclusion": "failure", "html_url": "", "details_url": "https://github.com/owner/myrepo/actions/runs/999/jobs/123", "head_sha": "abc123def", "check_suite": { "head_branch": null }, "pull_requests": [{"head": {"ref": "feature/my-branch"}}] }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` func TestGitHubWebhook_CheckRunNullBranch_UsesPRRefAndDetailsURL(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "check_run", checkRunNullBranchPayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatalf("task not found: %v", err) } if !strings.Contains(tk.Name, "feature/my-branch") { t.Errorf("task name %q should contain PR branch", tk.Name) } if !strings.Contains(tk.Agent.Instructions, "actions/runs/999") { t.Errorf("instructions should contain details_url fallback, got: %s", tk.Agent.Instructions) } } const workflowRunNullBranchPayload = `{ "action": "completed", "workflow_run": { "name": "CI Pipeline", "conclusion": "failure", "html_url": "", "head_sha": "def456abc", "head_branch": null, "pull_requests": [{"head": {"ref": "fix/something"}}] }, "repository": { "name": "myrepo", "full_name": "owner/myrepo" } }` func TestGitHubWebhook_WorkflowRunNullBranch_UsesPRRef(t *testing.T) { srv, store := testServer(t) srv.projects = []config.Project{{Name: "myrepo", Dir: "/workspace/myrepo"}} w := webhookPost(t, srv, "workflow_run", workflowRunNullBranchPayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatalf("task not found: %v", err) } if !strings.Contains(tk.Name, "fix/something") { t.Errorf("task name %q should contain PR branch", tk.Name) } } func TestGitHubWebhook_UnknownEvent_Returns204(t *testing.T) { srv, _ := testServer(t) w := webhookPost(t, srv, "push", `{"ref":"refs/heads/main"}`, "") if w.Code != http.StatusNoContent { t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) } } func TestGitHubWebhook_MissingEvent_Returns204(t *testing.T) { srv, _ := testServer(t) req := httptest.NewRequest("POST", "/api/webhooks/github", bytes.NewBufferString("{}")) req.Header.Set("Content-Type", "application/json") // No X-GitHub-Event header w := httptest.NewRecorder() srv.Handler().ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Fatalf("want 204, got %d; body: %s", w.Code, w.Body.String()) } } func TestGitHubWebhook_FallbackToSingleProject(t *testing.T) { srv, store := testServer(t) // Project name doesn't match repo name, but there's only one project → fall back. srv.projects = []config.Project{{Name: "different-name", Dir: "/workspace/someapp"}} w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatalf("task not found: %v", err) } if tk.Agent.ProjectDir != "/workspace/someapp" { t.Errorf("expected fallback to /workspace/someapp, got %q", tk.Agent.ProjectDir) } } func TestGitHubWebhook_NoProjectsConfigured_CreatesTaskWithoutProjectDir(t *testing.T) { srv, store := testServer(t) // No projects configured — task should still be created, just no project dir set. w := webhookPost(t, srv, "check_run", checkRunFailurePayload, "") if w.Code != http.StatusOK { t.Fatalf("want 200, got %d; body: %s", w.Code, w.Body.String()) } var resp map[string]string json.NewDecoder(w.Body).Decode(&resp) tk, err := store.GetTask(resp["task_id"]) if err != nil { t.Fatalf("task not found: %v", err) } if tk.Agent.ProjectDir != "" { t.Errorf("expected empty project dir, got %q", tk.Agent.ProjectDir) } } // contains checks if a string slice contains a value. func contains(slice []string, val string) bool { for _, s := range slice { if s == val { return true } } return false }