v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
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
This commit is contained in:
2026-10-15 18:37:50 +02:00
parent 85f65c70d8
commit e66d4669d6
120 changed files with 16239 additions and 9866 deletions
@@ -0,0 +1,179 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// THE GUIDE, RENDERED (v0.315.0, Scenario F). The schema and the overlay are pinned in the stacks
// package (guide_test.go); these are the four rules the PAGE is responsible for:
//
// 1. a guide whose author named no version is not shown — and the page says one is still to come
// 2. the household's own address replaces DOMAIN in every guide string
// 3. English shows English where it has it and Hungarian where it does not, FIELD BY FIELD
// 4. a step containing markup is shown as the characters the author typed
//
// Rendered through the real template. A guide that parses and never renders is the seam-built-but-
// never-wired class, which this project has shipped four times.
// guideAppInfo is a two-section, two-FAQ guide with an address in it.
func guideAppInfo(checkedVersion string) stacks.AppInfo {
return stacks.AppInfo{
Tagline: "Teszt alkalmazas",
Guide: stacks.AppGuide{
CheckedVersion: checkedVersion,
Sections: []stacks.GuideSection{
{Title: "Elso bekapcsolas", Intro: "Nyisd meg a wiki.DOMAIN oldalt.",
Steps: []string{"Kattints a Fiok menure", "Add meg a DOMAIN cimet"}},
{Title: "Megosztas", Steps: []string{"Valaszd ki a mappat"}},
},
FAQ: []stacks.GuideFAQ{
{Q: "Hol vannak a fajlok?", A: "A megadott mappaban."},
{Q: "Lehet tobb felhasznalo?", A: "Igen."},
},
},
}
}
func renderGuideTab(t *testing.T, ai stacks.AppInfo) string {
t.Helper()
st := stacks.Stack{Name: "testapp", Deployed: true, State: stacks.StateRunning}
st.Meta = stacks.Metadata{DisplayName: "TestApp", Slug: "testapp", AppInfo: ai}
return renderBackupPage(t, "app_guide", map[string]interface{}{
"Page": "stacks", "Title": "TestApp", "Domain": "example.hu", "CSRFToken": "tok",
"Stack": &st, "Meta": st.Meta, "AppInfo": ai, "HasAppInfo": true,
"Deployed": true, "AppOperational": true,
})
}
// TestGuide_RenderRules — the render gate, the address, and the escaping.
//
// RED-PROOF (REPORT.md): change the template's gate from `.AppInfo.Guide.Rendered` to
// `.AppInfo.Guide.Sections` → the empty-checked_version case renders the guide and fails. Reverted.
func TestGuide_RenderRules(t *testing.T) {
t.Run("checked version renders", func(t *testing.T) {
// R-498 over the guide: the catalog says wiki.DOMAIN, the household gets its own address.
h := renderGuideTab(t, guideAppInfo("1.2.3").WithAddress("jegyzet", "example.hu"))
if !strings.Contains(h, `data-guide="1"`) {
t.Fatal("a checked guide must render")
}
if !strings.Contains(h, `data-guide-checked="1.2.3"`) {
t.Error("the checked version must be on the page as the gate's evidence")
}
if n := strings.Count(h, `data-guide-section="1"`); n != 2 {
t.Errorf("%d guide sections rendered, want 2", n)
}
if !strings.Contains(h, `data-guide-faq="1"`) {
t.Error("the FAQ block must render")
}
for _, want := range []string{"Elso bekapcsolas", "Megosztas", "Kattints a Fiok menure",
"Valaszd ki a mappat", "Hol vannak a fajlok?", "A megadott mappaban."} {
if !strings.Contains(h, want) {
t.Errorf("the guide is missing %q", want)
}
}
// The address, in the intro AND in a step.
if !strings.Contains(h, "Nyisd meg a jegyzet.example.hu oldalt.") {
t.Error("DOMAIN was not replaced in the section intro")
}
if !strings.Contains(h, "Add meg a example.hu cimet") {
t.Error("DOMAIN was not replaced in a step")
}
if strings.Contains(h, "DOMAIN") {
t.Error("the literal DOMAIN placeholder reached the page")
}
if strings.Contains(h, `data-guide-missing="1"`) {
t.Error("the „no guide yet” line must not show beside a rendered guide")
}
})
t.Run("empty checked version does not render", func(t *testing.T) {
h := renderGuideTab(t, guideAppInfo(""))
if strings.Contains(h, `data-guide="1"`) || strings.Contains(h, `data-guide-section="1"`) {
t.Error("a guide whose author named NO version must not render — nobody vouched for it")
}
if strings.Contains(h, "Elso bekapcsolas") {
t.Error("an unchecked guide's text reached the page")
}
if !strings.Contains(h, `data-guide-missing="1"`) {
t.Error("the page must say a guide is still to come")
}
})
t.Run("absent guide says so", func(t *testing.T) {
h := renderGuideTab(t, stacks.AppInfo{Tagline: "Teszt"})
if !strings.Contains(h, `data-guide-missing="1"`) {
t.Error("an app with no guide block must say so")
}
if strings.Contains(h, `data-guide="1"`) {
t.Error("an absent guide rendered a guide block")
}
})
t.Run("markup in a step is shown as text", func(t *testing.T) {
ai := guideAppInfo("1.2.3")
ai.Guide.Sections[0].Steps = []string{`<script>alert(1)</script>`}
ai.Guide.FAQ[0].A = `<img src=x onerror=alert(1)>`
h := renderGuideTab(t, ai)
if strings.Contains(h, "<script>alert(1)</script>") {
t.Error("a catalog step was rendered as HTML — catalog copy is DATA, never markup")
}
if strings.Contains(h, "<img src=x onerror=alert(1)>") {
t.Error("a FAQ answer was rendered as HTML")
}
if !strings.Contains(h, "&lt;script&gt;") {
t.Error("the step's characters must appear, escaped, as the author typed them")
}
})
}
// TestGuide_EnglishFallsBackFieldByField — the half-translated state the catalog is allowed to ship,
// through the real localisation path and the real template: English FAQ, Hungarian sections.
func TestGuide_EnglishFallsBackFieldByField(t *testing.T) {
en := "en"
meta := stacks.Metadata{DisplayName: "TestApp", Slug: "testapp", AppInfo: guideAppInfo("1.2.3")}
meta.I18n = map[string]stacks.MetadataOverlay{
en: {AppInfo: &stacks.AppInfoOverlay{Guide: &stacks.AppGuideOverlay{
// Section 1: the title translated, the intro left BLANK (= absent, operator ruling 3).
Sections: []stacks.GuideSectionOverlay{{Title: sptr("First start"), Intro: sptr("")}},
FAQ: []stacks.GuideFAQOverlay{
{Q: sptr("Where are the files?"), A: sptr("In the folder you chose.")},
{Q: sptr("Can several people use it?"), A: sptr("Yes.")},
},
}}},
}
h := renderGuideTab(t, meta.For(en).AppInfo.WithAddress("jegyzet", "example.hu"))
if !strings.Contains(h, "First start") {
t.Error("the translated section title did not reach the English page")
}
if !strings.Contains(h, "Where are the files?") || !strings.Contains(h, "In the folder you chose.") {
t.Error("the English FAQ did not reach the English page")
}
// A BLANK English intro is absent, so the Hungarian one stays — with the address filled.
if !strings.Contains(h, "Nyisd meg a jegyzet.example.hu oldalt.") {
t.Error("a blank English intro must fall back to the Hungarian one, not render empty")
}
// Section 2 had no English entry at all: wholly Hungarian.
if !strings.Contains(h, "Megosztas") || !strings.Contains(h, "Valaszd ki a mappat") {
t.Error("an untranslated section must render in Hungarian")
}
// And the gate is not copy: it survived the overlay.
if !strings.Contains(h, `data-guide-checked="1.2.3"`) {
t.Error("the checked version did not survive the English overlay")
}
// The Hungarian page is unchanged by the presence of the block.
hu := renderGuideTab(t, meta.For("hu").AppInfo.WithAddress("jegyzet", "example.hu"))
if strings.Contains(hu, "First start") || strings.Contains(hu, "Where are the files?") {
t.Error("English copy reached the Hungarian page")
}
if !strings.Contains(hu, "Elso bekapcsolas") {
t.Error("the Hungarian section title is gone")
}
}