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
225 lines
8.9 KiB
Go
225 lines
8.9 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
// --- stub detection (the controller-namespace leg of the badge, RCA fix 2) --------------------------
|
|
|
|
func TestStubNetworkPaths_Classification(t *testing.T) {
|
|
s := testServer(t)
|
|
classes := map[string]string{
|
|
"/mnt/felhom-drives/stubbed": system.FSClassStub,
|
|
"/mnt/felhom-drives/idle": system.FSClassAutofs, // healthy — never a stub
|
|
"/mnt/felhom-drives/live": system.FSClassNetwork, // healthy
|
|
"/mnt/felhom-drives/wedged": system.FSClassUnknown, // fail open — never a stub
|
|
}
|
|
s.classifyFSPath = func(p string) string { return classes[p] }
|
|
netPaths := map[string]settings.StoragePath{}
|
|
for p := range classes {
|
|
netPaths[p] = settings.StoragePath{Path: p, Label: "L:" + p, Kind: settings.StorageKindNetwork}
|
|
}
|
|
|
|
got := s.stubNetworkPaths(netPaths)
|
|
if len(got) != 1 {
|
|
t.Fatalf("stub set = %v, want exactly the stubbed path", got)
|
|
}
|
|
if lbl := got["/mnt/felhom-drives/stubbed"]; lbl != "L:/mnt/felhom-drives/stubbed" {
|
|
t.Fatalf("stub label = %q", lbl)
|
|
}
|
|
}
|
|
|
|
// --- per-stack mapping: stub wins, unreachable byte-behavior preserved -------------------------------
|
|
|
|
func TestNetworkStorageWarningsIn_StubWins(t *testing.T) {
|
|
list := []stacks.Stack{
|
|
{Name: "cwa", Deployed: true},
|
|
{Name: "jellyfin", Deployed: true},
|
|
{Name: "undeployed", Deployed: false},
|
|
}
|
|
env := map[string]map[string]string{
|
|
"cwa": {"HDD_PATH": "/mnt/felhom-drives/media"},
|
|
"jellyfin": {"HDD_PATH": "/mnt/felhom-drives/other"},
|
|
"undeployed": {"HDD_PATH": "/mnt/felhom-drives/media"},
|
|
}
|
|
load := func(name string) *stacks.AppConfig {
|
|
if e, ok := env[name]; ok {
|
|
return &stacks.AppConfig{Env: e}
|
|
}
|
|
return nil
|
|
}
|
|
unreachable := map[string]string{
|
|
"/mnt/felhom-drives/media": "Média", // ALSO unreachable — stub must win
|
|
"/mnt/felhom-drives/other": "Másik",
|
|
}
|
|
stubPaths := map[string]string{"/mnt/felhom-drives/media": "Média"}
|
|
|
|
warnings, stubs := networkStorageWarningsIn(list, load, unreachable, stubPaths)
|
|
if len(stubs) != 1 || stubs["cwa"] != "Média" {
|
|
t.Fatalf("stubs = %v, want cwa only (deployed, on the stub path)", stubs)
|
|
}
|
|
if _, both := warnings["cwa"]; both {
|
|
t.Fatalf("cwa must not ALSO carry the unreachable badge (stub wins): %v", warnings)
|
|
}
|
|
if len(warnings) != 1 || warnings["jellyfin"] != "Másik" {
|
|
t.Fatalf("warnings = %v, want jellyfin only (unreachable-alone → unchanged behavior)", warnings)
|
|
}
|
|
}
|
|
|
|
func TestNetworkStorageWarningsIn_UnreachableAloneUnchanged(t *testing.T) {
|
|
list := []stacks.Stack{{Name: "jellyfin", Deployed: true}}
|
|
load := func(string) *stacks.AppConfig {
|
|
return &stacks.AppConfig{Env: map[string]string{"HDD_PATH": "/mnt/felhom-drives/media"}}
|
|
}
|
|
warnings, stubs := networkStorageWarningsIn(list, load,
|
|
map[string]string{"/mnt/felhom-drives/media": "Média"}, map[string]string{})
|
|
if len(warnings) != 1 || warnings["jellyfin"] != "Média" || len(stubs) != 0 {
|
|
t.Fatalf("unreachable-alone must keep the old shape: warnings=%v stubs=%v", warnings, stubs)
|
|
}
|
|
}
|
|
|
|
// The two badge sentences are distinct strings and both present in the templates (the stub badge
|
|
// must never reuse the recoverable-unreachable copy; the unreachable line stays byte-identical).
|
|
func TestNetworkBadgeTemplates_DistinctStrings(t *testing.T) {
|
|
for _, tpl := range []string{"templates/dashboard.html", "templates/stacks.html"} {
|
|
body, err := huTemplateSource(tpl)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := string(body)
|
|
if !strings.Contains(s, "Hálózati tárhely nem elérhető: {{$nw}}") {
|
|
t.Errorf("%s: the unreachable badge line changed (must stay byte-identical)", tpl)
|
|
}
|
|
if !strings.Contains(s, "Hálózati tárhely hibás — az alkalmazás nem a NAS-t látja") {
|
|
t.Errorf("%s: the stub badge string missing", tpl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- rider (RCA fix 4): the deployed-app storage select shows the STORED HDD_PATH -------------------
|
|
|
|
// testDeployPageServer: a real Manager over a temp stacks dir holding one deployed app with a
|
|
// path-type deploy field, plus two registered storage paths (the default ≠ the app's stored path —
|
|
// the exact pre-fix trap).
|
|
func testDeployPageServer(t *testing.T, storedHDD string) *Server {
|
|
t.Helper()
|
|
lg := log.New(io.Discard, "", 0)
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{}
|
|
cfg.Customer.ID = "test-customer"
|
|
cfg.Customer.Domain = "example.hu"
|
|
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
|
cfg.Paths.DataDir = filepath.Join(dir, "data")
|
|
cfg.Stacks.ComposeCommand = "docker compose"
|
|
|
|
stackDir := filepath.Join(cfg.Paths.StacksDir, "testapp")
|
|
if err := os.MkdirAll(stackDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
meta := `display_name: Testapp
|
|
deploy_fields:
|
|
- env_var: HDD_PATH
|
|
label: "Tárhely útvonal"
|
|
type: path
|
|
required: true
|
|
`
|
|
if err := os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appYAML := "deployed: true\nenv:\n HDD_PATH: " + storedHDD + "\nlocked_fields:\n - HDD_PATH\n"
|
|
if err := os.WriteFile(filepath.Join(stackDir, "app.yaml"), []byte(appYAML), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The default drive is NOT the app's stored path — pre-fix the select showed this one.
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/felhom-usb", Label: "Tárhely (felhom-usb)", IsDefault: true, Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/nas-media", Label: "Hálózati tárhely: nas-media", Schedulable: true, Kind: settings.StorageKindNetwork}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
mgr, err := stacks.NewManager(cfg, lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// ScanStacks registers the stack dirs first and only then refreshes container status via
|
|
// `docker ps` — tolerate that last step failing on docker-less test hosts, but require the
|
|
// stack itself to have been discovered.
|
|
_ = mgr.ScanStacks()
|
|
if _, ok := mgr.GetStack("testapp"); !ok {
|
|
t.Fatal("testapp not discovered by ScanStacks")
|
|
}
|
|
s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
|
|
s.classifyFSPath = func(string) string { return system.FSClassUnknown }
|
|
s.loadTemplates()
|
|
return s
|
|
}
|
|
|
|
// optionTag returns the full <option ...>...</option> block whose value attribute equals path.
|
|
func optionTag(t *testing.T, body, path string) string {
|
|
t.Helper()
|
|
marker := `value="` + path + `"`
|
|
i := strings.Index(body, marker)
|
|
if i < 0 {
|
|
t.Fatalf("no option with value %q in rendered page", path)
|
|
}
|
|
start := strings.LastIndex(body[:i], "<option")
|
|
end := strings.Index(body[i:], ">")
|
|
return body[start : i+end]
|
|
}
|
|
|
|
// Deployed on the NAS → the NAS option carries `selected`; the default drive does NOT.
|
|
// Companion red-proof: revert deploy.html to the IsDefault-only selection → the default option is
|
|
// selected instead → both assertions FAIL (the exact S-C lie from the RCA).
|
|
func TestDeployPage_DeployedSelectShowsStoredHDDPath(t *testing.T) {
|
|
s := testDeployPageServer(t, "/mnt/felhom-drives/nas-media")
|
|
// v0.315.0: an installed app's fields are the app page's settings TAB. The old /stacks/…/deploy
|
|
// URL answers 302 here, which TestAppPage_OldURLsRedirectGETOnly pins separately.
|
|
rec := getPage(t, s, "/apps/testapp/settings")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if nas := optionTag(t, body, "/mnt/felhom-drives/nas-media"); !strings.Contains(nas, "selected") {
|
|
t.Errorf("the STORED path's option must be selected, got: %s", nas)
|
|
}
|
|
if usb := optionTag(t, body, "/mnt/felhom-drives/felhom-usb"); strings.Contains(usb, "selected") {
|
|
t.Errorf("the default drive must NOT be selected for a deployed app, got: %s", usb)
|
|
}
|
|
}
|
|
|
|
// Stored path absent from the schedulable list → an extra disabled option names it verbatim (the
|
|
// view must never silently show a different storage than app.yaml).
|
|
func TestDeployPage_MissingStoredPathRendersTruthOption(t *testing.T) {
|
|
s := testDeployPageServer(t, "/mnt/felhom-drives/gone")
|
|
rec := getPage(t, s, "/apps/testapp/settings")
|
|
if rec.Code != 200 {
|
|
t.Fatalf("GET settings tab = %d: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "/mnt/felhom-drives/gone (nem elérhető)") {
|
|
t.Errorf("missing stored path must render as a disabled truth option")
|
|
}
|
|
if gone := optionTag(t, body, "/mnt/felhom-drives/gone"); !strings.Contains(gone, "selected") || !strings.Contains(gone, "disabled") {
|
|
t.Errorf("the truth option must be selected+disabled, got: %s", gone)
|
|
}
|
|
}
|