7f3e58ff0a
gates / gates (push) Failing after 49s
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:
ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
is a thing you READ, so a card there is a promise the content cannot keep.
A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
amber when something is risky. The exception-colour principle applied to shape as well.
What moved:
- the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
(state, lifecycle, update) and meta text (the address, the last automatic update);
- the tabs are links with a 2px blue underline under the open one — no pills, no fills;
- sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
value on the page starts at the same x, and stack under 720px;
- the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
wants its claim code should meet the code box, not a Restart button;
- "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
pages that no longer exist, on a tab that already says which app this is;
- the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
that said the same thing is skipped for an installed app (the install wizard still asks);
- the "this app is already installed, the settings are read-only" BANNER becomes the hint under
the fields it is about;
- the install wizard gets the same back link, hero and column, so pressing Telepites does not
land the household on a page that looks like a different product.
THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.
Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.
app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
622 lines
28 KiB
Go
622 lines
28 KiB
Go
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, "</a>")]
|
|
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], "<a "):i]
|
|
if !strings.Contains(tag, `href="/apps/grocy"`) {
|
|
t.Errorf("%s points at %q, want /apps/grocy", marker, tag)
|
|
}
|
|
}
|
|
// After a successful install the wizard's own links point at the app's tabs.
|
|
for _, want := range []string{`/apps/grocy/settings`, `/apps/grocy/logs`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the post-install card must link %s", want)
|
|
}
|
|
}
|
|
// An unknown app has no install page.
|
|
if rec := appGet(t, s, "/apps/nosuchapp/install"); rec.Code != http.StatusNotFound {
|
|
t.Errorf("GET /apps/nosuchapp/install = %d, want 404", rec.Code)
|
|
}
|
|
}
|
|
|
|
// ── the tabs' own content ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// TestAppPage_EachTabRendersItsOwnContent — one marker per tab, so a tab that silently renders
|
|
// another tab's body (or nothing) is caught. The log poller is the sharpest of them: it must run on
|
|
// the log tab and NOWHERE else, which is the reason these are four URLs and not four hidden panels.
|
|
func TestAppPage_EachTabRendersItsOwnContent(t *testing.T) {
|
|
s := appPageHarness(t)
|
|
for _, c := range []struct {
|
|
tab string
|
|
want []string
|
|
mustNot []string
|
|
wantPoll bool
|
|
}{
|
|
{tab: "settings", want: []string{`data-run-card="1"`}, mustNot: []string{`data-tab-panel="logs"`}},
|
|
{tab: "data", want: []string{`class="app-data-tab"`}, mustNot: []string{`data-run-card="1"`, `data-tab-panel="logs"`}},
|
|
{tab: "guide", want: []string{`data-guide-missing="1"`}, mustNot: []string{`data-run-card="1"`, `data-tab-panel="logs"`}},
|
|
{tab: "logs", want: []string{`data-tab-panel="logs"`}, mustNot: []string{`data-run-card="1"`}, wantPoll: true},
|
|
} {
|
|
rec := appGet(t, s, "/apps/adventurelog/"+c.tab)
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("GET /apps/adventurelog/%s = %d, want 200", c.tab, rec.Code)
|
|
continue
|
|
}
|
|
body := rec.Body.String()
|
|
for _, w := range c.want {
|
|
if !strings.Contains(body, w) {
|
|
t.Errorf("the %s tab is missing its own marker %s", c.tab, w)
|
|
}
|
|
}
|
|
for _, w := range c.mustNot {
|
|
if strings.Contains(body, w) {
|
|
t.Errorf("the %s tab carries %s, which belongs to another tab", c.tab, w)
|
|
}
|
|
}
|
|
// The poller: `setInterval(window.fetchLogs` is the live log loop.
|
|
if poll := strings.Contains(body, "setInterval(window.fetchLogs"); poll != c.wantPoll {
|
|
t.Errorf("the %s tab runs the log poller=%v, want %v", c.tab, poll, c.wantPoll)
|
|
}
|
|
// The shell is on every tab, and so is its tab bar.
|
|
if !strings.Contains(body, `data-tab="`+c.tab+`"`) {
|
|
t.Errorf("the %s tab has no tab bar", c.tab)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAppPage_ScriptTagsAreBalanced — every tab's rendered HTML closes every <script> it opens.
|
|
//
|
|
// THIS TEST EXISTS BECAUSE THE SUITE MISSED A DEAD PAGE. The log tab's body was lifted out of the
|
|
// old logs.html by line range and the range stopped one line short, dropping `</script>`. Go's
|
|
// html/template parsed it, the page rendered, and TestAppPage_EachTabRendersItsOwnContent found
|
|
// `setInterval(window.fetchLogs` exactly where it expected it — because the TEXT was there. The
|
|
// browser then refused the block with „SyntaxError: Unexpected token '<'" and the live log poller
|
|
// never ran. A marker proves a string is present; it does not prove the browser will execute it.
|
|
//
|
|
// RED-PROOF (REPORT.md): delete the `</script>` from app_logs.html again → this fails naming the
|
|
// logs tab. Reverted.
|
|
func TestAppPage_ScriptTagsAreBalanced(t *testing.T) {
|
|
s := appPageHarness(t)
|
|
for _, p := range []string{
|
|
"/apps/adventurelog", "/apps/adventurelog/settings", "/apps/adventurelog/data",
|
|
"/apps/adventurelog/guide", "/apps/adventurelog/logs",
|
|
"/apps/grocy", "/apps/grocy/install",
|
|
} {
|
|
body := appGet(t, s, p).Body.String()
|
|
open := strings.Count(body, "<script")
|
|
closed := strings.Count(body, "</script>")
|
|
if open != closed {
|
|
t.Errorf("%s: %d <script> opened, %d closed — an unclosed block swallows the markup after it "+
|
|
"and the browser refuses the whole thing", p, open, closed)
|
|
}
|
|
// An unclosed block shows up as page markup INSIDE the script: the first thing after the
|
|
// last statement would be a tag rather than </script>.
|
|
if i := strings.LastIndex(body, "<script"); i >= 0 {
|
|
rest := body[i:]
|
|
if j := strings.Index(rest, "</script>"); j < 0 {
|
|
t.Errorf("%s: the last <script> is never closed", p)
|
|
}
|
|
}
|
|
}
|
|
}
|