Files
zeekayandHanzo Dev 472238ddb0 cd: the API is /v1, not /api/v1 — no /api prefix anywhere
House rule: /v1/ is the API prefix, everywhere, and /api/ is never added on
top of it. The api.* subdomain names the API; a path prefix restating it is
noise. This applies it to the whole route surface:

  /api/v1/*      -> /v1/*        106 proto annotations, 109 gateway patterns
  /api/version   -> /v1/version
  /api/badge     -> /v1/badge
  /api/webhook   -> /v1/webhook  (server + applicationset controller)
  /api/dex/*     -> /v1/dex/*    (constants; dex is built, not deployed)

The grpc-gateway .pb.gw.go files are generated, and the plugin toolchain is
not on this machine, so they were rewritten by a script that applies exactly
the transform codegen would: for /api/v1 paths, drop the leading "api"
literal-push opcode pair and decrement every LitPush(2,N)/Capture(5,N) pool
index; for /api/version, rename the segment. ConcatN(4,N) operands are
counts, not pool indexes, and are untouched. The .proto annotations moved in
the same commit, so the next real codegen run is a no-op against these files.

MustPattern panics at package init on a malformed pattern, so every rewritten
pattern is validated by any import of pkg/apiclient — those tests pass.

Also moved: assets/swagger.json (82 paths), the UI's two roots (apiRoot()
'/v1', version-service '/v1/version') and its two relative composers (app
logs download, badge panel), the five audit-pattern regexes in server.go, and
the tests that asserted the old paths. terminal_test's request URL claimed
/api/v1/terminal, a route that never existed — the handler is mounted at
/terminal; the fixture now says so.

NOT touched, deliberately: /api/v3 in GitHub Enterprise base-url examples
(GitHub's URL shape, not ours), util/vendored/kubeclientmetrics (parses the
KUBERNETES API server's /api/v1 paths), and util/http's cookie-path fixture
(arbitrary string exercising slash normalization).

go build ./... clean. Pre-existing failures in badge/session/webhook/account
tests reproduce identically on the parent commit (rebrand fallout — badge
colors, cookie name length, dex issuer) and are not made worse here.

External consumers checked: universe has zero references to cd.hanzo.ai/api;
the CLI speaks gRPC, not the REST gateway; UI and server ship in one image so
they move together.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-29 12:01:34 -07:00

242 lines
5.8 KiB
Go

package webhook
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/hanzoai/cd/pkg/apis/application/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
kubetesting "k8s.io/client-go/testing"
)
func TestNormalizeOCI(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
expected string
}{
{"strips oci:// prefix and lowercases", "oci://GHCR.IO/USER/REPO", "ghcr.io/user/repo"},
{"strips oci:// prefix and trailing slash", "oci://ghcr.io/user/repo/", "ghcr.io/user/repo"},
{"already normalized with prefix", "oci://ghcr.io/user/repo", "ghcr.io/user/repo"},
{"without oci:// prefix", "ghcr.io/user/repo", "ghcr.io/user/repo"},
{"uppercase without prefix", "GHCR.IO/USER/REPO", "ghcr.io/user/repo"},
{"empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := normalizeOCI(tt.url)
assert.Equal(t, tt.expected, got)
})
}
}
func TestGHCRHandlerCanHandle(t *testing.T) {
t.Parallel()
h := newGHCRParser("")
tests := []struct {
name string
event string
expected bool
}{
{"package event", "package", true},
{"registry package event", "registry_package", false},
{"push event", "push", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", http.NoBody)
req.Header.Set("X-GitHub-Event", tt.event)
assert.Equal(t, tt.expected, h.CanHandle(req))
})
}
}
func TestRegistryPackageEvent(t *testing.T) {
hook := test.NewGlobal()
h := NewMockHandler(nil, []string{})
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/webhook", http.NoBody)
req.Header.Set("X-GitHub-Event", "package")
payload, err := os.ReadFile("testdata/ghcr-package-event.json")
require.NoError(t, err)
req.Body = io.NopCloser(bytes.NewReader(payload))
w := httptest.NewRecorder()
h.Handler(w, req)
h.Shutdown()
assert.Equal(t, http.StatusOK, w.Code)
assertLogContains(t, hook, "Received registry webhook event")
}
func TestHandleRegistryEvent_RefreshMatchingApp(t *testing.T) {
hook := test.NewGlobal()
patchedApps := []string{}
reaction := func(action kubetesting.Action) (bool, runtime.Object, error) {
patch := action.(kubetesting.PatchAction)
patchedApps = append(patchedApps, patch.GetName())
return true, nil, nil
}
h := NewMockHandler(
&reactorDef{"patch", "applications", reaction},
[]string{},
&v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "oci-app",
Namespace: "cd",
},
Spec: v1alpha1.ApplicationSpec{
Sources: v1alpha1.ApplicationSources{
{
RepoURL: "oci://ghcr.io/user/repo",
TargetRevision: "1.0.0",
},
},
},
},
)
event := &RegistryEvent{
RegistryURL: "ghcr.io",
Repository: "user/repo",
Tag: "1.0.0",
}
h.HandleRegistryEvent(event)
assert.Contains(t, patchedApps, "oci-app")
assert.Contains(t, hook.LastEntry().Message, "Requested app 'oci-app' refresh")
}
func TestHandleRegistryEvent_RepoMismatch(t *testing.T) {
logrus.SetLevel(logrus.DebugLevel)
hook := test.NewGlobal()
h := NewMockHandler(nil, []string{},
&v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "oci-app",
Namespace: "cd",
},
Spec: v1alpha1.ApplicationSpec{
Sources: v1alpha1.ApplicationSources{
{
RepoURL: "oci://ghcr.io/other/repo",
TargetRevision: "1.0.0",
},
},
},
},
)
event := &RegistryEvent{
RegistryURL: "ghcr.io",
Repository: "user/repo",
Tag: "1.0.0",
}
h.HandleRegistryEvent(event)
assert.Contains(t, hook.LastEntry().Message, "Skipping app: OCI repository URLs do not match")
}
func TestHandleRegistryEvent_RevisionMismatch(t *testing.T) {
logrus.SetLevel(logrus.DebugLevel)
hook := test.NewGlobal()
h := NewMockHandler(
nil,
[]string{},
&v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "oci-app",
Namespace: "cd",
},
Spec: v1alpha1.ApplicationSpec{
Sources: v1alpha1.ApplicationSources{
{
RepoURL: "oci://ghcr.io/user/repo",
TargetRevision: "2.0.0",
},
},
},
},
)
event := &RegistryEvent{
RegistryURL: "ghcr.io",
Repository: "user/repo",
Tag: "1.0.0",
}
h.HandleRegistryEvent(event)
assert.Contains(t, hook.LastEntry().Message, "Skipping app: revision does not match targetRevision")
}
func TestHandleRegistryEvent_NamespaceFiltering(t *testing.T) {
t.Parallel()
patched := []string{}
reaction := func(action kubetesting.Action) (bool, runtime.Object, error) {
patch := action.(kubetesting.PatchAction)
patched = append(patched, patch.GetNamespace())
return true, nil, nil
}
h := NewMockHandler(
&reactorDef{"patch", "applications", reaction},
[]string{"team-*"},
&v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "app1",
Namespace: "team-a",
},
Spec: v1alpha1.ApplicationSpec{
Sources: v1alpha1.ApplicationSources{
{RepoURL: "oci://ghcr.io/user/repo", TargetRevision: "1.0.0"},
},
},
},
&v1alpha1.Application{
ObjectMeta: metav1.ObjectMeta{
Name: "app2",
Namespace: "kube-system",
},
Spec: v1alpha1.ApplicationSpec{
Sources: v1alpha1.ApplicationSources{
{RepoURL: "oci://ghcr.io/user/repo", TargetRevision: "1.0.0"},
},
},
},
)
event := &RegistryEvent{
RegistryURL: "ghcr.io",
Repository: "user/repo",
Tag: "1.0.0",
}
h.HandleRegistryEvent(event)
assert.Contains(t, patched, "team-a")
assert.NotContains(t, patched, "kube-system")
}