Files
felhom-controller/controller/internal/web/app_page_tuning_test.go
T
admin d170a2aecf
gates / gates (push) Successful in 52s
v0.319.0: the app page in panels, the login in one place, a password the box never had is said in words
- style A (operator 2026-10-16): hero, tab bar and every section are panels on --bg-1, one width, 16px apart;
  the install page's form groups use the same rule; the settings tab's 700px inner wrapper is gone
- „Belépés az alkalmazásba": name + password side by side, fetched on demand (R-254, R-709); pairing in app_login.go
- the empty-value rule: no stored value -> a sentence, never an eye; page and reveal endpoint share storedValuePresent
- „Cím és kulcsok" is rows (no Domain input, no disabled storage select, no install form); hint under the title
- tests red-proved: app_page_tuning_test.go (4); 36 app-page parity fixtures re-captured, 4 new; install fixtures unchanged

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-16 12:12:06 +02:00

435 lines
18 KiB
Go

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 </div>.
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], "<div")
depth := 0
for _, m := range divTagRe.FindAllStringIndex(body[start:], -1) {
if strings.HasPrefix(body[start+m[0]:], "</") {
depth--
} else {
depth++
}
if depth == 0 {
return body[start : start+m[1]]
}
}
t.Fatalf("%s: unbalanced divs", marker)
return ""
}
var divTagRe = regexp.MustCompile(`</?div\b[^>]*>`)
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)<script\b.*?</script>|<style\b.*?</style>|<!--.*?-->`)
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:], "</nav>")
rest := body[nav+end+len("</nav>"):]
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 (`<div data-tab-panel="logs">`, 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], "<div"):i], "app-section") {
t.Error("a not-installed app's guide-missing line is not a section")
}
// The install page: every form group is a .form-section inside the wizard's form, and the
// stylesheet draws it with the SAME rule as .app-section.
for _, c := range i18nCases() {
if c.tmpl != "deploy" {
continue
}
body := renderI18nCase(t, s, "hu", c)
form := body[strings.Index(body, `id="deploy-form"`):]
form = form[:strings.Index(form, "</form>")]
if strings.Count(form, `<h4>`) != strings.Count(form, `<div class="form-section">`) {
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])))
}
}
}