db52f5a4cf
gates / gates (push) Failing after 49s
FOUND IN A BROWSER. The log tab's body was lifted out of the old logs.html by line range and the
range stopped one line short, so the closing </script> never came with it. Everything downstream
agreed the page was fine:
- html/template parsed it,
- the page rendered and looked right,
- TestAppPage_EachTabRendersItsOwnContent found `setInterval(window.fetchLogs` exactly where it
expected it, because the TEXT was there.
Chrome refused the whole block with "SyntaxError: Unexpected token '<'" at the `</div>` that
followed, so the live log never refreshed. A marker proves a string is present; it does not prove
the browser will execute it.
TestAppPage_ScriptTagsAreBalanced now counts <script> against </script> on all seven app-page URLs
and checks the last block is closed. Red-proved by removing the tag again: it fails naming the logs
tab, 3 opened and 2 closed.
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.
|
|
// The header is everything before the hero block; app-info-hero is the design system's existing
|
|
// hero class, which the shell reuses rather than inventing one with no rules behind it.
|
|
hi, hj := strings.Index(body, `class="page-header"`), strings.Index(body, `class="app-info-hero"`)
|
|
if hi < 0 || hj < 0 || hj < hi {
|
|
t.Fatalf("the shell's header/hero markers are gone (page-header=%d app-info-hero=%d)", hi, hj)
|
|
}
|
|
header := body[hi:hj]
|
|
if strings.Contains(header, "stackAction(") {
|
|
t.Errorf("the page header must carry no state-change button, 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)
|
|
}
|
|
}
|
|
}
|
|
}
|