package webhook import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // ── unit: updateImageEntry ──────────────────────────────────────────────────── func TestUpdateImageEntry(t *testing.T) { t.Run("replaces existing by bare name strips tag", func(t *testing.T) { images := []string{"forgejo.riotpiao.com/rock/api-gateway:old"} got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { t.Fatalf("got %v", got) } }) t.Run("replaces existing by bare name strips digest", func(t *testing.T) { images := []string{"forgejo.riotpiao.com/rock/api-gateway@sha256:old"} got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { t.Fatalf("got %v", got) } }) t.Run("appends new entry when image not in list", func(t *testing.T) { images := []string{"forgejo.riotpiao.com/rock/other:latest"} got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") if len(got) != 2 || got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { t.Fatalf("got %v", got) } }) t.Run("preserves other entries when updating one", func(t *testing.T) { images := []string{ "forgejo.riotpiao.com/rock/other:1.0", "forgejo.riotpiao.com/rock/api-gateway:old", "forgejo.riotpiao.com/rock/third:2.0", } got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new") if len(got) != 3 { t.Fatalf("expected 3 entries got %d: %v", len(got), got) } if got[0] != "forgejo.riotpiao.com/rock/other:1.0" { t.Errorf("first entry changed: %s", got[0]) } if got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" { t.Errorf("second entry wrong: %s", got[1]) } if got[2] != "forgejo.riotpiao.com/rock/third:2.0" { t.Errorf("third entry changed: %s", got[2]) } }) } // ── unit: mergeHelmParams ───────────────────────────────────────────────────── func TestMergeHelmParams(t *testing.T) { t.Run("update existing param preserve unrelated", func(t *testing.T) { existing := []map[string]string{ {"name": "runner.image.tag", "value": "old"}, {"name": "replicas", "value": "2"}, } updates := []map[string]string{ {"name": "runner.image.tag", "value": "new"}, } got := mergeHelmParams(existing, updates) find := func(name string) string { for _, p := range got { if p["name"] == name { return p["value"] } } return "" } if find("runner.image.tag") != "new" { t.Errorf("tag not updated: %v", got) } if find("replicas") != "2" { t.Errorf("replicas lost: %v", got) } }) t.Run("append new param not in existing list", func(t *testing.T) { existing := []map[string]string{ {"name": "replicas", "value": "2"}, } updates := []map[string]string{ {"name": "runner.image.tag", "value": "new"}, } got := mergeHelmParams(existing, updates) if len(got) != 2 { t.Fatalf("expected 2 params got %d: %v", len(got), got) } }) t.Run("empty existing list just returns updates", func(t *testing.T) { updates := []map[string]string{ {"name": "runner.image.tag", "value": "new"}, } got := mergeHelmParams(nil, updates) if len(got) != 1 || got[0]["name"] != "runner.image.tag" { t.Fatalf("got %v", got) } }) } // ── unit: lookupEntry ───────────────────────────────────────────────────────── func TestLookupEntry(t *testing.T) { t.Run("known image returns entry", func(t *testing.T) { e := lookupEntry("forgejo.riotpiao.com/rock/api-gateway") if e == nil { t.Fatal("expected entry, got nil") } if e.Application != "api-gw" { t.Errorf("wrong app: %s", e.Application) } }) t.Run("unknown image returns nil", func(t *testing.T) { e := lookupEntry("forgejo.riotpiao.com/rock/unknown") if e != nil { t.Fatalf("expected nil, got %+v", e) } }) } // ── unit: toStringSlice ─────────────────────────────────────────────────────── func TestToStringSlice(t *testing.T) { t.Run("skips non-string elements", func(t *testing.T) { got := toStringSlice([]any{"a", "b", 42}) if len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Fatalf("got %v", got) } }) t.Run("nil input returns nil", func(t *testing.T) { got := toStringSlice(nil) if got != nil { t.Fatalf("expected nil, got %v", got) } }) } // ── helpers ─────────────────────────────────────────────────────────────────── func newPromoter(regURL, k8sURL string) *ImagePromoter { return &ImagePromoter{ registryBase: regURL, registryUser: "testuser", registryPAT: "testpat", k8sHost: k8sURL, k8sClient: &http.Client{Timeout: 5 * time.Second}, regClient: &http.Client{Timeout: 5 * time.Second}, } } func mustJSON(v any) []byte { b, err := json.Marshal(v) if err != nil { panic(err) } return b } // ── integration: fetchDigest ────────────────────────────────────────────────── func TestFetchDigest_Success(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Docker-Content-Digest", "sha256:abc123") w.WriteHeader(http.StatusOK) })) defer reg.Close() ctx := context.Background() p := newPromoter(reg.URL, "") digest, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") if err != nil { t.Fatalf("unexpected error: %v", err) } if digest != "sha256:abc123" { t.Errorf("got digest %q", digest) } } func TestFetchDigest_NotFound(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) })) defer reg.Close() ctx := context.Background() p := newPromoter(reg.URL, "") _, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "404") { t.Errorf("error should contain 404: %v", err) } } func TestFetchDigest_MissingDigestHeader(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) // No Docker-Content-Digest header })) defer reg.Close() ctx := context.Background() p := newPromoter(reg.URL, "") _, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "Docker-Content-Digest") { t.Errorf("error should mention Docker-Content-Digest: %v", err) } } // ── integration: Promote kustomize ─────────────────────────────────────────── func TestPromote_KustomizeSuccess(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Docker-Content-Digest", "sha256:newdigest") w.WriteHeader(http.StatusOK) })) defer reg.Close() kustomizeApp := map[string]any{ "spec": map[string]any{ "sources": []any{ map[string]any{ "kustomize": map[string]any{ "images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"}, }, }, }, }, } var getCalled, patchCalled int32 k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: atomic.AddInt32(&getCalled, 1) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(mustJSON(kustomizeApp)) case http.MethodPatch: atomic.AddInt32(&patchCalled, 1) raw, _ := readAll(r.Body) if !strings.Contains(string(raw), `"/spec/sources/0/kustomize"`) { t.Errorf("patch path missing: %s", raw) } if !strings.Contains(string(raw), `"images"`) { t.Errorf("images key missing from kustomize block: %s", raw) } if !strings.Contains(string(raw), "@sha256:newdigest") { t.Errorf("digest missing from patch body: %s", raw) } w.WriteHeader(http.StatusOK) } })) defer k8s.Close() p := newPromoter(reg.URL, k8s.URL) ctx := context.Background() if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil { t.Fatalf("Promote error: %v", err) } if atomic.LoadInt32(&getCalled) == 0 { t.Error("k8s GET not called") } if atomic.LoadInt32(&patchCalled) == 0 { t.Error("k8s PATCH not called") } } // ── integration: Promote helm ───────────────────────────────────────────────── func TestPromote_HelmSuccess(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Docker-Content-Digest", "sha256:helmdigest") w.WriteHeader(http.StatusOK) })) defer reg.Close() helmApp := map[string]any{ "spec": map[string]any{ "source": map[string]any{ "helm": map[string]any{ "parameters": []any{ map[string]any{"name": "runner.image.tag", "value": "old"}, }, }, }, }, } var patchCalled int32 k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(mustJSON(helmApp)) case http.MethodPatch: atomic.AddInt32(&patchCalled, 1) raw, _ := readAll(r.Body) if !strings.Contains(string(raw), "/spec/source/helm/parameters") { t.Errorf("patch path missing: %s", raw) } if !strings.Contains(string(raw), "helmdigest") { t.Errorf("digest missing from patch body: %s", raw) } w.WriteHeader(http.StatusOK) } })) defer k8s.Close() p := newPromoter(reg.URL, k8s.URL) ctx := context.Background() if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/forgejo-runner-golang", "latest"); err != nil { t.Fatalf("Promote error: %v", err) } if atomic.LoadInt32(&patchCalled) == 0 { t.Error("k8s PATCH not called") } } // ── integration: Promote unknown image ─────────────────────────────────────── func TestPromote_UnknownImage(t *testing.T) { p := newPromoter("http://unused", "http://unused") ctx := context.TODO() err := p.Promote(ctx, "forgejo.riotpiao.com/rock/unknown-image", "latest") if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "not in promotion table") { t.Errorf("error should mention 'not in promotion table': %v", err) } } // ── integration: Promote conflict retry ────────────────────────────────────── func TestPromote_Conflict_Retry(t *testing.T) { reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Docker-Content-Digest", "sha256:retrydigest") w.WriteHeader(http.StatusOK) })) defer reg.Close() kustomizeApp := map[string]any{ "spec": map[string]any{ "sources": []any{ map[string]any{ "kustomize": map[string]any{ "images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"}, }, }, }, }, } var patchCount int32 k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(mustJSON(kustomizeApp)) case http.MethodPatch: n := atomic.AddInt32(&patchCount, 1) if n < 3 { w.WriteHeader(http.StatusConflict) // 409 return } w.WriteHeader(http.StatusOK) } })) defer k8s.Close() p := newPromoter(reg.URL, k8s.URL) ctx := context.Background() if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil { t.Fatalf("Promote error after retries: %v", err) } if got := atomic.LoadInt32(&patchCount); got != 3 { t.Errorf("expected 3 PATCH calls, got %d", got) } } // readAll is a test helper to read request body. func readAll(r io.Reader) ([]byte, error) { return io.ReadAll(r) }