mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +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>
299 lines
9.6 KiB
Go
299 lines
9.6 KiB
Go
package vcs
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRegistry(t *testing.T) {
|
|
for _, k := range []string{"forgejo", "gitea", "gitlab"} {
|
|
if _, ok := For(k); !ok {
|
|
t.Errorf("registry missing provider %q", k)
|
|
}
|
|
}
|
|
if _, ok := For("bitbucket"); ok {
|
|
t.Error("unknown provider should not resolve")
|
|
}
|
|
}
|
|
|
|
func TestKindValid(t *testing.T) {
|
|
if !KindForgejo.Valid() || !KindGitea.Valid() || !KindGitLab.Valid() {
|
|
t.Error("known kinds must be valid")
|
|
}
|
|
if Kind("svn").Valid() {
|
|
t.Error("unknown kind must be invalid")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeInstanceURL(t *testing.T) {
|
|
cases := map[string]string{
|
|
"https://forgejo.example.com/": "https://forgejo.example.com",
|
|
" https://forge.test ": "https://forge.test",
|
|
"https://forge.test/sub/": "https://forge.test/sub",
|
|
}
|
|
for in, want := range cases {
|
|
if got := NormalizeInstanceURL(in); got != want {
|
|
t.Errorf("NormalizeInstanceURL(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestForgejoVerifySignature(t *testing.T) {
|
|
p, _ := For("forgejo")
|
|
secret, body := "s3cr3t", []byte(`{"action":"opened"}`)
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(body)
|
|
valid := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
h := http.Header{}
|
|
h.Set("X-Gitea-Signature", valid)
|
|
if !p.VerifySignature(secret, h, body) {
|
|
t.Error("valid signature rejected")
|
|
}
|
|
h.Set("X-Gitea-Signature", "sha256="+valid)
|
|
if !p.VerifySignature(secret, h, body) {
|
|
t.Error("sha256-prefixed signature rejected")
|
|
}
|
|
if p.VerifySignature("wrong", h, body) {
|
|
t.Error("wrong secret accepted")
|
|
}
|
|
if p.VerifySignature(secret, h, []byte("tampered")) {
|
|
t.Error("tampered body accepted")
|
|
}
|
|
// An empty secret must be rejected outright — HMAC with an empty key is
|
|
// forgeable. Sign the body with the empty key and confirm it's still refused.
|
|
emptyMac := hmac.New(sha256.New, []byte(""))
|
|
emptyMac.Write(body)
|
|
h.Set("X-Gitea-Signature", hex.EncodeToString(emptyMac.Sum(nil)))
|
|
if p.VerifySignature("", h, body) {
|
|
t.Error("empty secret accepted")
|
|
}
|
|
}
|
|
|
|
func TestGitlabVerifySignature(t *testing.T) {
|
|
p, _ := For("gitlab")
|
|
h := http.Header{}
|
|
h.Set("X-Gitlab-Token", "tok123")
|
|
if !p.VerifySignature("tok123", h, nil) {
|
|
t.Error("matching token rejected")
|
|
}
|
|
if p.VerifySignature("other", h, nil) {
|
|
t.Error("mismatched token accepted")
|
|
}
|
|
if p.VerifySignature("", h, nil) {
|
|
t.Error("empty stored secret must never validate")
|
|
}
|
|
}
|
|
|
|
func TestForgejoEventKindAndParse(t *testing.T) {
|
|
p, _ := For("gitea")
|
|
h := http.Header{}
|
|
h.Set("X-Gitea-Event", "pull_request")
|
|
if p.EventKind(h) != EventPullRequest {
|
|
t.Error("pull_request not classified")
|
|
}
|
|
h.Set("X-Gitea-Event", "status")
|
|
if p.EventKind(h) != EventCIStatus {
|
|
t.Error("status not classified")
|
|
}
|
|
|
|
pr, err := p.ParsePullRequest([]byte(`{
|
|
"action":"closed",
|
|
"pull_request":{"number":7,"title":"Fix MUL-1","state":"closed","merged":true,
|
|
"html_url":"https://g/acme/widget/pulls/7","head":{"ref":"fix","sha":"abc"},
|
|
"user":{"username":"octo"},"additions":3},
|
|
"repository":{"name":"widget","owner":{"username":"acme"}}}`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePullRequest: %v", err)
|
|
}
|
|
if pr.RepoOwner != "acme" || pr.RepoName != "widget" || pr.Number != 7 {
|
|
t.Errorf("bad identity: %+v", pr)
|
|
}
|
|
if pr.State != "merged" {
|
|
t.Errorf("state = %q, want merged", pr.State)
|
|
}
|
|
if pr.AuthorLogin != "octo" || pr.HeadSHA != "abc" {
|
|
t.Errorf("bad author/sha: %+v", pr)
|
|
}
|
|
if !pr.Terminal() {
|
|
t.Error("closed action must be terminal")
|
|
}
|
|
|
|
st, err := p.ParseCIStatus([]byte(`{"sha":"abc","context":"ci","state":"success"}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseCIStatus: %v", err)
|
|
}
|
|
if st.State != "passed" {
|
|
t.Errorf("state = %q, want passed", st.State)
|
|
}
|
|
}
|
|
|
|
func TestGitlabParse(t *testing.T) {
|
|
p, _ := For("gitlab")
|
|
h := http.Header{}
|
|
h.Set("X-Gitlab-Event", "Merge Request Hook")
|
|
if p.EventKind(h) != EventPullRequest {
|
|
t.Error("MR hook not classified")
|
|
}
|
|
|
|
pr, err := p.ParsePullRequest([]byte(`{
|
|
"object_kind":"merge_request",
|
|
"user":{"username":"alice","avatar_url":"a"},
|
|
"project":{"path_with_namespace":"group/sub/widget"},
|
|
"object_attributes":{"iid":42,"title":"Add MUL-9","state":"merged","action":"merge",
|
|
"source_branch":"feat","url":"https://gl/group/sub/widget/-/merge_requests/42",
|
|
"last_commit":{"id":"deadbeef"}}}`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePullRequest: %v", err)
|
|
}
|
|
if pr.RepoOwner != "group/sub" || pr.RepoName != "widget" {
|
|
t.Errorf("namespace split wrong: owner=%q name=%q", pr.RepoOwner, pr.RepoName)
|
|
}
|
|
if pr.Number != 42 || pr.State != "merged" || pr.HeadSHA != "deadbeef" {
|
|
t.Errorf("bad MR: %+v", pr)
|
|
}
|
|
if !pr.Terminal() {
|
|
t.Error("merge action must be terminal")
|
|
}
|
|
|
|
h.Set("X-Gitlab-Event", "Pipeline Hook")
|
|
if p.EventKind(h) != EventCIStatus {
|
|
t.Error("pipeline hook not classified")
|
|
}
|
|
st, err := p.ParseCIStatus([]byte(`{"object_kind":"pipeline","object_attributes":{"sha":"deadbeef","status":"failed"}}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseCIStatus: %v", err)
|
|
}
|
|
if st.SHA != "deadbeef" || st.State != "failed" || st.Context != "gitlab/pipeline" {
|
|
t.Errorf("bad status: %+v", st)
|
|
}
|
|
}
|
|
|
|
func TestGitlabDraftDetection(t *testing.T) {
|
|
p, _ := For("gitlab")
|
|
pr, _ := p.ParsePullRequest([]byte(`{"object_kind":"merge_request",
|
|
"project":{"path_with_namespace":"g/r"},
|
|
"object_attributes":{"iid":1,"title":"Draft: wip","state":"opened","work_in_progress":true}}`))
|
|
if pr.State != "draft" {
|
|
t.Errorf("state = %q, want draft", pr.State)
|
|
}
|
|
}
|
|
|
|
func TestValidateToken(t *testing.T) {
|
|
cases := []struct {
|
|
kind, path, authHeader, authValue, bodyJSON, wantLogin string
|
|
}{
|
|
{"forgejo", "/api/v1/user", "Authorization", "token tok", `{"login":"octo"}`, "octo"},
|
|
{"gitlab", "/api/v4/user", "PRIVATE-TOKEN", "tok", `{"username":"alice"}`, "alice"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.kind, func(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != c.path {
|
|
t.Errorf("path = %q, want %q", r.URL.Path, c.path)
|
|
}
|
|
if r.Header.Get(c.authHeader) != c.authValue {
|
|
t.Errorf("%s = %q, want %q", c.authHeader, r.Header.Get(c.authHeader), c.authValue)
|
|
}
|
|
_, _ = w.Write([]byte(c.bodyJSON))
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := For(c.kind)
|
|
acct, err := p.ValidateToken(context.Background(), srv.URL, "tok")
|
|
if err != nil {
|
|
t.Fatalf("ValidateToken: %v", err)
|
|
}
|
|
if acct.Login != c.wantLogin {
|
|
t.Errorf("login = %q, want %q", acct.Login, c.wantLogin)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// GitLab sends timestamps as "2017-09-20 08:31:45 UTC" (not RFC3339). The
|
|
// shared handler parser is RFC3339-only, so without normalization every GitLab
|
|
// timestamp was dropped and replaced with ingestion time — defeating the
|
|
// monotonic guards on PR upsert and commit status for GitLab specifically, and
|
|
// skewing PR list ordering. These assert the adapter normalizes to RFC3339.
|
|
func TestNormalizeGitLabTime(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"", ""},
|
|
{"2017-09-20 08:31:45 UTC", "2017-09-20T08:31:45Z"},
|
|
{"2017-09-20T08:31:45Z", "2017-09-20T08:31:45Z"},
|
|
// Sub-second precision is preserved (RFC3339Nano output) so two events in
|
|
// the same wall-clock second still order correctly under the monotonic
|
|
// guards; a zero fraction still formats without a decimal part.
|
|
{"2017-09-20T08:31:45.123Z", "2017-09-20T08:31:45.123Z"},
|
|
{"2017-09-20 08:31:45.123456 UTC", "2017-09-20T08:31:45.123456Z"},
|
|
{"not a time", ""},
|
|
}
|
|
for _, c := range cases {
|
|
if got := normalizeGitLabTime(c.in); got != c.want {
|
|
t.Errorf("normalizeGitLabTime(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGitlabParseTimestamps(t *testing.T) {
|
|
p, _ := For("gitlab")
|
|
// A merge_request payload with GitLab-format created_at/updated_at.
|
|
pr, err := p.ParsePullRequest([]byte(`{
|
|
"object_kind":"merge_request",
|
|
"project":{"path_with_namespace":"g/r"},
|
|
"object_attributes":{"iid":1,"title":"MUL-1","state":"opened","action":"open",
|
|
"created_at":"2017-09-20 08:31:45 UTC","updated_at":"2017-09-21 09:00:00 UTC",
|
|
"last_commit":{"id":"abc"}}}`))
|
|
if err != nil {
|
|
t.Fatalf("ParsePullRequest: %v", err)
|
|
}
|
|
if pr.CreatedAt != "2017-09-20T08:31:45Z" || pr.UpdatedAt != "2017-09-21T09:00:00Z" {
|
|
t.Errorf("timestamps not normalized: created=%q updated=%q", pr.CreatedAt, pr.UpdatedAt)
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, pr.UpdatedAt); err != nil {
|
|
t.Errorf("UpdatedAt not RFC3339: %v", err)
|
|
}
|
|
|
|
// A pipeline payload's finished_at feeds the commit-status guard.
|
|
st, err := p.ParseCIStatus([]byte(`{"object_kind":"pipeline",
|
|
"object_attributes":{"sha":"abc","status":"success",
|
|
"created_at":"2017-09-20 08:00:00 UTC","finished_at":"2017-09-20 08:05:00 UTC"}}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseCIStatus: %v", err)
|
|
}
|
|
if st.UpdatedAt != "2017-09-20T08:05:00Z" {
|
|
t.Errorf("CI UpdatedAt = %q, want finished_at normalized", st.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestForgejoStatusTimestamp(t *testing.T) {
|
|
p, _ := For("forgejo")
|
|
// Forgejo/Gitea status payloads carry RFC3339 created_at/updated_at.
|
|
st, err := p.ParseCIStatus([]byte(`{"sha":"abc","context":"ci","state":"success",
|
|
"created_at":"2017-09-20T08:00:00Z","updated_at":"2017-09-20T08:05:00Z"}`))
|
|
if err != nil {
|
|
t.Fatalf("ParseCIStatus: %v", err)
|
|
}
|
|
if st.UpdatedAt != "2017-09-20T08:05:00Z" {
|
|
t.Errorf("CI UpdatedAt = %q, want updated_at", st.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
func TestValidateTokenUnauthorized(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer srv.Close()
|
|
p, _ := For("forgejo")
|
|
_, err := p.ValidateToken(context.Background(), srv.URL, "bad")
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("err = %v, want ErrUnauthorized", err)
|
|
}
|
|
}
|