Files
felhom-controller/controller/internal/web/updatebadge_test.go
T
admin 8025304acc
gates / gates (push) Successful in 12s
v0.233.0: record what each compose service actually installed, and badge whether it is current
Update arc slices 1 and 2. NEITHER CHANGES ANY BEHAVIOUR — no new endpoint, no
auto-update, the three lifecycle buttons byte-identical.

Slice 1 — app.yaml gains installed_images, keyed by compose SERVICE name, each
entry carrying ref + repo digest + first-seen timestamp. Written by
Manager.recordInstalledImages after a successful compose up from StartStack,
RestartStack, UpdateStack and runComposeDeploy. Read from the CONTAINER, never
from docker-compose.yml: the syncer overwrites a deployed app's compose on a
15-minute cycle and the two disagreed for 25 minutes in the spike's own
measurement. A failed write NEVER refuses the action - the deliberate opposite
of SetDesiredState, because this is an observation and that is an intent. Not
called from StartStackServices (the R-47 DB-only window). Its own docker seam
with a context and a 30s timeout, which neither existing exec helper has.

Slice 2 — .felhom.yml gains optional catalog_since; web.updateBadge compares the
recorded ref per service against what the current template pins and returns a
*MetaBadge through the EXISTING meta_badge partial. No new markup, no new CSS.
NO RECORD RENDERS NOTHING: absent means unknown and never means current. No
version number reaches the customer and no registry is queried.

Known limitation, filed not hidden: 23 catalog pins float, so those apps can read
Naprakesz when the image behind the tag has moved.

+17 tests (1707 -> 1724), 28 packages green. Wiring proven through a real
RestartStack plus an AST walk of the four call sites. Three companion red-proofs
run and reverted.
2026-09-02 20:18:01 +02:00

269 lines
11 KiB
Go

package web
import (
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Slice 2 (v0.233.0) — the badge. FOUR states, and the fourth is the load-bearing one:
// NO RECORD RENDERS NOTHING. An app with no record showing "Naprakész" is the R-166 failure in a
// new place — absent means UNKNOWN and never means current.
var badgeNow = time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
// ubStack builds a deployed app whose record and template pins are stated explicitly.
func ubStack(installed map[string]stacks.InstalledImage, template map[string]string, since string) stacks.Stack {
return stacks.Stack{
Name: "bookstack",
Deployed: true,
State: stacks.StateRunning,
Meta: stacks.Metadata{DisplayName: "BookStack", Slug: "bookstack", CatalogSince: since},
AppConfig: &stacks.AppConfig{Deployed: true, InstalledImages: installed},
TemplateImages: template,
}
}
func rec(ref string) stacks.InstalledImage {
return stacks.InstalledImage{Ref: ref, Digest: "sha256:x", At: "2026-09-01T00:00:00Z"}
}
// --- GROUP D: the four states ---
// TestGroupD_FourStates.
//
// COMPANION RED-PROOF (run 2026-09-02): in compareInstalledToTemplate, change the
// `len(s.AppConfig.InstalledImages) == 0` guard to fall through to updateCurrent instead of
// updateUnknown — i.e. the trivial implementation that treats "we never wrote it down" as
// "up to date". The `no record at all` sub-test then fails with a "Naprakész" badge on an app
// nobody has ever measured. Reverted.
func TestGroupD_FourStates(t *testing.T) {
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2", "db": "mariadb:12.3"}
cases := []struct {
name string
stack stacks.Stack
wantBadge bool
wantLabel string
wantClass string
}{
{
name: "current",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok",
},
{
name: "behind, age known",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, "2026-07-18"),
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
},
{
name: "behind, age unknown",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2"), "db": rec(tpl["db"])}, tpl, ""),
wantBadge: true, wantLabel: "Frissítés elérhető", wantClass: "tag-warn",
},
{
name: "behind, catalog moved TODAY",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("old:1"), "db": rec(tpl["db"])}, tpl, "2026-09-02"),
wantBadge: true, wantLabel: "Frissítés elérhető — ma", wantClass: "tag-warn",
},
{
name: "NO RECORD AT ALL (legacy app.yaml) — nothing rendered",
stack: ubStack(nil, tpl, "2026-07-18"),
wantBadge: false,
},
{
name: "a service was ADDED by the template",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
wantBadge: true, wantLabel: "Frissítés elérhető — 46 napja", wantClass: "tag-warn",
},
{
name: "template unreadable — nothing rendered",
stack: ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"]), "db": rec(tpl["db"])}, nil, "2026-07-18"),
wantBadge: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
b := updateBadgeAt(c.stack, badgeNow)
if (b != nil) != c.wantBadge {
t.Fatalf("badge = %+v, want present=%v", b, c.wantBadge)
}
if b == nil {
return
}
if b.Label != c.wantLabel {
t.Errorf("label = %q, want %q", b.Label, c.wantLabel)
}
if b.Class != c.wantClass {
t.Errorf("class = %q, want %q", b.Class, c.wantClass)
}
if b.Title == "" {
t.Error("a badge that is only a word is a riddle — it must carry an explanation")
}
})
}
}
// TestGroupD_NoVersionNumberIsEverShown — the operator ruled it: a household cannot act on
// "26.05.2". Version strings stay in the logs, the API and the hub.
func TestGroupD_NoVersionNumberIsEverShown(t *testing.T) {
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
for _, s := range []stacks.Stack{
ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18"),
ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18"),
} {
b := updateBadgeAt(s, badgeNow)
if b == nil {
t.Fatal("expected a badge")
}
for _, forbidden := range []string{"26.05.2", "25.02.2", "bookstack:", "mariadb", "sha256"} {
if strings.Contains(b.Label+b.Title, forbidden) {
t.Errorf("badge text leaks %q: label=%q title=%q", forbidden, b.Label, b.Title)
}
}
}
}
// TestGroupD_NothingIsBadgedThatCannotBeJudged — undeployed, protected and orphaned apps.
func TestGroupD_NothingIsBadgedThatCannotBeJudged(t *testing.T) {
tpl := map[string]string{"web": "nginx:1.27"}
inst := map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}
for name, mutate := range map[string]func(*stacks.Stack){
"not deployed": func(s *stacks.Stack) { s.Deployed = false },
"protected": func(s *stacks.Stack) { s.Protected = true },
"orphaned": func(s *stacks.Stack) { s.Orphaned = true },
} {
s := ubStack(inst, tpl, "2026-07-18")
mutate(&s)
if b := updateBadgeAt(s, badgeNow); b != nil {
t.Errorf("%s: rendered %q — there is nothing to be current WITH", name, b.Label)
}
}
}
// --- GROUP D, rendered: the PRODUCTION templates ---
func ubAppInfoData(st stacks.Stack) map[string]interface{} {
return map[string]interface{}{
"Page": "stacks", "Title": st.Meta.DisplayName,
"Stack": &st, "Meta": st.Meta, "AppInfo": st.Meta.AppInfo,
"HasAppInfo": st.Meta.HasAppInfo(), "EffectiveSubdomain": st.Meta.Subdomain,
"Domain": "demo-felhom.eu",
}
}
func ubStacksData(st stacks.Stack) map[string]interface{} {
return map[string]interface{}{
"Page": "stacks", "Title": "Alkalmazások",
"Stacks": []stacks.Stack{st},
"MissingStorage": map[string]string{},
"NetworkWarnings": map[string]string{},
"NetworkStubs": map[string]string{},
"StorageLabels": map[string]string{},
"Subdomains": map[string]string{},
}
}
// TestGroupD_BadgeRendersOnBothSurfaces renders the REAL templates, which is the only thing that
// catches a template-time failure: `.Stack` reaches app_info as a *stacks.Stack, and a funcmap entry
// taking a VALUE has to be reachable from it. A compile-clean funcmap that 500s at render is exactly
// how v0.158.0's pointer-receiver defect shipped.
//
// COMPANION RED-PROOF (run 2026-09-02): delete the {{template "meta_badge" (updateBadge …)}} line
// from stacks.html and the "app list" sub-test fails; delete it from app_info.html and the "app page"
// sub-test fails. Reverted.
func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) {
tpl := map[string]string{"web": "lscr.io/linuxserver/bookstack:26.05.2"}
behind := ubStack(map[string]stacks.InstalledImage{"web": rec("lscr.io/linuxserver/bookstack:25.02.2")}, tpl, "2026-07-18")
current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, "2026-07-18")
legacy := ubStack(nil, tpl, "2026-07-18")
for _, surface := range []struct {
name string
data func(stacks.Stack) map[string]interface{}
tmpl string
}{
{"app page", ubAppInfoData, "app_info"},
{"app list", ubStacksData, "stacks"},
} {
t.Run(surface.name, func(t *testing.T) {
h := renderBackupPage(t, surface.tmpl, surface.data(behind))
if !strings.Contains(h, "Frissítés elérhető — 46 napja") {
t.Errorf("the behind badge is missing from %s", surface.tmpl)
}
h = renderBackupPage(t, surface.tmpl, surface.data(current))
if !strings.Contains(h, "Naprakész") {
t.Errorf("the current badge is missing from %s", surface.tmpl)
}
// The negative half. Without it, an implementation that badges everything passes.
h = renderBackupPage(t, surface.tmpl, surface.data(legacy))
if strings.Contains(h, "Naprakész") || strings.Contains(h, "Frissítés elérhető") {
t.Errorf("an app with NO RECORD must be badged with NOTHING on %s", surface.tmpl)
}
})
}
}
// --- SCENARIO E: nothing about updating changed ---
// TestScenarioE_TheUpdateButtonIsUntouched. This slice is information only. The badge must be wired
// to no action, and the three lifecycle buttons must render exactly as they did before it existed.
func TestScenarioE_TheUpdateButtonIsUntouched(t *testing.T) {
tpl := map[string]string{"web": "nginx:1.27"}
states := map[string]stacks.Stack{
"current": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")}, tpl, "2026-07-18"),
"behind": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, "2026-07-18"),
"behind/na": ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, ""),
"no record": ubStack(nil, tpl, "2026-07-18"),
}
for name, st := range states {
h := renderBackupPage(t, "stacks", ubStacksData(st))
for _, want := range []string{
`stackAction(event, 'bookstack', 'update')`,
`stackAction(event, 'bookstack', 'restart')`,
`stackAction(event, 'bookstack', 'stop')`,
} {
if !strings.Contains(h, want) {
t.Errorf("%s: the %q button changed — this slice must change no behaviour", name, want)
}
}
// No new action may hang off the badge.
if strings.Contains(h, "updateBadgeAction") || strings.Contains(h, "'autoupdate'") {
t.Errorf("%s: the badge must be wired to NOTHING", name)
}
}
}
// --- GROUP F: catalog_since tolerance ---
// TestGroupF_CatalogSinceTolerance — absent, empty, malformed and FUTURE all degrade to "no age
// known" and never brick the badge. The future case is not pedantry: a box whose clock is behind the
// catalog would otherwise print "-3 napja".
func TestGroupF_CatalogSinceTolerance(t *testing.T) {
for _, since := range []string{"", " ", "tegnap", "18/07/2026", "2026-13-45", "2026-12-31"} {
m := stacks.Metadata{CatalogSince: since}
if days, ok := m.CatalogSinceAge(badgeNow); ok {
t.Errorf("catalog_since %q must be UNUSABLE, got %d napja", since, days)
}
tpl := map[string]string{"web": "nginx:1.27"}
s := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.26")}, tpl, since)
b := updateBadgeAt(s, badgeNow)
if b == nil {
t.Fatalf("catalog_since %q: the badge must still render, just without an age", since)
}
if b.Label != "Frissítés elérhető" {
t.Errorf("catalog_since %q: label = %q, want the age-less form", since, b.Label)
}
}
// And the usable cases.
for since, want := range map[string]int{"2026-09-02": 0, "2026-09-01": 1, "2026-07-18": 46} {
got, ok := stacks.Metadata{CatalogSince: since}.CatalogSinceAge(badgeNow)
if !ok || got != want {
t.Errorf("catalog_since %q → (%d, %v), want (%d, true)", since, got, ok, want)
}
}
}