mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
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>
527 lines
21 KiB
Go
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)
|
|
}
|
|
}
|