Files
tiny-forge/internal/webhook/vendor_parsers_test.go
T
alexei.dolgolyov 82d32181ba feat(webhook): vendor-specific event parsing (Gitea / GitHub / GitLab)
The /api/webhook/workloads/{secret} ingress now short-circuits on a
recognized X-*-Event header before falling back to the generic
simple-body parser. Vendor parsers populate fields the generic
parser cannot (image digest, GitEvent.Vendor, registry host).

internal/webhook/vendor_parsers.go covers:
- Gitea package events (X-Gitea-Event: package, container type)
- GitHub registry_package + package events (CONTAINER package_type)
- GitHub / Gitea push events with vendor stamping
- GitLab Push Hook + Tag Push Hook with path_with_namespace mapping

When a vendor parser claims a request (ok=true), it's authoritative
— a malformed Gitea package payload surfaces as an error rather
than silently re-parsing as generic. The generic {image} /
{ref + repository.full_name} fallback stays in place for legacy
CIs that send those shapes.

Coverage: internal/webhook/vendor_parsers_test.go +
inbound_event_test.go (round-trip through buildInboundEvent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:17:53 +03:00

292 lines
9.0 KiB
Go

package webhook
import (
"net/http"
"strings"
"testing"
)
func headerWith(k, v string) http.Header {
h := http.Header{}
h.Set(k, v)
return h
}
func TestParseGiteaPackageEvent_Container(t *testing.T) {
body := []byte(`{
"action": "created",
"package": {
"name": "my-app",
"type": "container",
"version": "v1.2.3",
"owner": {"login": "alexei"},
"repository": {"full_name": "alexei/my-app", "html_url": "https://git.example.com/alexei/my-app"}
}
}`)
res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package"))
if !res.ok {
t.Fatalf("expected ok=true")
}
if res.err != nil {
t.Fatalf("unexpected err: %v", res.err)
}
if res.event.Kind != "image-push" || res.event.Image == nil {
t.Fatalf("kind=%q image=%+v", res.event.Kind, res.event.Image)
}
if res.event.Image.Repo != "alexei/my-app" {
t.Errorf("Repo=%q want alexei/my-app", res.event.Image.Repo)
}
if res.event.Image.Tag != "v1.2.3" {
t.Errorf("Tag=%q want v1.2.3", res.event.Image.Tag)
}
if res.event.Image.Registry != "git.example.com" {
t.Errorf("Registry=%q want git.example.com", res.event.Image.Registry)
}
}
func TestParseGiteaPackageEvent_NonContainerRejected(t *testing.T) {
body := []byte(`{"action": "created", "package": {"name": "lib", "type": "npm", "version": "1.0.0"}}`)
res := parseGiteaPackageEvent(body, headerWith(headerGiteaEvent, "package"))
if !res.ok {
t.Fatalf("expected ok=true (claimed)")
}
if res.err == nil || !strings.Contains(res.err.Error(), "container") {
t.Errorf("expected container-type error, got %v", res.err)
}
}
func TestParseGiteaPackageEvent_NoHeaderSkips(t *testing.T) {
res := parseGiteaPackageEvent([]byte(`{}`), http.Header{})
if res.ok {
t.Errorf("expected ok=false when header missing")
}
}
func TestParseGitHubPackageEvent_RegistryPackage(t *testing.T) {
body := []byte(`{
"action": "published",
"registry_package": {
"name": "my-app",
"namespace": "owner",
"package_type": "CONTAINER",
"package_version": {
"name": "sha256:deadbeef",
"container_metadata": {"tag": {"name": "v2.0.0"}}
},
"registry": {"url": "https://ghcr.io/owner/my-app"}
}
}`)
res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "registry_package"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Image == nil || res.event.Image.Tag != "v2.0.0" {
t.Errorf("Tag mismatch: %+v", res.event.Image)
}
if res.event.Image.Digest != "sha256:deadbeef" {
t.Errorf("Digest=%q want sha256:deadbeef", res.event.Image.Digest)
}
if res.event.Image.Repo != "owner/my-app" {
t.Errorf("Repo=%q want owner/my-app", res.event.Image.Repo)
}
if res.event.Image.Registry != "ghcr.io" {
t.Errorf("Registry=%q want ghcr.io", res.event.Image.Registry)
}
}
func TestParseGitHubPackageEvent_PackageAlias(t *testing.T) {
// Older webhooks deliver under "package" with event name "package".
body := []byte(`{
"action": "published",
"package": {
"name": "img",
"namespace": "org",
"package_type": "container",
"package_version": {"container_metadata": {"tag": {"name": "latest"}}},
"registry": {"url": "https://ghcr.io/"}
}
}`)
res := parseGitHubPackageEvent(body, headerWith(headerGitHubEvent, "package"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Image.Repo != "org/img" {
t.Errorf("Repo=%q want org/img", res.event.Image.Repo)
}
if res.event.Image.Tag != "latest" {
t.Errorf("Tag=%q want latest", res.event.Image.Tag)
}
}
func TestParseGitHubPushEvent_StampsVendor(t *testing.T) {
body := []byte(`{
"ref": "refs/heads/main",
"after": "abc",
"repository": {"full_name": "owner/repo"},
"pusher": {"name": "alice"}
}`)
res := parseGitHubPushEvent(body, headerWith(headerGitHubEvent, "push"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Git == nil || res.event.Git.Vendor != "github" {
t.Errorf("Vendor=%q want github (git=%+v)", "", res.event.Git)
}
if res.event.Git.Branch != "main" {
t.Errorf("Branch=%q want main", res.event.Git.Branch)
}
}
func TestParseGiteaPushEvent_StampsVendor(t *testing.T) {
body := []byte(`{
"ref": "refs/tags/v1.0",
"after": "deadbeef",
"repository": {"full_name": "alexei/app"},
"pusher": {"username": "alexei"}
}`)
res := parseGiteaPushEvent(body, headerWith(headerGiteaEvent, "push"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Kind != "git-tag" {
t.Errorf("Kind=%q want git-tag", res.event.Kind)
}
if res.event.Git.Vendor != "gitea" {
t.Errorf("Vendor=%q want gitea", res.event.Git.Vendor)
}
if res.event.Git.Tag != "v1.0" {
t.Errorf("Tag=%q want v1.0", res.event.Git.Tag)
}
if res.event.Git.Pusher != "alexei" {
t.Errorf("Pusher=%q want alexei", res.event.Git.Pusher)
}
}
func TestParseGitLabPushEvent(t *testing.T) {
body := []byte(`{
"ref": "refs/heads/develop",
"after": "feedface",
"user_username": "bob",
"project": {"path_with_namespace": "group/proj", "git_http_url": "https://gitlab.example.com/group/proj.git"}
}`)
res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Push Hook"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Git.Vendor != "gitlab" {
t.Errorf("Vendor=%q want gitlab", res.event.Git.Vendor)
}
if res.event.Git.Repo != "group/proj" {
t.Errorf("Repo=%q want group/proj", res.event.Git.Repo)
}
if res.event.Git.Branch != "develop" {
t.Errorf("Branch=%q want develop", res.event.Git.Branch)
}
if res.event.Git.Pusher != "bob" {
t.Errorf("Pusher=%q want bob", res.event.Git.Pusher)
}
}
func TestParseGitLabTagPushEvent(t *testing.T) {
body := []byte(`{
"ref": "refs/tags/v3",
"after": "abc",
"user_name": "carol",
"project": {"path_with_namespace": "g/p"}
}`)
res := parseGitLabPushEvent(body, headerWith(headerGitLabEvent, "Tag Push Hook"))
if !res.ok || res.err != nil {
t.Fatalf("ok=%v err=%v", res.ok, res.err)
}
if res.event.Kind != "git-tag" || res.event.Git.Tag != "v3" {
t.Errorf("Tag mismatch: kind=%q git=%+v", res.event.Kind, res.event.Git)
}
if res.event.Git.Pusher != "carol" {
t.Errorf("Pusher=%q want carol (user_name fallback)", res.event.Git.Pusher)
}
}
func TestBuildInboundEvent_GiteaPackageRouted(t *testing.T) {
body := []byte(`{
"action": "created",
"package": {
"name": "svc",
"type": "container",
"version": "v9",
"owner": {"login": "alexei"},
"repository": {"html_url": "https://git.example.com/alexei/svc"}
}
}`)
evt, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package"))
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Image == nil || evt.Image.Tag != "v9" {
t.Errorf("expected Gitea-routed image-push, got %+v", evt)
}
// RawBody and Headers should round-trip even via the vendor branch.
if len(evt.RawBody) == 0 {
t.Errorf("RawBody should be attached")
}
if http.Header(evt.Headers).Get(headerGiteaEvent) != "package" {
t.Errorf("Headers not attached")
}
}
func TestBuildInboundEvent_FallbackToGeneric(t *testing.T) {
// No vendor header — generic simple-body parser still works.
body := []byte(`{"image":"reg.example.com/x/y:tag"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Image == nil || evt.Image.Tag != "tag" {
t.Errorf("generic fallback failed: %+v", evt)
}
}
func TestBuildInboundEvent_GenericRefStillSupported(t *testing.T) {
body := []byte(`{"ref":"refs/heads/main","repository":{"full_name":"a/b"},"after":"sha"}`)
evt, err := buildInboundEvent(body, http.Header{})
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if evt.Kind != "git-push" || evt.Git == nil || evt.Git.Branch != "main" {
t.Errorf("generic ref parse failed: %+v", evt)
}
// Generic path does NOT stamp a vendor — only the vendor-header
// paths do, so trigger plugins can tell them apart.
if evt.Git.Vendor != "" {
t.Errorf("generic parser should leave Vendor empty, got %q", evt.Git.Vendor)
}
}
func TestBuildInboundEvent_VendorErrorDoesNotFallThrough(t *testing.T) {
// A request with the Gitea header but a non-container package should
// error out cleanly rather than fall through to the generic parser
// (which would silently accept the body as JSON-with-no-fields).
body := []byte(`{"package":{"name":"x","type":"npm","version":"1.0"}}`)
_, err := buildInboundEvent(body, headerWith(headerGiteaEvent, "package"))
if err == nil {
t.Fatal("expected error for non-container Gitea package")
}
if !strings.Contains(err.Error(), "container") {
t.Errorf("error should mention container, got %v", err)
}
}
func TestRegistryHostFromGiteaRepoURL(t *testing.T) {
cases := []struct{ in, out string }{
{"https://git.example.com/owner/repo", "git.example.com"},
{"http://localhost:3000/o/r", "localhost:3000"},
{"git.example.com/owner/repo", "git.example.com"},
{"", ""},
}
for _, c := range cases {
got := registryHostFromGiteaRepoURL(c.in)
if got != c.out {
t.Errorf("registryHostFromGiteaRepoURL(%q) = %q, want %q", c.in, got, c.out)
}
}
}