e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
355 lines
15 KiB
Go
355 lines
15 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 CATALOG images are stated explicitly.
|
|
//
|
|
// Since v0.235.0 the badge compares against `CatalogImages` — what the catalog OFFERS — and never
|
|
// against `TemplateImages`, which after the freeze is the app's own (possibly frozen) live file.
|
|
// Both are set to the same map here because that is the un-frozen case; `ubFrozenStack` is the one
|
|
// where they deliberately differ.
|
|
func ubStack(installed map[string]stacks.InstalledImage, catalog 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: catalog,
|
|
CatalogImages: catalog,
|
|
}
|
|
}
|
|
|
|
// ubFrozenStack models a v0.235.0 FROZEN app: it runs an old version, its LIVE compose file has been
|
|
// rendered from its own stored definition and therefore also names the old version, and the CATALOG
|
|
// has moved on. This is the shape that silently inverts the badge if the comparison reads the wrong
|
|
// field — see TestGroupG.
|
|
func ubFrozenStack(running, catalogRef, since string) stacks.Stack {
|
|
st := ubStack(map[string]stacks.InstalledImage{"web": rec(running)}, map[string]string{"web": catalogRef}, since)
|
|
st.AppConfig.PinnedImages = map[string]string{"web": running}
|
|
st.TemplateImages = map[string]string{"web": running} // the frozen live file
|
|
return st
|
|
}
|
|
|
|
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",
|
|
},
|
|
{
|
|
// R-524, the BIGNIGHT case: the box updated and the catalog was reverted under it.
|
|
// It must NOT read „Frissítés elérhető" — the update behind that word is a downgrade.
|
|
name: "AHEAD of the catalog — up to date, not a warning",
|
|
stack: ubStack(map[string]stacks.InstalledImage{"web": rec("privatebin/pdo:2.0.6")},
|
|
map[string]string{"web": "privatebin/pdo:2.0.5"}, "2026-09-02"),
|
|
wantBadge: true, wantLabel: "Naprakész", wantClass: "tag-ok",
|
|
},
|
|
{
|
|
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",
|
|
// v0.315.0: the badges are in the app page's shared header, which renders them for an
|
|
// installed app — the same condition the details page's header always had.
|
|
"Deployed": st.Deployed, "AppOperational": st.Deployed,
|
|
}
|
|
}
|
|
|
|
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_settings"},
|
|
{"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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- GROUP G (v0.235.0): the badge must read the CATALOG, not the rendered file ---
|
|
|
|
// TestGroupG_FrozenAppStillReadsBehind is the test that stops slice 3 from silently inverting slice 2.
|
|
//
|
|
// After the freeze, a pinned app whose version the catalog has moved past has its LIVE
|
|
// docker-compose.yml rendered from its own stored definition — so that file names the OLD version.
|
|
// A comparison against it finds installed == template and answers „Naprakész" on precisely the apps
|
|
// that are behind. The two fields have the same type and shape, so nothing but this test catches it.
|
|
//
|
|
// COMPANION RED-PROOF 3 (run 2026-09-06): point compareInstalledToTemplate back at
|
|
// s.TemplateImages. This test then fails with „Naprakész" on a frozen app. Reverted.
|
|
func TestGroupG_FrozenAppStillReadsBehind(t *testing.T) {
|
|
since := time.Now().UTC().AddDate(0, 0, -46).Format("2006-01-02")
|
|
frozen := ubFrozenStack("nextcloud:31.0.14-apache", "nextcloud:34.0.1-apache", since)
|
|
|
|
b := updateBadgeAt(frozen, time.Now().UTC())
|
|
if b == nil {
|
|
t.Fatal("a frozen, behind app must carry a badge")
|
|
}
|
|
if b.Label == "Naprakész" {
|
|
t.Fatal("THE FEATURE IS INVERTED: the comparison read the frozen live file instead of the catalog")
|
|
}
|
|
if !strings.HasPrefix(b.Label, "Frissítés elérhető") || b.Class != "tag-warn" {
|
|
t.Fatalf("label = %q class = %q", b.Label, b.Class)
|
|
}
|
|
|
|
// And it renders that way on the real page, not just in the pure function.
|
|
html := renderBackupPage(t, "stacks", ubStacksData(frozen))
|
|
if !strings.Contains(html, "Frissítés elérhető") {
|
|
t.Error("the frozen app must be badged as behind on the app list")
|
|
}
|
|
if strings.Contains(html, "Naprakész") {
|
|
t.Error("a frozen, behind app must never render Naprakész")
|
|
}
|
|
}
|
|
|
|
// TestGroupG_NoCatalogEntryRendersNothing — an orphaned app, or a box whose catalog cache is missing,
|
|
// cannot be judged. Absent is unknown; it is never „Naprakész".
|
|
func TestGroupG_NoCatalogEntryRendersNothing(t *testing.T) {
|
|
st := ubStack(map[string]stacks.InstalledImage{"web": rec("nginx:1.27")},
|
|
map[string]string{"web": "nginx:1.27"}, "2026-07-18")
|
|
st.CatalogImages = nil // the catalog cache could not be read
|
|
if b := updateBadgeAt(st, badgeNow); b != nil {
|
|
t.Fatalf("no readable catalog template must render NOTHING, got %q", b.Label)
|
|
}
|
|
}
|