package sync import ( "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) const ( digA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" digB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ) func ladderWithDigest(t *testing.T, catDir, dig string) { t.Helper() write(t, filepath.Join(catDir, ".felhom.yml"), "display_name: Nextcloud\nupdate_ladder:\n"+ ` - {"from": {"web": "nextcloud:30.0.0-apache"}, "to": {"web": "nextcloud:31.0.14-apache"}, "digest": {"web": "`+dig+`"}, "verdict": "proven", "tested_at": "2026-09-24T01:00:00Z"}`+"\n") } // v0.269.0 (`09` §6.4 part 6) — a FRESH INSTALL takes the tested digest: the syncer writes the catalog's // compose for an undeployed app WITH the digest of the ladder entry for exactly its refs. // // COMPANION RED-PROOF (REPORT): make RenderWithLadderDigests return its input — the image line stays a bare // tag and this test fails at "no digest in the rendered compose". func TestDigest_SyncerRendersTheTestedDigest(t *testing.T) { s, stackDir, catDir := renderFixture(t, tplOld) ladderWithDigest(t, catDir, digA) s.SetRenderPlanFn(func(string) stacks.RenderPlan { return stacks.RenderPlan{} }) // not deployed if _, _, err := s.copyTemplates(); err != nil { t.Fatal(err) } got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) if !strings.Contains(got, "image: nextcloud:31.0.14-apache@"+digA) { t.Fatalf("no digest in the rendered compose:\n%s", got) } } // TestDigest_SyncerKeepsTheRunningDigest — a DEPLOYED app keeps the digest it runs; a sync never moves it. // MEASURED LIVE (night 2026-09-24 Part B): the catalog re-tested redis:7-alpine at a new digest, the sync // wrote that digest into the RUNNING app's compose before anyone pressed Update, so the next restart would // have pulled it with no backup and no undo. The fix still flows (the healthcheck line), the stored // definition is refreshed with the same bytes, and an app that runs NO digest gets none from a sync. // // COMPANION RED-PROOF (REPORT): render the ladder's digest for a deployed app again (the pre-fix call site) // — this test fails at "the sync MOVED the running digest". func TestDigest_SyncerKeepsTheRunningDigest(t *testing.T) { for _, tc := range []struct{ name, live, want, notWant string }{ {"runs an older digest", strings.Replace(tplOld, "nextcloud:31.0.14-apache", "nextcloud:31.0.14-apache@"+digB, 1), "@" + digB, digA}, {"runs no digest", tplOld, "image: nextcloud:31.0.14-apache\n", digA}, } { t.Run(tc.name, func(t *testing.T) { s, stackDir, catDir := renderFixture(t, tplOldFixed) ladderWithDigest(t, catDir, digA) write(t, filepath.Join(stackDir, "docker-compose.yml"), tc.live) plan := pinnedPlan(stackDir, map[string]string{"web": "nextcloud:31.0.14-apache"}, false)("") plan.StackDir = stackDir s.SetRenderPlanFn(func(string) stacks.RenderPlan { return plan }) if _, _, err := s.copyTemplates(); err != nil { t.Fatal(err) } got := readFile(t, filepath.Join(stackDir, "docker-compose.yml")) if strings.Contains(got, tc.notWant) || !strings.Contains(got, tc.want) { t.Fatalf("the sync MOVED the running digest (want %q, never %q):\n%s", tc.want, tc.notWant, got) } if !strings.Contains(got, "/status.php") { t.Fatalf("the fix did not flow:\n%s", got) } if applied := readFile(t, stacks.AppliedComposePath(stackDir)); applied != got { t.Fatalf("the stored definition was not refreshed with the same bytes:\n%s", applied) } }) } }