38d28b5b62
gates / gates (push) Successful in 13s
The operator looked at demo-felhom the morning after v0.233.0 and found OpenGist - up 15 hours, running exactly the catalog pin - showing no badge at all. v0.233.0 wrote the record only from the four bring-up paths, so an app nobody restarts carried no record indefinitely. On a quiet box that is every app, which is the box we most want to see. The known limitation WAS the feature not working. BackfillInstalledImages runs once at startup, beside BackfillDesiredState and before the boot reconciler. It READS containers: starts nothing, restarts nothing, writes no compose file. It never overwrites an existing record. And it REFUSES to seed a partial observation, which is why this is not a three-line loop: the badge reads a service-count mismatch as BEHIND, so seeding a degraded app from what is visible would render 'Frissites elerheto' over an app that is perfectly current. The bring-up paths may write a partial because they follow a successful up -d where a gap is real news; a backfill meets any state. Same data, two writers, two admission rules - deliberately. Also fixes a calendar bomb of mine: the render test hardcoded catalog_since and the string '46 napja', but the render path reads time.Now(), so it was green on the day it was written and red the next morning. Now derived. Filed as R-457 with six other candidate files named as unchecked, not accused. +5 tests (1724 -> 1729), 28 packages green. Red-proof of the partial guard run and reverted; the wiring and its ORDER pinned by an AST walk.
280 lines
11 KiB
Go
280 lines
11 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"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.
|
|
//
|
|
// THE CLOCK, and why `catalog_since` is computed rather than written down: the templates call the
|
|
// funcmap entry `updateBadge`, which uses time.Now() — the injected `badgeNow` reaches only the pure
|
|
// tests. A hardcoded date plus a hardcoded age is therefore a test that passes on the day it is
|
|
// written and FAILS THE NEXT MORNING, which is exactly what this one did (written 2026-09-02
|
|
// asserting "46 napja", red on 2026-09-03). Derive the date from the same clock the code will read.
|
|
func TestGroupD_BadgeRendersOnBothSurfaces(t *testing.T) {
|
|
const behindDays = 46
|
|
since := time.Now().UTC().AddDate(0, 0, -behindDays).Format("2006-01-02")
|
|
wantBehind := fmt.Sprintf("Frissítés elérhető — %d napja", behindDays)
|
|
|
|
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, since)
|
|
current := ubStack(map[string]stacks.InstalledImage{"web": rec(tpl["web"])}, tpl, since)
|
|
legacy := ubStack(nil, tpl, since)
|
|
|
|
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, wantBehind) {
|
|
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)
|
|
}
|
|
}
|
|
}
|