2026-09-28 02:45:41 +00:00
|
|
|
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)
|
2026-09-28 14:18:32 +07:00
|
|
|
if !strings.Contains(string(raw), `"/spec/sources/0/kustomize"`) {
|
2026-09-28 02:45:41 +00:00
|
|
|
t.Errorf("patch path missing: %s", raw)
|
|
|
|
|
}
|
2026-09-28 14:18:32 +07:00
|
|
|
if !strings.Contains(string(raw), `"images"`) {
|
|
|
|
|
t.Errorf("images key missing from kustomize block: %s", raw)
|
|
|
|
|
}
|
2026-09-28 02:45:41 +00:00
|
|
|
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)
|
|
|
|
|
}
|