package web import ( "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "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" ) // APP PAGE TUNING, ROUND 2 (v0.319.0) — the login panel, the empty-value rule, the settings tab // without the install form's duplicates, and one section style on every surface. // // The page tests go through s.ServeHTTP (the production route switch and handler), because the // thing under test is what the HANDLER tells the template: a template rendered with hand-built // LoginFields would assert nothing about whether the box's own values stay out of the page. // Assertions are on ASCII `data-*` markers and ids, never on accented Hungarian. // The two values the box holds for an installed Calibre-Web. Neither may appear in any page. const ( tuningUser = "TESTONLY-user-q7x" tuningPassword = "TESTONLY-pw-Lm93!" tuningKey = "TESTONLY-key-Zr55" ) // calibreMeta is the catalog's calibre-web deploy_fields (templates/calibre-web/.felhom.yml, read // 2026-10-16), plus one generated key so a non-login secret row is exercised too. const calibreMeta = "display_name: Calibre-Web\nslug: calibre-web\nsubdomain: books\n" + "deploy_fields:\n" + " - env_var: DOMAIN\n label: Domain\n type: domain\n locked_after_deploy: true\n" + " - env_var: SUBDOMAIN\n label: Aldomain\n type: subdomain\n default: books\n required: true\n" + " - env_var: HDD_PATH\n label: Konyvtar utvonal\n type: path\n required: true\n" + " - env_var: ADMIN_USER\n label: Admin nev\n type: secret\n generate: \"hex:5\"\n" + " - env_var: ADMIN_PASSWORD\n label: Admin jelszo\n type: password\n generate: \"password:24:special\"\n" + " - env_var: SECRET_KEY\n label: Titkos kulcs\n type: secret\n generate: \"hex:16\"\n" // tuningHarness: `calibre-web` installed with the given env, and `calibre-new` — the same template, // not installed — for the install page. func tuningHarness(t *testing.T, env map[string]string) *Server { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } stub := "#!/bin/sh\nif [ \"$1\" = ps ]; then\n printf 'calibre-web\\tbusybox\\trunning\\tUp 2 hours\\tcalibre-web\\n'\nfi\nexit 0\n" if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 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") for _, app := range []string{"calibre-web", "calibre-new"} { d := filepath.Join(cfg.Paths.StacksDir, app) if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } meta := strings.Replace(calibreMeta, "slug: calibre-web", "slug: "+app, 1) for name, body := range map[string]string{ "docker-compose.yml": "services:\n " + app + ":\n image: busybox\n", ".felhom.yml": meta, } { if err := os.WriteFile(filepath.Join(d, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } } if err := stacks.SaveAppConfig(filepath.Join(cfg.Paths.StacksDir, "calibre-web"), &stacks.AppConfig{Deployed: true, Env: env}, nil, nil); err != nil { t.Fatal(err) } sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/hdd_1", Label: "Kulso lemez", IsDefault: true, Schedulable: true}); err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } _ = mgr.ScanStacks() if _, ok := mgr.GetStack("calibre-web"); !ok { t.Fatal("calibre-web was not discovered by ScanStacks") } s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"} s.loadTemplates() return s } func tuningEnv() map[string]string { return map[string]string{ "DOMAIN": "example.hu", "SUBDOMAIN": "books", "HDD_PATH": "/mnt/felhom-drives/hdd_1", "ADMIN_USER": tuningUser, "ADMIN_PASSWORD": tuningPassword, "SECRET_KEY": tuningKey, } } // revealStatus asks the reveal endpoint exactly as the eye button does, and returns its status. func revealStatus(s *Server, envVar string) int { r := httptest.NewRequest(http.MethodPost, "/stacks/calibre-web/auto-field/reveal", strings.NewReader("env_var="+envVar)) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() s.appAutoFieldRevealHandler(w, r, "calibre-web") return w.Code } // sectionOf returns the element that starts at the first `marker` up to its matching . func sectionOf(t *testing.T, body, marker string) string { t.Helper() i := strings.Index(body, marker) if i < 0 { t.Fatalf("no %s in the page", marker) } start := strings.LastIndex(body[:i], "]*>`) var revealCallRe = regexp.MustCompile(`revealAutoField\('calibre-web','([A-Z_]+)'`) // TestAppPage_LoginPairPanel (Scenario B) — ONE „Belepes az alkalmazasba" panel holds the name and // the password, name first; both are fetched on demand, so neither value is in the page; and the // eye on each is one the endpoint answers. // // RED-PROOF (REPORT.md): render the panel without the name (drop the pairing loop in // loginFieldIndexes) -> the ADMIN_USER assertion fails. Reverted. func TestAppPage_LoginPairPanel(t *testing.T) { s := tuningHarness(t, tuningEnv()) rec := appGet(t, s, "/apps/calibre-web/settings") if rec.Code != http.StatusOK { t.Fatalf("GET settings = %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() for _, v := range []string{tuningUser, tuningPassword, tuningKey} { if strings.Contains(body, v) { t.Fatalf("a stored secret value is in the settings page (R-254/R-709)") } } if n := strings.Count(body, `data-login-panel="1"`); n != 1 { t.Fatalf("%d login panels, want exactly 1", n) } panel := sectionOf(t, body, `data-login-panel="1"`) u, p := strings.Index(panel, `data-login-field="ADMIN_USER"`), strings.Index(panel, `data-login-field="ADMIN_PASSWORD"`) if u < 0 || p < 0 { t.Fatalf("the login panel must hold BOTH halves (user=%d password=%d)", u, p) } if u > p { t.Error("the name must come before the password") } if !strings.Contains(panel, `class="app-login-pair"`) { t.Error("the pair is not laid out side by side (app-login-pair)") } for _, f := range []string{"ADMIN_USER", "ADMIN_PASSWORD"} { if !strings.Contains(panel, `data-login-reveal="`+f+`"`) { t.Errorf("no eye for %s in the login panel", f) } if n := strings.Count(body, `id="field-`+f+`"`); n != 1 { t.Errorf("field %s shown %d times, want once (Scenario E)", f, n) } } // The login fields are shown ONCE: not also as a „Cim es kulcsok" row. if strings.Contains(body, `data-setting-row="ADMIN_USER"`) || strings.Contains(body, `data-setting-row="ADMIN_PASSWORD"`) { t.Error("a login field is shown twice — in the panel and as a row") } // Every eye on the page is one the endpoint answers with a value. for _, m := range revealCallRe.FindAllStringSubmatch(body, -1) { if code := revealStatus(s, m[1]); code != http.StatusOK { t.Errorf("the page offers an eye for %s and the endpoint answers %d", m[1], code) } } } // TestAppPage_NoStoredValueShowsSentence (Scenario C) — the box holds no password (Calibre-Web // installed before its template had one, measured on demo-hp 2026-10-16): the page says so in a // sentence and offers no eye, because the endpoint would answer 404 for it. // // RED-PROOF (REPORT.md): render the eye regardless (Stored: true in appLoginFields) -> the // no-eye and the every-eye-answers assertions fail. Reverted. func TestAppPage_NoStoredValueShowsSentence(t *testing.T) { env := tuningEnv() delete(env, "ADMIN_PASSWORD") env["ADMIN_USER"] = " " // a blank value is no value: the endpoint's own rule s := tuningHarness(t, env) body := appGet(t, s, "/apps/calibre-web/settings").Body.String() for _, f := range []string{"ADMIN_PASSWORD", "ADMIN_USER"} { if code := revealStatus(s, f); code != http.StatusNotFound { t.Fatalf("precondition: the endpoint must answer 404 for %s, got %d", f, code) } if !strings.Contains(body, `data-login-not-stored="`+f+`"`) { t.Errorf("%s: no sentence for a field the box holds no value for", f) } if strings.Contains(body, `data-login-reveal="`+f+`"`) || strings.Contains(body, `revealAutoField('calibre-web','`+f+`'`) { t.Errorf("%s: an eye is offered for a value the endpoint answers 404 for", f) } } for _, m := range revealCallRe.FindAllStringSubmatch(body, -1) { if code := revealStatus(s, m[1]); code != http.StatusOK { t.Errorf("the page offers an eye for %s and the endpoint answers %d", m[1], code) } } } // TestAppPage_SettingsHasNoDuplicateDomain (Scenario E) — the settings tab carries no Domain input // (the address is the „Cim" row), no disabled storage select (it is the „Tarhely" row), no install // form, and the read-only hint sits under the „Cim es kulcsok" title. The install page keeps its // fields (Scenario D's byte-identity is the parity fixtures' job; this asserts the fields are there). // // RED-PROOF (REPORT.md): put `{{template "app_deploy_fields" .}}` back into the settings tab -> the // Domain/deploy-form assertions fail. Reverted. func TestAppPage_SettingsHasNoDuplicateDomain(t *testing.T) { s := tuningHarness(t, tuningEnv()) body := appGet(t, s, "/apps/calibre-web/settings").Body.String() for _, gone := range []string{`id="auto-field-DOMAIN"`, `id="field-DOMAIN"`, `id="deploy-form"`, `id="field-HDD_PATH"`, `id="field-SUBDOMAIN"`} { if strings.Contains(body, gone) { t.Errorf("the settings tab still renders %s", gone) } } if n := strings.Count(body, `data-app-address-row="1"`); n != 1 { t.Errorf("%d address rows, want 1", n) } keys := sectionOf(t, body, `data-address-keys="1"`) hint, addr := strings.Index(keys, `data-readonly-hint="1"`), strings.Index(keys, `data-app-address-row="1"`) if hint < 0 || addr < 0 || hint > addr { t.Errorf("the read-only hint must sit under the title, above the rows (hint=%d address=%d)", hint, addr) } if strings.Count(body, `data-readonly-hint="1"`) != 1 { t.Error("the read-only hint must appear once, inside the address-and-keys section") } if row := sectionOf(t, keys, `data-app-storage-row="1"`); !strings.Contains(row, "Kulso lemez") { t.Errorf("the storage row must name the app's drive, got %q", row) } if !strings.Contains(keys, `data-setting-row="SECRET_KEY"`) { t.Error("a generated non-login key must still be a row with its eye") } inst := appGet(t, s, "/apps/calibre-new/install") if inst.Code != http.StatusOK { t.Fatalf("GET install = %d", inst.Code) } ib := inst.Body.String() for _, want := range []string{`id="deploy-form"`, `id="field-SUBDOMAIN"`, `id="field-ADMIN_PASSWORD"`, `id="auto-field-ADMIN_USER"`, `generatePassword('field-ADMIN_PASSWORD'`} { if !strings.Contains(ib, want) { t.Errorf("the install page lost %s", want) } } } // ── one section style ───────────────────────────────────────────────────────────────────────────── var ( tagRe = regexp.MustCompile(`<(/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*?)(/?)>`) classAttrRe = regexp.MustCompile(`class="([^"]*)"`) stripBlockRe = regexp.MustCompile(`(?s)||`) voidTags = map[string]bool{"input": true, "img": true, "br": true, "hr": true, "meta": true, "link": true, "source": true, "use": true, "path": true} // Wrappers that carry no look of their own: their children are the page's sections. transparentWrappers = []string{"app-sections", "app-guide", "app-data-tab", "app-settings-tab"} // Not sections: the save/failure flash lines at the top of a tab. messageClasses = []string{"flash", "monitoring-banner"} ) func hasClass(attrs, want string) bool { m := classAttrRe.FindStringSubmatch(attrs) if m == nil { return false } for _, c := range strings.Fields(m[1]) { if c == want { return true } } return false } // tabTopLevelBlocks returns, for a rendered app-page TAB, every block the household sees between the // tab bar and the end of the column — looking through the transparent wrappers — as its opening tag. func tabTopLevelBlocks(t *testing.T, where, body string) []string { t.Helper() body = stripBlockRe.ReplaceAllString(body, "") nav := strings.Index(body, `class="app-tabs"`) if nav < 0 { t.Fatalf("%s: no tab bar", where) } end := strings.Index(body[nav:], "") rest := body[nav+end+len(""):] var stack []bool // true = transparent wrapper var out []string for _, m := range tagRe.FindAllStringSubmatch(rest, -1) { closing, name, attrs, self := m[1] == "/", strings.ToLower(m[2]), m[3], m[4] == "/" if closing { if len(stack) == 0 { return out // the column itself closed } stack = stack[:len(stack)-1] continue } if voidTags[name] || self { continue } level := 0 for _, tr := range stack { if !tr { level++ } } transparent := false if name == "div" { for _, w := range transparentWrappers { if hasClass(attrs, w) { transparent = true } } } if level == 0 && !transparent { out = append(out, m[0]) } stack = append(stack, transparent) } return out } // TestAppPage_OneSectionStyleEverywhere — style A, the operator's choice of 2026-10-16: every section // on the four tabs is an `.app-section` panel, the install page's form groups and progress panel are // the same panel by one CSS rule, and no surface keeps the old flat hairline rule or a box of its own. // // Rendered through the parity cases (every branch the fixtures carry, both the rich and the bare data) // and the production route for the bare harness app. // // RED-PROOF (REPORT.md): leave the logs tab on its old markup (`
`, no // app-section) -> the app_logs case fails. Reverted. func TestAppPage_OneSectionStyleEverywhere(t *testing.T) { s := i18nTestServer(t) tabs := map[string]bool{"app_settings": true, "app_data": true, "app_guide": true, "app_logs": true} checked := map[string]int{} for _, c := range i18nCases() { if !tabs[c.tmpl] { continue } body := renderI18nCase(t, s, "hu", c) if !strings.Contains(body, `class="app-tabs"`) { continue // a not-installed app's guide has no tab bar; the route test below covers it } checked[c.tmpl]++ for _, tag := range tabTopLevelBlocks(t, c.name, body) { if hasClass(tag, "app-section") { continue } msg := false for _, mc := range messageClasses { msg = msg || hasClass(tag, mc) } if !msg { t.Errorf("%s: a block outside the section style: %s", c.name, tag) } } for _, legacy := range []string{`class="app-optional-config"`, `class="deploy-storage-info"`, `class="card"`, `class="deploy-container"`, `id="deploy-form"`} { if strings.Contains(body, legacy) { t.Errorf("%s: a tab still carries the old box %s", c.name, legacy) } } } for tmpl := range tabs { if checked[tmpl] == 0 { t.Fatalf("no parity case exercised %s — the check proved nothing for that tab", tmpl) } } // A not-installed app: its one view is the guide, without a tab bar — sections after the hero. hs := appPageHarness(t) g := appGet(t, hs, "/apps/grocy").Body.String() g = stripBlockRe.ReplaceAllString(g, "") if i := strings.Index(g, `data-guide-missing="1"`); i < 0 || !strings.Contains(g[strings.LastIndex(g[:i], "")] if strings.Count(form, `

`) != strings.Count(form, `
`) { t.Errorf("%s: a form group on the install page is not a form-section", c.name) } } css, err := os.ReadFile(filepath.Join("templates", "style.css")) if err != nil { t.Fatal(err) } panelRule := regexp.MustCompile(`(?s)\.app-section,\s*\.app-page \.deploy-form \.form-section,\s*\.app-page \.deploy-progress\s*\{([^}]*)\}`).FindSubmatch(css) if panelRule == nil { t.Fatal("style.css: the one panel rule (.app-section + the install page's form-section and progress) is gone") } for _, want := range []string{"background: var(--bg-1)", "border: 1px solid var(--line)", "padding: 18px 24px 22px", "margin: 0 0 16px"} { if !strings.Contains(string(panelRule[1]), want) { t.Errorf("style.css: the panel rule lacks %q (style A)", want) } } // No .app-section rule may redraw it the old way: v0.316.0's flat `padding: 28px 0` + hairline. for _, m := range regexp.MustCompile(`([^{}]*\.app-section[^{}]*)\{([^}]*)\}`).FindAllSubmatch(css, -1) { if strings.Contains(string(m[2]), "padding: 28px 0") { t.Errorf("style.css: %s still carries the old flat section rule", strings.TrimSpace(string(m[1]))) } } }