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
200 lines
8.0 KiB
Go
200 lines
8.0 KiB
Go
package web
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
func lcStack(name, lifecycle string, deployed bool) stacks.Stack {
|
|
return stacks.Stack{
|
|
Name: name,
|
|
Deployed: deployed,
|
|
State: stacks.StateNotDeployed,
|
|
Meta: stacks.Metadata{DisplayName: name, Slug: name, Lifecycle: lifecycle},
|
|
}
|
|
}
|
|
|
|
// TestVisibleCatalogStacks pins the catalog-listing rule on both axes at once: withdrawn templates
|
|
// disappear from the OFFER list, and a deployed instance of the very same template stays.
|
|
//
|
|
// COMPANION RED-PROOF: drop the `st.Deployed ||` clause and the two "deployed" rows fail — a
|
|
// customer's working app vanishes from their own Alkalmazások page, which is the failure that makes
|
|
// withdrawing an app dangerous in the first place.
|
|
func TestVisibleCatalogStacks(t *testing.T) {
|
|
in := []stacks.Stack{
|
|
lcStack("bookstack", "", false), // available, not deployed → OFFERED
|
|
lcStack("immich", "", true), // available, deployed → shown
|
|
lcStack("plant-it", "abandoned", false), // withdrawn, not deployed → HIDDEN
|
|
lcStack("plant-it-run", "abandoned", true), // withdrawn, DEPLOYED → shown
|
|
lcStack("oldapp", "hidden", false), // withdrawn, not deployed → HIDDEN
|
|
lcStack("oldapp-run", "hidden", true), // withdrawn, DEPLOYED → shown
|
|
lcStack("typoapp", "bogus", false), // unknown → available → OFFERED (fail-open)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, st := range visibleCatalogStacks(in) {
|
|
got[st.Name] = true
|
|
}
|
|
want := []string{"bookstack", "immich", "plant-it-run", "oldapp-run", "typoapp"}
|
|
notWant := []string{"plant-it", "oldapp"}
|
|
|
|
for _, n := range want {
|
|
if !got[n] {
|
|
t.Errorf("%q must be listed", n)
|
|
}
|
|
}
|
|
for _, n := range notWant {
|
|
if got[n] {
|
|
t.Errorf("%q is withdrawn and not deployed — it must NOT be offered", n)
|
|
}
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Errorf("listed %d stacks, want %d (%v)", len(got), len(want), got)
|
|
}
|
|
}
|
|
|
|
// TestVisibleCatalogStacks_ProtectedAlwaysSurvives: infra stacks carry no catalog metadata and must
|
|
// never be filtered out by a rule about catalog apps.
|
|
func TestVisibleCatalogStacks_ProtectedAlwaysSurvives(t *testing.T) {
|
|
st := lcStack("traefik", "hidden", false)
|
|
st.Protected = true
|
|
if len(visibleCatalogStacks([]stacks.Stack{st})) != 1 {
|
|
t.Fatal("a protected infra stack must never be hidden by the lifecycle filter")
|
|
}
|
|
}
|
|
|
|
func TestLifecycleBadge(t *testing.T) {
|
|
cases := []struct {
|
|
lifecycle string
|
|
wantBadge bool
|
|
}{
|
|
{"", false},
|
|
{"available", false},
|
|
// hidden renders NOTHING on a deployed app: "we stopped offering this" is not a fact the
|
|
// customer running it needs, and a badge implying something is wrong would be misleading.
|
|
{"hidden", false},
|
|
{"abandoned", true},
|
|
{"bogus", false},
|
|
}
|
|
for _, c := range cases {
|
|
b := lifecycleBadge(stacks.Metadata{Lifecycle: c.lifecycle})
|
|
if (b != nil) != c.wantBadge {
|
|
t.Errorf("lifecycle %q: badge=%v, want present=%v", c.lifecycle, b, c.wantBadge)
|
|
}
|
|
if b != nil {
|
|
if b.Label != "Nem karbantartott" {
|
|
t.Errorf("label = %q", b.Label)
|
|
}
|
|
if b.Title == "" {
|
|
t.Error("a badge that is only a word is a riddle — it must carry an explanation")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAbandonedBadgeRendersOnCatalogCard renders the PRODUCTION stacks template.
|
|
//
|
|
// COMPANION RED-PROOF: remove the {{template "meta_badge" ...}} line from stacks.html and this
|
|
// fails — the badge is the only thing telling a customer the software is no longer maintained.
|
|
func TestAbandonedBadgeRendersOnCatalogCard(t *testing.T) {
|
|
deployed := lcStack("plant-it", "abandoned", true)
|
|
deployed.State = stacks.StateRunning
|
|
html := renderBackupPage(t, "stacks", map[string]interface{}{
|
|
"Page": "stacks", "Title": "Alkalmazások",
|
|
"Stacks": []stacks.Stack{deployed},
|
|
"MissingStorage": map[string]string{},
|
|
"NetworkWarnings": map[string]string{},
|
|
"NetworkStubs": map[string]string{},
|
|
"StorageLabels": map[string]string{},
|
|
"Subdomains": map[string]string{},
|
|
})
|
|
if !strings.Contains(html, "Nem karbantartott") {
|
|
t.Fatal("a deployed abandoned app must carry the Nem karbantartott badge on its card")
|
|
}
|
|
if strings.Contains(html, "Telepítés</a>") {
|
|
t.Error("an abandoned app must never offer a Telepítés button")
|
|
}
|
|
}
|
|
|
|
// TestAvailableAppHasNoBadgeAndKeepsInstallButton is the negative half — without it, an impl that
|
|
// badges everything would pass the test above.
|
|
func TestAvailableAppHasNoBadgeAndKeepsInstallButton(t *testing.T) {
|
|
html := renderBackupPage(t, "stacks", map[string]interface{}{
|
|
"Page": "stacks", "Title": "Alkalmazások",
|
|
"Stacks": []stacks.Stack{lcStack("bookstack", "", false)},
|
|
"MissingStorage": map[string]string{},
|
|
"NetworkWarnings": map[string]string{},
|
|
"NetworkStubs": map[string]string{},
|
|
"StorageLabels": map[string]string{},
|
|
"Subdomains": map[string]string{},
|
|
})
|
|
if strings.Contains(html, "Nem karbantartott") {
|
|
t.Error("an available app must not be badged")
|
|
}
|
|
if !strings.Contains(html, "Telepítés</a>") {
|
|
t.Fatal("an available, undeployed app must still offer Telepítés")
|
|
}
|
|
}
|
|
|
|
// appInfoData mirrors appCommonData's data map — CRUCIALLY including `"Meta": st.Meta` as a
|
|
// stacks.Metadata VALUE inside a map[string]interface{}, which is what the handler really does.
|
|
// That detail is the entire point of this test: html/template cannot call a POINTER-receiver method
|
|
// on a non-addressable value, so `{{if .Meta.IsAbandoned}}` renders fine in a unit test that passes
|
|
// a *Metadata and 500s in production. Do not "simplify" this to a pointer.
|
|
func appInfoData(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 shell asks whether the app is installed (the tab bar, the Megnyitas link)
|
|
// and the guide tab asks it again for the Telepites button.
|
|
"Deployed": st.Deployed, "AppOperational": st.Deployed,
|
|
}
|
|
}
|
|
|
|
// TestAppInfoRendersForEveryLifecycle is the regression test for a defect that SHIPPED: v0.158.0's
|
|
// first cut used pointer receivers, so `{{if .Meta.IsAbandoned}}` failed at RENDER time with
|
|
// "can't evaluate field IsAbandoned in type interface {}" — a 500 on EVERY app detail page,
|
|
// abandoned or not. It compiled, and every other test passed, because nothing rendered app_info.
|
|
//
|
|
// A template method call is only checked when the template runs. Render the page.
|
|
func TestAppInfoRendersForEveryLifecycle(t *testing.T) {
|
|
for _, lc := range []string{"", "available", "hidden", "abandoned", "bogus"} {
|
|
t.Run("lifecycle="+lc, func(t *testing.T) {
|
|
st := lcStack("someapp", lc, true)
|
|
st.State = stacks.StateRunning
|
|
html := renderBackupPage(t, "app_guide", appInfoData(st))
|
|
if len(html) < 500 {
|
|
t.Fatalf("app_info rendered only %d bytes — the page is broken", len(html))
|
|
}
|
|
wantNotice := lc == "abandoned"
|
|
gotNotice := strings.Contains(html, "felhagyott a fejlesztéssel")
|
|
if gotNotice != wantNotice {
|
|
t.Errorf("abandoned notice present=%v, want %v (lifecycle %q)", gotNotice, wantNotice, lc)
|
|
}
|
|
gotBadge := strings.Contains(html, "Nem karbantartott")
|
|
if gotBadge != wantNotice {
|
|
t.Errorf("badge present=%v, want %v (lifecycle %q)", gotBadge, wantNotice, lc)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAppInfoHidesInstallButtonForWithdrawnApp — the undeployed view: a withdrawn app must not offer
|
|
// Telepítés, and an available one must.
|
|
func TestAppInfoHidesInstallButtonForWithdrawnApp(t *testing.T) {
|
|
for _, c := range []struct {
|
|
lifecycle string
|
|
wantBtn bool
|
|
}{{"", true}, {"available", true}, {"hidden", false}, {"abandoned", false}} {
|
|
st := lcStack("someapp", c.lifecycle, false)
|
|
html := renderBackupPage(t, "app_guide", appInfoData(st))
|
|
got := strings.Contains(html, "Telepítés</a>")
|
|
if got != c.wantBtn {
|
|
t.Errorf("lifecycle %q: Telepítés button present=%v, want %v", c.lifecycle, got, c.wantBtn)
|
|
}
|
|
}
|
|
}
|