dev-pod-api-build/internal/api/webhooks_test.go
2026-04-16 04:16:36 +00:00

583 lines
17 KiB
Go

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)
}
})
}
}