82d32181ba
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>
292 lines
9.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|