summaryrefslogtreecommitdiff
path: root/internal/api/webhook_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/webhook_test.go')
-rw-r--r--internal/api/webhook_test.go313
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
+}