package web import ( "encoding/json" "io" "log" "net/http" "net/http/httptest" "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" ) // THE APP PAGE (v0.315.0) — routing, the default tab, the old URLs, and the after-start registry. // // Every test here goes through s.ServeHTTP, the production route switch, because the thing under // test IS the routing: which tab a URL renders, which method gets a redirect and which gets its // handler. A test that called the tab renderers directly would assert nothing about any of that. // // Assertions are on the ASCII `data-*` markers the templates carry for the purpose, never on // accented Hungarian: an accented pattern through a test host's encoding returns a false negative // that reads exactly like the markup being gone (.claude/rules/ui-hungarian.md). // appPageHarness builds a server with two apps: `adventurelog` installed and running, `grocy` // present in the catalog and not installed. Both carry a slug that differs from nothing — the // name/slug resolution is exercised by TestAppPage_OldURLsRedirectGETOnly's own app. func appPageHarness(t *testing.T) *Server { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } // ScanStacks ends by asking `docker ps -a` for container status, and the app's STATE is what the // Mukodes card branches on. The stub answers that one call the way a running AdventureLog would — // the five tab-separated fields refreshStatusLocked parses — so the page under test is the page a // household with a running app sees, and the status path is exercised rather than stubbed away. // Every other docker call exits 0. dockerStub := "#!/bin/sh\n" + "if [ \"$1\" = ps ]; then\n" + " printf 'adventurelog\\tbusybox\\trunning\\tUp 2 hours\\tadventurelog\\n'\n" + "fi\n" + "exit 0\n" if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(dockerStub), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) lg := log.New(io.Discard, "", 0) cfg := config.Default() cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") write := func(app, name, body string) { d := filepath.Join(cfg.Paths.StacksDir, app) if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(d, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } // Installed, running, with a subdomain so the shell's „Megnyitas" has something to point at. write("adventurelog", "docker-compose.yml", "services:\n adventurelog:\n image: busybox\n") write("adventurelog", ".felhom.yml", "display_name: AdventureLog\nslug: adventurelog\nsubdomain: utak\n"+ "app_info:\n tagline: Utinaplo\n") write("adventurelog", "app.yaml", "deployed: true\nenv:\n SUBDOMAIN: utak\n") // Not installed. write("grocy", "docker-compose.yml", "services:\n grocy:\n image: busybox\n") write("grocy", ".felhom.yml", "display_name: Grocy\nslug: grocy\napp_info:\n tagline: Keszlet\n") sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } _ = mgr.ScanStacks() for _, n := range []string{"adventurelog", "grocy"} { if _, ok := mgr.GetStack(n); !ok { t.Fatalf("%s was not discovered by ScanStacks", n) } } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() return s } func appGet(t *testing.T, s *Server, path string) *httptest.ResponseRecorder { t.Helper() rec := httptest.NewRecorder() s.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) return rec } // ── A ───────────────────────────────────────────────────────────────────────────────────────────── // TestAppPage_DeployedDefaultsToSettings — an installed app opens on „Beallitasok", at its own URL, // with the four tabs; Mukodes offers the state changes and the HEADER offers none of them. // // RED-PROOF (REPORT.md): make the default tab the guide for a deployed app (appPageHandler's // `tab = appTabSettings`) → the data-tab/aria-selected assertion fails. Reverted. func TestAppPage_DeployedDefaultsToSettings(t *testing.T) { s := appPageHarness(t) rec := appGet(t, s, "/apps/adventurelog") if rec.Code != http.StatusOK { t.Fatalf("GET /apps/adventurelog = %d, want 200: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() // The settings tab is the one showing, and it says so in the markup the household's browser reads. if !strings.Contains(body, `data-tab="settings"`) { t.Error("the settings tab link is missing from the tab bar") } if !strings.Contains(body, `data-tab="settings"`+"\n") && !strings.Contains(body, `aria-selected="true"`) { t.Error("no tab is marked as the selected one") } settingsTab := body[strings.Index(body, `data-tab="settings"`):] settingsTab = settingsTab[:strings.Index(settingsTab, "")] if !strings.Contains(settingsTab, `aria-selected="true"`) { t.Errorf("the SETTINGS tab must be the selected one, got: %q", settingsTab) } for _, tab := range []string{"settings", "data", "guide", "logs"} { if !strings.Contains(body, `data-tab="`+tab+`"`) { t.Errorf("tab %q is missing from an installed app's tab bar", tab) } } // Mukodes carries the state changes. if !strings.Contains(body, `data-run-card="1"`) { t.Error("the Mukodes card is missing") } for _, act := range []string{"restart", "stop"} { if !strings.Contains(body, `data-run-action="`+act+`"`) { t.Errorf("a running app's Mukodes card must offer %q", act) } } if strings.Contains(body, `data-run-action="start"`) { t.Error("a RUNNING app must not be offered Ind:tas") } // The header does not. This is the whole point of the Mukodes card: the three buttons used to // sit in the page header and read as menu items. // v0.316.0: the header IS the hero — a back link, the name, the chips and (at most) „Megnyitas". // Everything above the tab bar is therefore what must carry no state-change button. hi, hj := strings.Index(body, `class="app-back"`), strings.Index(body, `class="app-tabs"`) if hi < 0 || hj < 0 || hj < hi { t.Fatalf("the shell's hero/tab markers are gone (app-back=%d app-tabs=%d)", hi, hj) } header := body[hi:hj] if strings.Contains(header, "stackAction(") { t.Errorf("the hero must carry no state-change button — they belong in the Mukodes card, got: %q", header) } // Part 3: Raspberry Pi is no longer supported, so neither badge may appear. for _, gone := range []string{"Pi kompatibilis", "Runs on Pi", "Csak x86", "x86 only"} { if strings.Contains(body, gone) { t.Errorf("the removed Pi/x86 badge text %q is still on the page", gone) } } } // ── B ───────────────────────────────────────────────────────────────────────────────────────────── // TestAppPage_NotDeployedShowsGuideOnly — a not-installed app has ONE view and one URL. The tab bar // is absent (a bar of one tab is furniture) and every tab suffix answers 302 to the base. // // RED-PROOF (REPORT.md): drop the `!found.Deployed` redirect branch → /apps/grocy/settings answers // 200 and the status assertion fails. Reverted. func TestAppPage_NotDeployedShowsGuideOnly(t *testing.T) { s := appPageHarness(t) rec := appGet(t, s, "/apps/grocy") if rec.Code != http.StatusOK { t.Fatalf("GET /apps/grocy = %d, want 200", rec.Code) } body := rec.Body.String() for _, tab := range []string{"settings", "data", "guide", "logs"} { if strings.Contains(body, `data-tab="`+tab+`"`) { t.Errorf("a not-installed app must have no tab bar, but tab %q is there", tab) } } if !strings.Contains(body, `data-install-cta="1"`) { t.Error("a not-installed, installable app must offer Telepites") } if strings.Contains(body, `data-run-card="1"`) { t.Error("a not-installed app has nothing to start or stop") } for _, tab := range []string{"settings", "data", "guide", "logs"} { rec := appGet(t, s, "/apps/grocy/"+tab) if rec.Code != http.StatusFound { t.Errorf("GET /apps/grocy/%s = %d, want 302", tab, rec.Code) } if loc := rec.Header().Get("Location"); loc != "/apps/grocy" { t.Errorf("GET /apps/grocy/%s redirected to %q, want /apps/grocy", tab, loc) } } // An unknown app is a 404 on every tab URL, as it always was. for _, p := range []string{"/apps/nosuchapp", "/apps/nosuchapp/settings", "/apps/nosuchapp/logs"} { if rec := appGet(t, s, p); rec.Code != http.StatusNotFound { t.Errorf("GET %s = %d, want 404", p, rec.Code) } } // An unknown SUFFIX is not a tab, and must not be swallowed as part of the slug. if rec := appGet(t, s, "/apps/grocy/nonsense"); rec.Code != http.StatusNotFound { t.Errorf("GET /apps/grocy/nonsense = %d, want 404", rec.Code) } } // ── C ───────────────────────────────────────────────────────────────────────────────────────────── // TestAppPage_OldURLsRedirectGETOnly — the four old app URLs answer 302 (never 301) to the tab that // now holds them, and the POSTs on those same paths still reach their handlers. // // RED-PROOF (REPORT.md): remove `&& r.Method == http.MethodGet` from the /stacks/…/backup redirect // case → the POST is answered with a 302 to the data tab and the save-reaches-its-handler assertion // fails. Reverted. func TestAppPage_OldURLsRedirectGETOnly(t *testing.T) { s := appPageHarness(t) for _, c := range []struct{ from, want string }{ {"/stacks/adventurelog/logs", "/apps/adventurelog/logs"}, {"/stacks/adventurelog/export", "/apps/adventurelog/data"}, {"/stacks/adventurelog/backup", "/apps/adventurelog/data"}, // Installed → the settings tab. Not installed → the install wizard. {"/stacks/adventurelog/deploy", "/apps/adventurelog/settings"}, {"/stacks/grocy/deploy", "/apps/grocy/install"}, } { rec := appGet(t, s, c.from) if rec.Code != http.StatusFound { t.Errorf("GET %s = %d, want 302 (a 301 is cached by the browser forever)", c.from, rec.Code) } if got := rec.Header().Get("Location"); got != c.want { t.Errorf("GET %s redirected to %q, want %q", c.from, got, c.want) } } // The query string travels: a logs page open since before the update polls with ?raw=1. rec := appGet(t, s, "/stacks/adventurelog/logs?raw=1") if got := rec.Header().Get("Location"); got != "/apps/adventurelog/logs?raw=1" { t.Errorf("the redirect dropped the query: Location=%q", got) } // And the tab itself still answers plain text for that poller. raw := appGet(t, s, "/apps/adventurelog/logs?raw=1") if ct := raw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("?raw=1 on the logs tab returned Content-Type %q, want text/plain", ct) } // An unknown stack name is a 404, not a redirect to an invented slug. if rec := appGet(t, s, "/stacks/nosuchapp/logs"); rec.Code != http.StatusNotFound { t.Errorf("GET /stacks/nosuchapp/logs = %d, want 404", rec.Code) } // THE POSTS ARE UNTOUCHED. Each must reach its handler — the redirect must never see a POST. for _, p := range []string{"/stacks/adventurelog/backup", "/stacks/adventurelog/app-email"} { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, p, strings.NewReader("enabled=on")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") s.ServeHTTP(rec, req) if rec.Code == http.StatusFound && strings.HasPrefix(rec.Header().Get("Location"), "/apps/") { t.Errorf("POST %s was redirected to %q — a save must reach its handler, not a page", p, rec.Header().Get("Location")) } } } // ── D and E: the after-start registry ───────────────────────────────────────────────────────────── // afterStartHarness is appPageHarness with one app whose catalog declares an after-start field. // `slug` and `env` say which, so one harness serves both the registered case (plex/PLEX_CLAIM) and // the unregistered one. func afterStartHarness(t *testing.T, slug, env string, gateClosed bool) *Server { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(bin, "docker"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin) lg := log.New(io.Discard, "", 0) cfg := config.Default() cfg.Customer.Domain = "example.hu" cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") app := filepath.Join(cfg.Paths.StacksDir, slug) if err := os.MkdirAll(app, 0o755); err != nil { t.Fatal(err) } write := func(n, b string) { if err := os.WriteFile(filepath.Join(app, n), []byte(b), 0o644); err != nil { t.Fatal(err) } } write("docker-compose.yml", "services:\n "+slug+":\n image: busybox\n") write(".felhom.yml", "display_name: "+slug+"\nslug: "+slug+"\nsetup_gate: true\n"+ "deploy_fields:\n - env_var: "+env+"\n label: Kod\n type: text\n after_start: true\n placeholder: claim-xxxx\n") gate := "open" if gateClosed { gate = "closed" } write("app.yaml", "deployed: true\nsetup_gate:\n state: "+gate+"\n since: \"2026-10-01T00:00:00Z\"\n") sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } _ = mgr.ScanStacks() if _, ok := mgr.GetStack(slug); !ok { t.Fatalf("%s not discovered", slug) } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() return s } // TestAfterStart_PlexThroughRouter (Scenario D) — the household's code goes in at // POST /apps/plex/after-start/PLEX_CLAIM, through the PRODUCTION ROUTER and the REAL registry, to a // fake Plex that answers the way the measured one does. The card is there while the gate is closed // and gone once Plex says claimed. // // The registry wiring in server.go is asserted BY THIS TEST and not by an injected registry — an // injected one would pass with the route missing, which is the seam-built-but-never-wired class. // // RED-PROOF (REPORT.md): remove the `/after-start/` case from server.go → the POST 404s and the // claim assertion fails. Reverted. func TestAfterStart_PlexThroughRouter(t *testing.T) { hosts := fakePlex(t) s := afterStartHarness(t, "plex", "PLEX_CLAIM", true) // The card is offered: the field is declared, the action is registered, and it is still needed. body := appGet(t, s, "/apps/plex").Body.String() if !strings.Contains(body, `data-afterstart="PLEX_CLAIM"`) { t.Fatalf("the after-start card is missing while the gate is closed") } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/apps/plex/after-start/PLEX_CLAIM", strings.NewReader(`{"value":"claim-felhomtestcode0000"}`)) req.Header.Set("Content-Type", "application/json") s.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("POST after-start = %d, want 200: %s", rec.Code, rec.Body.String()) } // The call actually reached the fake — by ADDRESS, which is what R-1060 fixed. if len(*hosts) == 0 { t.Fatal("no call reached the fake Plex — the registry did not run the claim") } for _, h := range *hosts { if !strings.HasPrefix(h, "10.9.8.7:") { t.Errorf("a call to Plex carried Host %q, not its address", h) } } // The gate moved, and the card is gone with it — Plex itself said claimed. st, _ := s.stackMgr.GetStack("plex") if st.AppConfig != nil && st.AppConfig.SetupGate.Closed() { t.Error("the setup gate is still closed after a successful claim") } if after := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(after, `data-afterstart="PLEX_CLAIM"`) { t.Error("the card is still shown after the claim succeeded") } } // TestAfterStart_NoActionNoCard (Scenario E) — THE WRONG CASE. A catalog template declares an // after-start field for an app this repository has no action for. Nothing renders, nothing runs, the // POST is a 404, and the fake records zero calls: a catalog file cannot make the box do anything. // // RED-PROOF (REPORT.md): drop the `!ok` guard in afterStartFields → the card renders for an app with // no action and the first assertion fails; drop it in appAfterStartHandler → the POST stops being a // 404. Both reverted. func TestAfterStart_NoActionNoCard(t *testing.T) { hosts := fakePlex(t) // A real-looking app with an after-start field nobody registered an action for. s := afterStartHarness(t, "grocy", "GROCY_TOKEN", true) if body := appGet(t, s, "/apps/grocy").Body.String(); strings.Contains(body, `data-afterstart=`) { t.Error("a field with no registered action must render NO card") } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/apps/grocy/after-start/GROCY_TOKEN", strings.NewReader(`{"value":"anything at all"}`)) req.Header.Set("Content-Type", "application/json") s.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("POST to an unregistered after-start field = %d, want 404", rec.Code) } // A registered env var on the WRONG app is just as inert — the key is slug AND env var. rec = httptest.NewRecorder() req = httptest.NewRequest(http.MethodPost, "/apps/grocy/after-start/PLEX_CLAIM", strings.NewReader(`{"value":"claim-felhomtestcode0000"}`)) req.Header.Set("Content-Type", "application/json") s.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("POST /apps/grocy/after-start/PLEX_CLAIM = %d, want 404", rec.Code) } if len(*hosts) != 0 { t.Errorf("%d call(s) reached Plex from an app with no registered action: %v", len(*hosts), *hosts) } } // TestAfterStart_RegistryAloneIsNotAnInvitation — the OTHER half of the handler's gate, and the one // the registry check cannot stand in for: the action IS registered (plex/PLEX_CLAIM), but THIS box's // catalog template does not declare the field. Nothing may run. // // It is tested on its own because the obvious case does not reach it: a POST naming an app with no // registry entry is refused by the `!ok` check first, so the `declared` check was green and unpinned // until this test existed — the comment-asserting-an-invariant class (CLAUDE.md). // // RED-PROOF (REPORT.md): drop `!declared` from appAfterStartHandler's guard → the POST answers 200 // and the fake records a claim call. Reverted. (Dropping it with only TestAfterStart_NoActionNoCard // in place left the suite GREEN, which is why this test was written.) func TestAfterStart_RegistryAloneIsNotAnInvitation(t *testing.T) { hosts := fakePlex(t) // Plex, with its setup gate closed, but WITHOUT the after_start field in its template — the state // every box was in before the catalog carried the field, and the state a box is in whose catalog // sync has not landed yet. s := afterStartHarness(t, "plex", "PLEX_CLAIM", true) undeclared := "display_name: plex\nslug: plex\nsetup_gate: true\n" if err := os.WriteFile(filepath.Join(s.cfg.Paths.StacksDir, "plex", ".felhom.yml"), []byte(undeclared), 0o644); err != nil { t.Fatal(err) } if err := s.stackMgr.ScanStacks(); err != nil { t.Fatal(err) } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/apps/plex/after-start/PLEX_CLAIM", strings.NewReader(`{"value":"claim-felhomtestcode0000"}`)) req.Header.Set("Content-Type", "application/json") s.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("POST for a field the catalog does not declare = %d, want 404", rec.Code) } if len(*hosts) != 0 { t.Errorf("%d call(s) reached Plex for an UNDECLARED field: %v", len(*hosts), *hosts) } // And the gate it would have opened is untouched. st, _ := s.stackMgr.GetStack("plex") if st.AppConfig == nil || !st.AppConfig.SetupGate.Closed() { t.Error("the setup gate moved although nothing was allowed to run") } // No card either, since the field is not declared. if body := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(body, `data-afterstart=`) { t.Error("a card rendered for a field the catalog does not declare") } } // TestAfterStart_CardHiddenWhenNotNeeded — the other branch of the registry's gate: the action is // registered, but it says the value is no longer wanted. func TestAfterStart_CardHiddenWhenNotNeeded(t *testing.T) { s := afterStartHarness(t, "plex", "PLEX_CLAIM", false) // the gate is OPEN: already claimed if body := appGet(t, s, "/apps/plex").Body.String(); strings.Contains(body, `data-afterstart="PLEX_CLAIM"`) { t.Error("the card must be hidden once the action says the value is not needed") } } // TestAfterStart_PlexClaimOldRouteStillWorks — the route the page carried before v0.315.0 runs the // same registry entry, so a tab open across the update still works. func TestAfterStart_PlexClaimOldRouteStillWorks(t *testing.T) { hosts := fakePlex(t) s := afterStartHarness(t, "plex", "PLEX_CLAIM", true) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/apps/plex/plex-claim", strings.NewReader(`{"code":"claim-felhomtestcode0000"}`)) req.Header.Set("Content-Type", "application/json") s.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("POST /apps/plex/plex-claim = %d, want 200: %s", rec.Code, rec.Body.String()) } var got struct { OK bool `json:"ok"` Data map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || !got.OK { t.Fatalf("the old route's answer changed shape: %s (%v)", rec.Body.String(), err) } if len(*hosts) == 0 { t.Error("the old route did not reach Plex") } } // ── G ───────────────────────────────────────────────────────────────────────────────────────────── // TestInstallPage_BackGoesToApp (Scenario G) — the wizard's Vissza and Megsem land on the APP, not // on the list. Pressing „back" and losing the app was the complaint this release is about. // // RED-PROOF (REPORT.md): point either href at /stacks → the matching assertion fails. Reverted. func TestInstallPage_BackGoesToApp(t *testing.T) { s := appPageHarness(t) rec := appGet(t, s, "/apps/grocy/install") if rec.Code != http.StatusOK { t.Fatalf("GET /apps/grocy/install = %d, want 200: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, marker := range []string{`data-install-back="1"`, `data-install-cancel="1"`} { i := strings.Index(body, marker) if i < 0 { t.Errorf("the install page has no %s", marker) continue } // The href is on the same tag, before the marker. tag := body[strings.LastIndex(body[:i], "