Files
multica/server/internal/handler/vcs_webhook_test.go
dixonl90 581d9527ba feat(vcs): self-hosted Git providers (Forgejo, Gitea, GitLab) alongside GitHub (MUL-3772) (#5006)
Adds self-hosted Git provider support (Forgejo, Gitea, GitLab) alongside GitHub:
per-workspace token connection, a provider-dispatched webhook, PR/MR and CI
mirroring, and the shared issue auto-link / auto-close machinery. Off until
MULTICA_VCS_SECRET_KEY is set, so existing deployments are unaffected.

Co-authored-by: Bohan <bohan@devv.ai>
2026-07-24 15:01:27 +08:00

527 lines
21 KiB
Go

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
t.Cleanup(func() { testHandler.VCSSecretBox = prev })
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) must link reference_only: excluded from the issue PR list and from
// the close gate, so it neither shows as a working PR nor blocks a genuine
// Closes sibling from advancing the issue. Mirrors the GitHub qualifying rule.
func TestVCSWebhook_ReferenceOnlyExcludedAndNonBlocking(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 → reference_only.
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())
}
// The link exists but is reference_only, so it is hidden from the PR list.
var referenceOnly bool
if err := testPool.QueryRow(ctx,
`SELECT reference_only FROM issue_vcs_pull_request WHERE issue_id = $1`,
issue.ID).Scan(&referenceOnly); err != nil {
t.Fatalf("select reference_only: %v", err)
}
if !referenceOnly {
t.Fatalf("body-only mention should be reference_only")
}
if rows, err := testHandler.Queries.ListVCSPullRequestsByIssue(ctx, parseUUID(issue.ID)); err != nil {
t.Fatalf("list: %v", err)
} else if len(rows) != 0 {
t.Fatalf("reference_only PR must be excluded from the list, got %d rows", len(rows))
}
// PR #8: MERGED with a title reference + Closes keyword → qualifying,
// close_intent. The still-open reference_only PR #7 must NOT block advance.
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 reference_only PR, got %q", updated.Status)
}
}
// The close gate must span providers: an issue with an OPEN GitHub PR and a
// MERGED close-intent VCS PR must report open_count > 0, so neither webhook
// auto-advances it out from under the still-open GitHub work (and vice versa).
func TestCombinedCloseAggregateSpansProviders(t *testing.T) {
ctx := context.Background()
box := withVCSBox(t)
connID := seedVCSConnection(t, ctx, box, "gitlab", "https://gitlab.test")
issue := newVCSIssue(t, "Cross-provider close gate")
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)
cleanupVCS(ctx, issue.ID)
})
// OPEN GitHub PR linked to the issue (installation_id carries no FK).
ghPR, err := testHandler.Queries.UpsertGitHubPullRequest(ctx, db.UpsertGitHubPullRequestParams{
WorkspaceID: parseUUID(testWorkspaceID), InstallationID: 987654,
RepoOwner: "acme", RepoName: "gh", PrNumber: 3,
Title: "WIP " + issue.Identifier, State: "open",
HtmlUrl: "https://github.com/acme/gh/pull/3",
PrCreatedAt: now, PrUpdatedAt: now, HeadSha: "ghsha",
})
if err != nil {
t.Fatalf("UpsertGitHubPullRequest: %v", err)
}
if err := testHandler.Queries.LinkIssueToPullRequest(ctx, db.LinkIssueToPullRequestParams{
IssueID: parseUUID(issue.ID), PullRequestID: ghPR.ID, CloseIntent: false,
ReferenceOnly: false, LinkedByType: strToText("system"),
}); err != nil {
t.Fatalf("LinkIssueToPullRequest: %v", err)
}
// MERGED close-intent VCS PR linked to the same issue.
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, CloseIntent: true,
ReferenceOnly: false, LinkedByType: strToText("system"),
}); err != nil {
t.Fatalf("LinkIssueToVCSPullRequest: %v", err)
}
counts, err := testHandler.Queries.GetIssueCombinedPullRequestCloseAggregate(ctx, parseUUID(issue.ID))
if err != nil {
t.Fatalf("GetIssueCombinedPullRequestCloseAggregate: %v", err)
}
if counts.OpenCount != 1 {
t.Errorf("open_count = %d, want 1 (the open GitHub PR must be seen)", counts.OpenCount)
}
if counts.MergedWithCloseIntentCount != 1 {
t.Errorf("merged_with_close_intent_count = %d, want 1 (the VCS MR)", counts.MergedWithCloseIntentCount)
}
}
// 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 redelivered older event must not rewrite the link metadata that a newer
// event already set. The PR-upsert monotonic guard protects the PR row; this
// covers the link (close_intent / reference_only).
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 qualifying Closes → close_intent, not reference_only.
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 rewrites the link to close_intent=false, reference_only=true.
fire("opened", "open", false, "WIP", "touches "+issue.Identifier, "2026-05-01T00:00:00Z")
var closeIntent, referenceOnly bool
if err := testPool.QueryRow(ctx,
`SELECT close_intent, reference_only FROM issue_vcs_pull_request WHERE issue_id = $1`,
issue.ID).Scan(&closeIntent, &referenceOnly); err != nil {
t.Fatalf("select link: %v", err)
}
if !closeIntent || referenceOnly {
t.Errorf("stale event rewrote link: close_intent=%v reference_only=%v, want true/false", closeIntent, referenceOnly)
}
// 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)
}
}
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)
}
}