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
122 lines
5.4 KiB
Go
122 lines
5.4 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestNoDirectMetaCopyReadOnPages — the guard for R-560's whole point.
|
|
//
|
|
// Catalog copy (`Description`, `AppInfo`, the deploy-field labels, `OptionalConfig`, `Integrations`,
|
|
// the `DataPaths` labels, the initial-credentials note) is HUNGARIAN in the value the stack manager
|
|
// caches. A page reaches the household's language only by going through `Metadata.For(lang)` —
|
|
// `stacks.LocalizeStacks`, `LocalizeStack`, `LocalizeStackPtr` or `MetaFor`. Read `x.Meta.<copy>`
|
|
// straight off a manager value and an English household silently gets Hungarian: no error, no log,
|
|
// nothing that looks wrong on the page. That is precisely the failure mode this project has shipped
|
|
// nine times under a comment that read as settled.
|
|
//
|
|
// So every direct read of a copy field off a `.Meta` in this package is LISTED BELOW WITH A REASON.
|
|
// A new one fails this test, and the author then has two honest choices: route it through For(lang),
|
|
// or add it here saying why it may stay Hungarian.
|
|
//
|
|
// WHAT THIS TEST CANNOT DO: it reads the source, so it cannot tell a localised receiver from an
|
|
// unlocalised one — `found.Meta.AppInfo` looks the same either way. It catches the ARRIVAL of a new
|
|
// copy read, which is when a human has to think, and that is the whole claim made for it.
|
|
//
|
|
// RED-PROOF (2026-09-20): adding `_ = stack.Meta.Description` to handlers.go failed this test
|
|
// naming handlers.go and Description; removing it went green.
|
|
var metaCopyReadAllowlist = map[string]string{
|
|
// The app page localises `found` in one place at the top of appCommonData and every read
|
|
// below it — these included — is of that localised value.
|
|
"handlers.go:AppInfo": "appCommonData reads it off the LocalizeStackPtr'd `found`",
|
|
"handlers.go:InitialCreds": "appCommonData: the nil check and the note, both off localised `found`",
|
|
// buildDataPathCards is called with the same localised `found`; the labels it renders are the
|
|
// English ones when the household is on English.
|
|
"datapath_card.go:DataPaths": "buildDataPathCards is handed the localised stack by appCommonData",
|
|
// R-255 (v0.308.0): appAutoFieldRevealHandler reads only EnvVar and Type of each optional field, to
|
|
// AUTHORISE a reveal; no label or help text reaches a page from here.
|
|
"handlers.go:OptionalConfig": "appAutoFieldRevealHandler: EnvVar/Type authorisation only, no copy rendered",
|
|
// v0.315.0, TWO reads in after_start.go, and they are different in kind:
|
|
//
|
|
// afterStartFields — DOES render copy (the field's label, description and placeholder go
|
|
// into the card). It is handed the localised `found` by
|
|
// renderAppSettingsTab, which is the same stack appCommonData localised
|
|
// at the top, so the card is in the household's language.
|
|
// appAfterStartHandler — reads AfterStart and EnvVar only, to decide whether the catalog
|
|
// DECLARED this field before the registry is allowed to run anything.
|
|
// Configuration, not copy; nothing from it reaches a page.
|
|
"after_start.go:DeployFields": "afterStartFields renders copy off the localised stack; appAfterStartHandler reads AfterStart/EnvVar as authorisation only",
|
|
}
|
|
|
|
// metaCopyFields are the Metadata fields that carry customer-facing TEXT. Everything else on
|
|
// Metadata — Slug, Subdomain, OpenPath, BrandColor, Category, Resources, Lifecycle, CatalogSince,
|
|
// HealthCheck, SMTPMapping, Backup — is configuration and reads the same in every language.
|
|
// DisplayName is deliberately NOT here: an app's name is not translated (10-localisation.md §11,
|
|
// operator ruling 7).
|
|
var metaCopyFields = map[string]bool{
|
|
"Description": true,
|
|
"AppInfo": true,
|
|
"DeployFields": true,
|
|
"OptionalConfig": true,
|
|
"Integrations": true,
|
|
"DataPaths": true,
|
|
"InitialCreds": true,
|
|
}
|
|
|
|
func TestNoDirectMetaCopyReadOnPages(t *testing.T) {
|
|
entries, err := os.ReadDir(".")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := map[string]bool{}
|
|
var unlisted []string
|
|
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, name, nil, 0) // comments are not walked — only real reads count
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", name, err)
|
|
}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
outer, ok := n.(*ast.SelectorExpr)
|
|
if !ok || !metaCopyFields[outer.Sel.Name] {
|
|
return true
|
|
}
|
|
inner, ok := outer.X.(*ast.SelectorExpr)
|
|
if !ok || inner.Sel.Name != "Meta" {
|
|
return true
|
|
}
|
|
key := name + ":" + outer.Sel.Name
|
|
seen[key] = true
|
|
if _, allowed := metaCopyReadAllowlist[key]; !allowed {
|
|
unlisted = append(unlisted, fmt.Sprintf("%s (%s)", key, fset.Position(outer.Pos())))
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
sort.Strings(unlisted)
|
|
for _, u := range unlisted {
|
|
t.Errorf("catalog COPY read straight off .Meta: %s\n"+
|
|
" Route it through stacks.LocalizeStack(s)/LocalizeStackPtr/MetaFor(lang), or add it to\n"+
|
|
" metaCopyReadAllowlist in this file with the reason it may stay Hungarian.", u)
|
|
}
|
|
|
|
// A stale allow-list entry is a lie about what the code does — it must go when its read goes.
|
|
for key := range metaCopyReadAllowlist {
|
|
if !seen[key] {
|
|
t.Errorf("allow-list entry %q no longer matches any read — delete it", key)
|
|
}
|
|
}
|
|
}
|