package api import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model" ) const testWebhookSecret = "test-webhook-secret-123" func computeHMAC(body, secret string) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(body)) return hex.EncodeToString(mac.Sum(nil)) } func newWebhookTestRouter() (*mockUserStore, *mockRunnerStore, *mockRunnerPodManager, *mockForgejoManager, http.Handler) { kv := newMockValidator() us := newMockUserStore() rs := newMockRunnerStore() rp := newMockRunnerPodManager() fm := newMockForgejoManager() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, K8s: newMockPodManager(), Users: us, Usage: newMockUsageStore(), Runners: rs, RunnerPods: rp, Forgejo: fm, Logger: logger, GenerateKey: mockGenerateKey, WebhookSecret: testWebhookSecret, } return us, rs, rp, fm, NewRouter(srv) } func makeWebhookPayload(t *testing.T, comment, owner, repo string, issueNumber int) string { t.Helper() payload := map[string]interface{}{ "action": "created", "comment": map[string]interface{}{ "body": comment, "created_at": time.Now().UTC().Format(time.RFC3339), "user": map[string]string{"login": "someone"}, }, "issue": map[string]interface{}{ "number": issueNumber, "title": "Test Issue", }, "repository": map[string]interface{}{ "full_name": owner + "/" + repo, "name": repo, "default_branch": "main", "owner": map[string]string{"login": owner}, }, } b, err := json.Marshal(payload) if err != nil { t.Fatal(err) } return string(b) } func doWebhookRequest(router http.Handler, body, secret, deliveryID, eventType string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body)) req.Header.Set("X-Forgejo-Signature", computeHMAC(body, secret)) req.Header.Set("X-Forgejo-Delivery", deliveryID) req.Header.Set("X-Forgejo-Event", eventType) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) return rr } func TestWebhook_Success(t *testing.T) { us, rs, rp, fm, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude implement the auth system", "alice", "myrepo", 42) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-001", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var runner model.Runner decodeJSON(t, rr, &runner) if runner.User != "alice" { t.Errorf("expected user alice, got %s", runner.User) } if runner.RepoURL != "alice/myrepo" { t.Errorf("expected repo alice/myrepo, got %s", runner.RepoURL) } if runner.Status != model.RunnerStatusPodCreating { t.Errorf("expected status pod_creating, got %s", runner.Status) } if runner.WebhookDeliveryID != "delivery-001" { t.Errorf("expected delivery id delivery-001, got %s", runner.WebhookDeliveryID) } if !strings.Contains(runner.Task, "implement") { t.Errorf("expected task to contain 'implement', got %s", runner.Task) } rs.mu.Lock() count := len(rs.runners) rs.mu.Unlock() if count != 1 { t.Errorf("expected 1 runner in store, got %d", count) } rp.mu.Lock() podCount := len(rp.pods) rp.mu.Unlock() if podCount != 1 { t.Errorf("expected 1 pod created, got %d", podCount) } fm.mu.Lock() commentCount := len(fm.issueComments) fm.mu.Unlock() if commentCount != 1 { t.Errorf("expected 1 issue comment, got %d", commentCount) } } func TestWebhook_InvalidSignature(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1) rr := doWebhookRequest(router, body, "wrong-secret", "delivery-002", "issue_comment") if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String()) } } func TestWebhook_MissingSignature(t *testing.T) { _, _, _, _, router := newWebhookTestRouter() body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1) req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body)) req.Header.Set("X-Forgejo-Delivery", "delivery-003") req.Header.Set("X-Forgejo-Event", "issue_comment") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d: %s", rr.Code, rr.Body.String()) } } func TestWebhook_MissingDeliveryID(t *testing.T) { _, _, _, _, router := newWebhookTestRouter() body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1) req := httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/forgejo", strings.NewReader(body)) req.Header.Set("X-Forgejo-Signature", computeHMAC(body, testWebhookSecret)) req.Header.Set("X-Forgejo-Event", "issue_comment") rr := httptest.NewRecorder() router.ServeHTTP(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d: %s", rr.Code, rr.Body.String()) } } func TestWebhook_DuplicateDelivery(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1) rr1 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment") if rr1.Code != http.StatusCreated { t.Fatalf("first request: expected 201, got %d: %s", rr1.Code, rr1.Body.String()) } rr2 := doWebhookRequest(router, body, testWebhookSecret, "delivery-dupe", "issue_comment") if rr2.Code != http.StatusOK { t.Fatalf("dupe: expected 200, got %d: %s", rr2.Code, rr2.Body.String()) } var resp map[string]string decodeJSON(t, rr2, &resp) if resp["status"] != "already_processed" { t.Errorf("expected status already_processed, got %s", resp["status"]) } } func TestWebhook_UnsupportedEventType(t *testing.T) { _, _, _, _, router := newWebhookTestRouter() body := `{"action":"created"}` rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-004", "push") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]string decodeJSON(t, rr, &resp) if resp["reason"] != "unsupported event type" { t.Errorf("expected reason 'unsupported event type', got %s", resp["reason"]) } } func TestWebhook_NoClaudeCommand(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "just a regular comment", "alice", "myrepo", 1) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-005", "issue_comment") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]string decodeJSON(t, rr, &resp) if resp["reason"] != "no @claude command" { t.Errorf("expected reason 'no @claude command', got %s", resp["reason"]) } } func TestWebhook_ReplayWindow(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) oldTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339) payload := map[string]interface{}{ "action": "created", "comment": map[string]interface{}{ "body": "@claude implement it", "created_at": oldTime, "user": map[string]string{"login": "alice"}, }, "issue": map[string]interface{}{ "number": 1, "title": "Old Issue", }, "repository": map[string]interface{}{ "full_name": "alice/myrepo", "name": "myrepo", "default_branch": "main", "owner": map[string]string{"login": "alice"}, }, } b, _ := json.Marshal(payload) body := string(b) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-006", "issue_comment") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]string decodeJSON(t, rr, &resp) if resp["reason"] != "event too old" { t.Errorf("expected reason 'event too old', got %s", resp["reason"]) } } func TestWebhook_UserNotFound(t *testing.T) { _, _, _, _, router := newWebhookTestRouter() body := makeWebhookPayload(t, "@claude implement it", "nonexistent", "myrepo", 1) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-007", "issue_comment") if rr.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d: %s", rr.Code, rr.Body.String()) } } func TestWebhook_WithSpinoffConfig(t *testing.T) { us, rs, _, fm, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) fm.mu.Lock() fm.repoFiles["alice/myrepo/.spinoff.yml"] = []byte("tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n") fm.mu.Unlock() body := makeWebhookPayload(t, "@claude implement it", "alice", "myrepo", 1) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-008", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var runner model.Runner decodeJSON(t, rr, &runner) if runner.Tools != "rust,go" { t.Errorf("expected tools 'rust,go', got %s", runner.Tools) } if runner.CPUReq != "4" { t.Errorf("expected cpu 4, got %s", runner.CPUReq) } if runner.MemReq != "8Gi" { t.Errorf("expected mem 8Gi, got %s", runner.MemReq) } rs.mu.Lock() for _, r := range rs.runners { if r.Tools != "rust,go" { t.Errorf("stored runner tools expected 'rust,go', got %s", r.Tools) } } rs.mu.Unlock() } func TestWebhook_ForgejoSpinoffConfig(t *testing.T) { us, _, _, fm, router := newWebhookTestRouter() us.addUser(&model.User{ID: "bob", Quota: model.DefaultQuota()}) fm.mu.Lock() fm.repoFiles["bob/project/.forgejo/spinoff.yml"] = []byte("tools: [node]\ncpu: \"8\"\nmem: 16Gi\n") fm.mu.Unlock() body := makeWebhookPayload(t, "@claude implement it", "bob", "project", 5) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-009", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var runner model.Runner decodeJSON(t, rr, &runner) if runner.Tools != "node" { t.Errorf("expected tools 'node', got %s", runner.Tools) } } func TestWebhook_ReviewCommand(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude review the PR changes", "alice", "myrepo", 10) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-010", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var runner model.Runner decodeJSON(t, rr, &runner) if !strings.Contains(runner.Task, "review") { t.Errorf("expected task to contain 'review', got %s", runner.Task) } } func TestWebhook_FixCommand(t *testing.T) { us, _, _, _, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude fix the failing tests", "alice", "myrepo", 15) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-011", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } var runner model.Runner decodeJSON(t, rr, &runner) if !strings.Contains(runner.Task, "fix") { t.Errorf("expected task to contain 'fix', got %s", runner.Task) } } func TestWebhook_NotConfigured(t *testing.T) { kv := newMockValidator() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) srv := &Server{ Store: kv, Logger: logger, } router := NewRouter(srv) body := `{}` rr := doWebhookRequest(router, body, "", "delivery-012", "issue_comment") if rr.Code != http.StatusServiceUnavailable { t.Fatalf("expected 503, got %d: %s", rr.Code, rr.Body.String()) } } func TestWebhook_CommentPostedOnIssue(t *testing.T) { us, _, _, fm, router := newWebhookTestRouter() us.addUser(&model.User{ID: "alice", Quota: model.DefaultQuota()}) body := makeWebhookPayload(t, "@claude implement the login flow", "alice", "myrepo", 42) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-013", "issue_comment") if rr.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String()) } fm.mu.Lock() defer fm.mu.Unlock() if len(fm.issueComments) != 1 { t.Fatalf("expected 1 comment, got %d", len(fm.issueComments)) } c := fm.issueComments[0] if c.Owner != "alice" || c.Repo != "myrepo" || c.IssueNumber != 42 { t.Errorf("comment on wrong target: %+v", c) } if !strings.Contains(c.Body, "Builder pod") { t.Errorf("expected comment to contain 'Builder pod', got %s", c.Body) } if strings.Contains(c.Body, "@claude") { t.Errorf("bot comment must NOT contain '@claude' (causes webhook loop), got %s", c.Body) } } func TestWebhook_ActionNotCreated(t *testing.T) { _, _, _, _, router := newWebhookTestRouter() payload := map[string]interface{}{ "action": "edited", "comment": map[string]interface{}{ "body": "@claude implement it", "created_at": time.Now().UTC().Format(time.RFC3339), "user": map[string]string{"login": "alice"}, }, "issue": map[string]interface{}{ "number": 1, "title": "Test", }, "repository": map[string]interface{}{ "full_name": "alice/myrepo", "name": "myrepo", "default_branch": "main", "owner": map[string]string{"login": "alice"}, }, } b, _ := json.Marshal(payload) body := string(b) rr := doWebhookRequest(router, body, testWebhookSecret, "delivery-014", "issue_comment") if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) } var resp map[string]string decodeJSON(t, rr, &resp) if resp["reason"] != "not a new comment" { t.Errorf("expected reason 'not a new comment', got %s", resp["reason"]) } } func TestParseClaudeCommand(t *testing.T) { tests := []struct { input string wantNil bool action string text string }{ {"@claude implement the auth system", false, "implement", "the auth system"}, {"@claude review the PR changes", false, "review", "the PR changes"}, {"@claude fix the bug", false, "fix", "the bug"}, {"@Claude IMPLEMENT it", false, "implement", "it"}, {"@claude implement", false, "implement", ""}, {"just a comment", true, "", ""}, {"mention @claude but no command", true, "", ""}, {"@claude deploy it", true, "", ""}, {"please @claude implement feature X", false, "implement", "feature X"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { cmd := parseClaudeCommand(tt.input) if tt.wantNil { if cmd != nil { t.Errorf("expected nil, got %+v", cmd) } return } if cmd == nil { t.Fatal("expected command, got nil") } if cmd.Action != tt.action { t.Errorf("expected action %q, got %q", tt.action, cmd.Action) } if cmd.Text != tt.text { t.Errorf("expected text %q, got %q", tt.text, cmd.Text) } }) } } func TestVerifyHMACSignature(t *testing.T) { body := []byte(`{"test": true}`) secret := "my-secret" mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) validSig := hex.EncodeToString(mac.Sum(nil)) if !verifyHMACSignature(body, validSig, secret) { t.Error("expected valid signature to pass") } if verifyHMACSignature(body, "invalid", secret) { t.Error("expected invalid signature to fail") } if verifyHMACSignature(body, validSig, "wrong-secret") { t.Error("expected wrong secret to fail") } } func TestParseSpinoffConfig(t *testing.T) { tests := []struct { name string input string tools []string cpu string mem string wantErr bool }{ { name: "full config", input: "tools: [rust, go]\ncpu: \"4\"\nmem: 8Gi\n", tools: []string{"rust", "go"}, cpu: "4", mem: "8Gi", }, { name: "tools only", input: "tools: [node]\n", tools: []string{"node"}, }, { name: "empty config", input: "", tools: nil, }, { name: "invalid yaml", input: "{{invalid", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg, err := parseSpinoffConfig([]byte(tt.input)) if tt.wantErr { if err == nil { t.Fatal("expected error") } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if len(cfg.Tools) != len(tt.tools) { t.Fatalf("expected %d tools, got %d", len(tt.tools), len(cfg.Tools)) } for i, tool := range tt.tools { if cfg.Tools[i] != tool { t.Errorf("tool %d: expected %q, got %q", i, tool, cfg.Tools[i]) } } if tt.cpu != "" && cfg.CPU != tt.cpu { t.Errorf("expected cpu %q, got %q", tt.cpu, cfg.CPU) } if tt.mem != "" && cfg.Mem != tt.mem { t.Errorf("expected mem %q, got %q", tt.mem, cfg.Mem) } }) } }