d170a2aecf
gates / gates (push) Successful in 52s
- 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
435 lines
18 KiB
Go
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])))
|
|
}
|
|
}
|
|
}
|