package handler import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/multica-ai/multica/server/internal/util/secretbox" db "github.com/multica-ai/multica/server/pkg/db/generated" ) func withVCSBox(t *testing.T) *secretbox.Box { t.Helper() box, err := secretbox.New(bytes.Repeat([]byte("k"), 32)) if err != nil { t.Fatalf("secretbox.New: %v", err) } prev := testHandler.VCSSecretBox testHandler.VCSSecretBox = box // The feature also requires the deployment-level switch; the box alone is // no longer sufficient. Enable it for the "configured" path tests. prevEnabled := testHandler.cfg.VCSIntegrationEnabled testHandler.cfg.VCSIntegrationEnabled = true t.Cleanup(func() { testHandler.VCSSecretBox = prev testHandler.cfg.VCSIntegrationEnabled = prevEnabled }) return box } const vcsTestSecret = "vcs-webhook-secret" func seedVCSConnection(t *testing.T, ctx context.Context, box *secretbox.Box, provider, instanceURL string) string { t.Helper() sealed, err := box.Seal([]byte(vcsTestSecret)) if err != nil { t.Fatalf("seal: %v", err) } tokenSealed, _ := box.Seal([]byte("tok")) conn, err := testHandler.Queries.UpsertVCSConnection(ctx, db.UpsertVCSConnectionParams{ WorkspaceID: parseUUID(testWorkspaceID), Provider: provider, InstanceUrl: instanceURL, AccountLogin: "acme", AccessTokenEncrypted: base64.StdEncoding.EncodeToString(tokenSealed), WebhookSecretEncrypted: base64.StdEncoding.EncodeToString(sealed), ConnectedByID: pgtype.UUID{}, }) if err != nil { t.Fatalf("UpsertVCSConnection: %v", err) } return uuidToString(conn.ID) } func cleanupVCS(ctx context.Context, issueID string) { testPool.Exec(ctx, `DELETE FROM issue_vcs_pull_request WHERE issue_id = $1`, issueID) testPool.Exec(ctx, `DELETE FROM vcs_commit_status cs USING vcs_connection c WHERE cs.connection_id = c.id AND c.workspace_id = $1`, testWorkspaceID) testPool.Exec(ctx, `DELETE FROM vcs_pull_request WHERE workspace_id = $1`, testWorkspaceID) testPool.Exec(ctx, `DELETE FROM vcs_connection WHERE workspace_id = $1`, testWorkspaceID) if issueID != "" { testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issueID) testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) } } func newVCSIssue(t *testing.T, title string) IssueResponse { t.Helper() w := httptest.NewRecorder() req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{ "title": title, "status": "in_progress", }) testHandler.CreateIssue(w, req) if w.Code != http.StatusCreated { t.Fatalf("CreateIssue: %d %s", w.Code, w.Body.String()) } var created IssueResponse json.NewDecoder(w.Body).Decode(&created) return created } func vcsWebhookReq(connID string, headers map[string]string, raw []byte) *http.Request { req := httptest.NewRequest("POST", "/api/webhooks/vcs/"+connID, bytes.NewReader(raw)) for k, v := range headers { req.Header.Set(k, v) } rctx := chi.NewRouteContext() rctx.URLParams.Add("connectionId", connID) return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) } func giteaSig(raw []byte) string { mac := hmac.New(sha256.New, []byte(vcsTestSecret)) mac.Write(raw) return hex.EncodeToString(mac.Sum(nil)) } func TestVCSWebhook_ForgejoMirrorsAndCloses(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Forgejo PR auto-merge") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) raw, _ := json.Marshal(map[string]any{ "action": "closed", "pull_request": map[string]any{ "number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7", "title": "Fix login " + issue.Identifier, "body": "Closes " + issue.Identifier, "state": "closed", "merged": true, "merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z", "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z", "head": map[string]any{"ref": "fix/login", "sha": "abc"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String()) } rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)) if err != nil { t.Fatalf("ListVCSPullRequestsByIssue: %v", err) } if len(rows) != 1 || rows[0].State != "merged" || rows[0].Provider != "forgejo" { t.Fatalf("unexpected rows: %+v", rows) } updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)) if updated.Status != "done" { t.Errorf("expected issue done, got %q", updated.Status) } } // A bare body mention ("Related MUL-X", no closing keyword, not in title or // branch) claims nothing, so it must not link at all: it neither shows as a // working PR nor blocks a genuine Closes sibling from completing the issue. // Mirrors the GitHub claim rule (MUL-3739, MUL-7072). func TestVCSWebhook_BareBodyMentionIsNotLinked(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Reference-only mention") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) // PR #7: OPEN, mentions the issue only in the body with no closing keyword, // generic title/branch → no claim, no link. refRaw, _ := json.Marshal(map[string]any{ "action": "opened", "pull_request": map[string]any{ "number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7", "title": "Update docs", "body": "Related " + issue.Identifier, "state": "open", "merged": false, "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-28T00:00:00Z", "head": map[string]any{"ref": "docs", "sha": "ref7"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(refRaw), }, refRaw)) if w.Code != http.StatusAccepted { t.Fatalf("ref PR: expected 202, got %d (%s)", w.Code, w.Body.String()) } // No link row at all, so nothing to exclude from the PR list. var links int if err := testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&links); err != nil { t.Fatalf("count links: %v", err) } if links != 0 { t.Fatalf("body-only mention should not link, got %d rows", links) } if rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)); err != nil { t.Fatalf("list: %v", err) } else if len(rows) != 0 { t.Fatalf("unlinked PR must not appear in the list, got %d rows", len(rows)) } // PR #8: MERGED with a title reference + Closes keyword → a real claim with // close intent. The still-open, unlinked PR #7 must NOT block completion. closeRaw, _ := json.Marshal(map[string]any{ "action": "closed", "pull_request": map[string]any{ "number": 8, "html_url": "https://forgejo.test/acme/widget/pulls/8", "title": "Fix " + issue.Identifier, "body": "Closes " + issue.Identifier, "state": "closed", "merged": true, "merged_at": "2026-04-29T00:00:00Z", "closed_at": "2026-04-29T00:00:00Z", "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-29T00:00:00Z", "head": map[string]any{"ref": "fix", "sha": "cls8"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w = httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(closeRaw), }, closeRaw)) if w.Code != http.StatusAccepted { t.Fatalf("close PR: expected 202, got %d (%s)", w.Code, w.Body.String()) } updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)) if updated.Status != "done" { t.Errorf("issue should advance despite the open unlinked PR, got %q", updated.Status) } } // The merge automation must span providers: an issue with an OPEN GitHub PR // and a MERGED VCS merge request waits for the GitHub PR, and moves once it // merges too. func TestAutoCompleteSpansProviders(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "gitlab", "https://gitlab.test") issue := newVCSIssue(t, "Cross-provider auto-complete") now := pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true} t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue_pull_request WHERE issue_id = $1`, issue.ID) testPool.Exec(ctx, `DELETE FROM github_pull_request WHERE workspace_id = $1`, testWorkspaceID) testPool.Exec(ctx, `DELETE FROM activity_log WHERE issue_id = $1`, issue.ID) cleanupVCS(ctx, issue.ID) }) upsertGH := func(state string) db.GithubPullRequest { t.Helper() pr, err := testHandler.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{ WorkspaceID: parseUUID(testWorkspaceID), InstallationID: 987654, RepoOwner: "acme", RepoName: "gh", PrNumber: 3, Title: "WIP " + issue.Identifier, State: state, HtmlUrl: "https://github.com/acme/gh/pull/3", PrCreatedAt: now, PrUpdatedAt: now, HeadSha: "ghsha", }) if err != nil { t.Fatalf("UpsertGitHubPullRequest: %v", err) } return pr } ghPR := upsertGH("open") if _, err := testHandler.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{ IssueID: parseUUID(issue.ID), PullRequestID: ghPR.ID, }); err != nil { t.Fatalf("LinkIssueToPullRequest: %v", err) } vcsPR, err := testHandler.Queries.UpsertVCSPullRequest(ctx, db.UpsertVCSPullRequestParams{ WorkspaceID: parseUUID(testWorkspaceID), ConnectionID: parseUUID(connID), Provider: "gitlab", RepoOwner: "acme", RepoName: "gl", PrNumber: 4, Title: "Fix " + issue.Identifier, State: "merged", HtmlUrl: "https://gitlab.test/acme/gl/-/merge_requests/4", PrCreatedAt: now, PrUpdatedAt: now, HeadSha: "glsha", }) if err != nil { t.Fatalf("UpsertVCSPullRequest: %v", err) } if _, err := testHandler.Queries.LinkIssueToVCSPullRequest(ctx, db.LinkIssueToVCSPullRequestParams{ IssueID: parseUUID(issue.ID), PullRequestID: vcsPR.ID, }); err != nil { t.Fatalf("LinkIssueToVCSPullRequest: %v", err) } testHandler.maybeAutoCompleteIssue(ctx, parseUUID(testWorkspaceID), parseUUID(issue.ID), nil) if got, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); got.Status == "done" { t.Fatal("the open GitHub PR must hold the issue open") } upsertGH("merged") testHandler.maybeAutoCompleteIssue(ctx, parseUUID(testWorkspaceID), parseUUID(issue.ID), nil) if got, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); got.Status != "done" { t.Errorf("status = %q, want done once both providers' PRs merged", got.Status) } } // DeleteIssue's VCS-link cleanup must honour the same workspace guard as the // issue delete: passing a foreign issue_id with a mismatched workspace_id must // be a complete no-op, not silently drop the victim tenant's link rows (#1661). func TestDeleteIssue_VCSLinkCleanupIsWorkspaceScoped(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Tenant-scoped delete") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) raw, _ := json.Marshal(map[string]any{ "action": "opened", "pull_request": map[string]any{ "number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7", "title": "Fix " + issue.Identifier, "state": "open", "merged": false, "created_at": "2026-05-01T00:00:00Z", "updated_at": "2026-05-01T00:00:00Z", "head": map[string]any{"ref": "fix", "sha": "abc"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("seed PR: %d %s", w.Code, w.Body.String()) } linkCount := func() int { var n int testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&n) return n } if linkCount() != 1 { t.Fatalf("expected 1 link after seed, got %d", linkCount()) } // Mismatched workspace_id → complete no-op: issue and link both survive. wrongWS := parseUUID("11111111-1111-1111-1111-111111111111") if err := testHandler.Queries.DeleteIssue(ctx, db.DeleteIssueParams{ID: parseUUID(issue.ID), WorkspaceID: wrongWS}); err != nil { t.Fatalf("DeleteIssue(wrong ws): %v", err) } if _, err := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)); err != nil { t.Errorf("issue must survive a mismatched-workspace delete: %v", err) } if linkCount() != 1 { t.Errorf("link rows must survive a mismatched-workspace delete, got %d", linkCount()) } // Correct workspace_id → issue and its links both removed. if err := testHandler.Queries.DeleteIssue(ctx, db.DeleteIssueParams{ID: parseUUID(issue.ID), WorkspaceID: parseUUID(testWorkspaceID)}); err != nil { t.Fatalf("DeleteIssue(correct ws): %v", err) } if linkCount() != 0 { t.Errorf("link rows should be gone after correct delete, got %d", linkCount()) } } // A PR linked while open stays linked when its merge event no longer names the // issue, and that merge moves the issue: the keyword only links (MUL-7726). func TestVCSWebhook_MergeMovesIssueLinkedEarlier(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Keyword removed before merge") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) fire := func(action, state string, merged bool, body, updatedAt string) { raw, _ := json.Marshal(map[string]any{ "action": action, "pull_request": map[string]any{ "number": 9, "html_url": "https://forgejo.test/acme/widget/pulls/9", "title": "Session refactor", "body": body, "state": state, "merged": merged, "created_at": "2026-05-01T00:00:00Z", "updated_at": updatedAt, "merged_at": "2026-05-02T00:00:00Z", "head": map[string]any{"ref": "refactor", "sha": "def"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("%s event: %d %s", action, w.Code, w.Body.String()) } } fire("opened", "open", false, "Closes "+issue.Identifier, "2026-05-01T00:00:00Z") fire("closed", "closed", true, "Related to "+issue.Identifier, "2026-05-02T00:00:00Z") updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)) if updated.Status != "done" { t.Errorf("status = %q, want done", updated.Status) } var links int testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&links) if links != 1 { t.Errorf("the merged PR must stay linked, got %d links", links) } } // A redelivered older event must not rewrite the link a newer event already // set. The PR-upsert monotonic guard protects the PR row; this covers the link. func TestVCSWebhook_StaleEventDoesNotRewriteLink(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Out-of-order link guard") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) fire := func(action, state string, merged bool, title, body, updatedAt string) { raw, _ := json.Marshal(map[string]any{ "action": action, "pull_request": map[string]any{ "number": 7, "html_url": "https://forgejo.test/acme/widget/pulls/7", "title": title, "body": body, "state": state, "merged": merged, "created_at": "2026-05-01T00:00:00Z", "updated_at": updatedAt, "merged_at": "2026-05-02T00:00:00Z", "head": map[string]any{"ref": "wip", "sha": "abc"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("%s event: %d %s", action, w.Code, w.Body.String()) } } // Newer terminal event: merged with a real claim (Closes). fire("closed", "closed", true, "Fix "+issue.Identifier, "Closes "+issue.Identifier, "2026-05-02T00:00:00Z") // Older redelivered "opened" event: bare body mention, generic title/branch. // Without the guard this would drop the link: an open PR that no longer // claims the issue is unlinked. fire("opened", "open", false, "WIP", "touches "+issue.Identifier, "2026-05-01T00:00:00Z") var links int testPool.QueryRow(ctx, `SELECT count(*) FROM issue_vcs_pull_request WHERE issue_id = $1`, issue.ID).Scan(&links) if links != 1 { t.Errorf("stale event rewrote the link: %d links, want 1", links) } // The PR row also stayed at the newer merged state. rows, _ := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)) if len(rows) != 1 || rows[0].State != "merged" { t.Errorf("PR row regressed: %+v", rows) } } func TestVCSWebhook_GitlabMergeRequest(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "gitlab", "https://gitlab.test") issue := newVCSIssue(t, "GitLab MR test") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) raw, _ := json.Marshal(map[string]any{ "object_kind": "merge_request", "user": map[string]any{"username": "alice"}, "project": map[string]any{"path_with_namespace": "acme/widget"}, "object_attributes": map[string]any{ "iid": 42, "title": "Add " + issue.Identifier, "description": "Closes " + issue.Identifier, "state": "merged", "action": "merge", "source_branch": "feat", "url": "https://gitlab.test/acme/widget/-/merge_requests/42", "last_commit": map[string]any{"id": "deadbeef"}, }, }) // GitLab authenticates by plaintext X-Gitlab-Token, not HMAC. w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitlab-Event": "Merge Request Hook", "X-Gitlab-Token": vcsTestSecret, }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String()) } rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)) if err != nil { t.Fatalf("ListVCSPullRequestsByIssue: %v", err) } if len(rows) != 1 || rows[0].Provider != "gitlab" || rows[0].RepoOwner != "acme" || rows[0].PrNumber != 42 { t.Fatalf("unexpected rows: %+v", rows) } if rows[0].State != "merged" { t.Errorf("expected merged, got %q", rows[0].State) } updated, _ := testHandler.Queries.GetIssue(ctx, parseUUID(issue.ID)) if updated.Status != "done" { t.Errorf("expected issue done, got %q", updated.Status) } } func TestVCSWebhook_CommitStatusMirrors(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") issue := newVCSIssue(t, "Forgejo CI status") t.Cleanup(func() { cleanupVCS(ctx, issue.ID) }) prRaw, _ := json.Marshal(map[string]any{ "action": "opened", "pull_request": map[string]any{ "number": 11, "html_url": "https://forgejo.test/acme/widget/pulls/11", "title": issue.Identifier + " feature", "state": "open", "created_at": "2026-04-28T00:00:00Z", "updated_at": "2026-04-28T00:00:00Z", "head": map[string]any{"ref": "feat", "sha": "deadbeef"}, "user": map[string]any{"username": "octo"}, }, "repository": map[string]any{"name": "widget", "owner": map[string]any{"username": "acme"}}, }) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(prRaw), }, prRaw)) if w.Code != http.StatusAccepted { t.Fatalf("pr: expected 202, got %d", w.Code) } stRaw, _ := json.Marshal(map[string]any{"sha": "deadbeef", "context": "ci/woodpecker", "state": "success"}) w = httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "status", "X-Gitea-Signature": giteaSig(stRaw), }, stRaw)) if w.Code != http.StatusAccepted { t.Fatalf("status: expected 202, got %d", w.Code) } rows, _ := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)) if len(rows) != 1 || rows[0].ChecksTotal != 1 || rows[0].ChecksPassed != 1 { t.Fatalf("expected 1 passed check, got %+v", rows) } } func TestVCSWebhook_BadSignature(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") t.Cleanup(func() { cleanupVCS(ctx, "") }) raw := []byte(`{"action":"opened","pull_request":{"number":1}}`) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": "00", }, raw)) if w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } func TestVCSWebhook_UnknownConnection(t *testing.T) { withVCSBox(t) req := vcsWebhookReq("00000000-0000-0000-0000-000000000000", map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": "00", }, []byte(`{}`)) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, req) if w.Code != http.StatusNotFound { t.Fatalf("expected 404, got %d", w.Code) } } // TestVCSWebhook_DisabledDeploymentReturns404 verifies the deployment-level // switch is enforced server-side: with the integration off (the managed-cloud // posture), even a valid, correctly-signed delivery to a real connection is // rejected with a bare 404, so the feature is never processed and reveals // nothing about config. The availability gate short-circuits before signature // verification. func TestVCSWebhook_DisabledDeploymentReturns404(t *testing.T) { ctx := context.Background() box := withVCSBox(t) // sets the box AND enables the switch connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") t.Cleanup(func() { cleanupVCS(ctx, "") }) // Now flip the deployment switch off (withVCSBox's cleanup restores it). testHandler.cfg.VCSIntegrationEnabled = false raw := []byte(`{"action":"opened","pull_request":{"number":1}}`) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusNotFound { t.Fatalf("expected 404 when integration disabled, got %d (%s)", w.Code, w.Body.String()) } } func TestVCSWebhook_UnconfiguredDeploymentReturns404(t *testing.T) { h := &Handler{cfg: Config{VCSIntegrationEnabled: true}} w := httptest.NewRecorder() h.HandleVCSWebhook(w, vcsWebhookReq("00000000-0000-0000-0000-000000000000", nil, []byte(`{}`))) if w.Code != http.StatusNotFound { t.Fatalf("expected 404 when integration is unconfigured, got %d (%s)", w.Code, w.Body.String()) } } func TestVCSWebhook_MalformedTolerated(t *testing.T) { ctx := context.Background() box := withVCSBox(t) connID := seedVCSConnection(t, ctx, box, "forgejo", "https://forgejo.test") t.Cleanup(func() { cleanupVCS(ctx, "") }) raw := []byte(`{"action":"opened","pull_request":"not-an-object"}`) w := httptest.NewRecorder() testHandler.HandleVCSWebhook(w, vcsWebhookReq(connID, map[string]string{ "X-Gitea-Event": "pull_request", "X-Gitea-Signature": giteaSig(raw), }, raw)) if w.Code != http.StatusAccepted { t.Fatalf("expected 202, got %d (%s)", w.Code, w.Body.String()) } var count int testPool.QueryRow(ctx, `SELECT count(*) FROM vcs_pull_request WHERE workspace_id = $1`, testWorkspaceID).Scan(&count) if count != 0 { t.Errorf("expected no PR rows, got %d", count) } }