package stacks import ( "path/filepath" "strings" "testing" "time" ) var dA = "sha256:" + strings.Repeat("a", 64) var dB = "sha256:" + strings.Repeat("b", 64) func TestDigest_RenderAndStrip(t *testing.T) { in := "# header\nservices:\n web:\n image: redis:7-alpine # pinned\n restart: unless-stopped\n db:\n image: \"mariadb:11.8\"\nvolumes:\n x:\n" out := string(renderDigests([]byte(in), map[string]string{"web": dA, "db": "not-a-digest"})) if !strings.Contains(out, " image: redis:7-alpine@"+dA+" # pinned") { t.Fatalf("web not rendered:\n%s", out) } if !strings.Contains(out, " image: \"mariadb:11.8\"") { t.Fatalf("an invalid digest must leave the line alone:\n%s", out) } again := string(renderDigests([]byte(out), map[string]string{"web": dB})) if strings.Count(again, "@sha256:") != 1 || !strings.Contains(again, "redis:7-alpine@"+dB) { t.Fatalf("re-render must replace, not stack, the digest:\n%s", again) } imgs, err := parseComposeImagesBytes([]byte(again)) if err != nil || imgs["web"] != "redis:7-alpine" { t.Fatalf("the parsed ref must be digest-free: %v %v", imgs, err) } } // TestDigest_FloatingTagBehindOnlyForANewerTestedDigest — the badge rule, every arm. // // COMPANION RED-PROOF (REPORT): make digestBehind return false — the "newer test" case then reads // Current and this test fails. func TestDigest_FloatingTagBehindOnlyForANewerTestedDigest(t *testing.T) { installAt := time.Date(2026, 9, 20, 0, 0, 0, 0, time.UTC) mk := func(instDigest, instAt string, cat map[string]string, testedAt time.Time) Stack { return Stack{Deployed: true, CatalogImages: map[string]string{"web": "redis:7-alpine"}, CatalogDigests: cat, CatalogTestedAt: testedAt, AppConfig: &AppConfig{InstalledImages: map[string]InstalledImage{"web": {Ref: "redis:7-alpine", Digest: instDigest, At: instAt}}}} } cases := []struct { name string s Stack want UpdateOrder }{ {"same digest", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dA}, installAt.Add(time.Hour)), UpdateOrderCurrent}, {"newer tested digest", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderBehind}, {"test older than the install", mk(dA, installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(-time.Hour)), UpdateOrderCurrent}, {"install time unknown", mk(dA, "", map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderCurrent}, {"installed digest unknown", mk("", installAt.Format(time.RFC3339), map[string]string{"web": dB}, installAt.Add(time.Hour)), UpdateOrderCurrent}, {"no tested digest", mk(dA, installAt.Format(time.RFC3339), nil, time.Time{}), UpdateOrderCurrent}, } for _, c := range cases { if got := CatalogOrder(c.s); got != c.want { t.Errorf("%s: %v, want %v", c.name, got, c.want) } } } // The update runs EXACTLY the tested image: the pinned step's compose carries the entry's digest, while // the pin itself stays a plain ref. func TestDigest_TheUpdateRendersTheStepsTestedDigest(t *testing.T) { m, dir, _, _, _ := ladderManager(t, true) catDir := filepath.Dir(m.CatalogTemplatePath("nextcloud", "docker-compose.yml")) // the ladder fixture's digests are sha256:aaaa… if err := m.StartGuardedUpdate("nextcloud"); err != nil { t.Fatal(err) } waitUpdateDone(t, m, "nextcloud") body := fileBody(t, ComposePathIn(dir)) if !strings.Contains(body, "image: "+ladderB+"@"+dA) { t.Fatalf("the live compose does not pin the tested digest:\n%s", body) } if pinOf(t, dir) != ladderB { t.Fatalf("the pin must stay a plain ref, got %q", pinOf(t, dir)) } _ = catDir }