diff options
Diffstat (limited to 'internal/api/webhook_test.go')
| -rw-r--r-- | internal/api/webhook_test.go | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/internal/api/webhook_test.go b/internal/api/webhook_test.go new file mode 100644 index 0000000..8b0599a --- /dev/null +++ b/internal/api/webhook_test.go @@ -0,0 +1,313 @@ +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_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 +} |
