package web import ( "bytes" "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/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // LOGINS AND FOLDERS (v0.320.0) — FileBrowser is part of the box, every installed app says where its login is, and // a „Fajlok" button opens one of the app's folders. // // Every test goes through s.ServeHTTP (the production route switch, appCommonData and the tab handlers), on a REAL // stack manager that scanned a stacks dir: the defect this release fixes was the page handler reading the wrong // field (Deployed) for a stack the scan builds, so a test with hand-built page data would assert nothing about it. // Only docker (a PATH stub) and the drive mount table (presentDrivesFn) are seamed. Markers are ASCII. const ( lfDriveA = "/mnt/felhom-drives/hdd_1" lfDriveB = "/mnt/felhom-drives/hdd_2" // A drive label with a space, an ampersand and accents — the FileBrowser source name is the URL identity, and // it must be PathEscaped (a space is %20, never +). lfLabelA = "Média & könyvtár" lfLabelB = "HDD 2" // Values the box holds; none may appear in any page. lfSecretPW = "TESTONLY-lf-pw-Qx81" lfFBPW = "TESTONLY-lf-fb-Zk40" ) type lfApp struct { meta, compose string env map[string]string // nil = not installed } var lfApps = map[string]lfApp{ // (a)/(c) the box's own login fields; data_paths: an import folder FIRST, the library second. "calibre-web": { meta: "display_name: Calibre-Web\nslug: calibre-web\nsubdomain: books\n" + "data_paths:\n - path: calibre\n root: import\n role: import\n label: Beolvasando\n" + " - path: media/books\n root: userdata\n role: library\n label: E-konyvtar\n" + "deploy_fields:\n - env_var: HDD_PATH\n label: Konyvtar\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\"\n", compose: "services:\n calibre-web:\n image: busybox\n volumes:\n - ${IMPORT_PATH}/calibre:/ingest\n - ${USERDATA_PATH}/media/books:/library\n", env: map[string]string{"HDD_PATH": lfDriveA, "ADMIN_USER": "TESTONLY-lf-user", "ADMIN_PASSWORD": lfSecretPW}, }, // (c) the same fields, no stored password (installed before the template gained it). "kimai": { meta: "display_name: Kimai\nslug: kimai\n" + "deploy_fields:\n - env_var: ADMIN_PASSWORD\n label: Admin jelszo\n type: password\n", compose: "services:\n kimai:\n image: busybox\n", env: map[string]string{}, }, "romm": { meta: "display_name: RomM\nslug: romm\n" + "data_paths:\n - path: roms\n root: userdata\n role: library\n label: ROM gyujtemeny\n" + " - path: roms-bios\n root: userdata\n role: library\n label: BIOS\n" + "deploy_fields:\n - env_var: HDD_PATH\n label: Tarhely\n type: path\n required: true\n", compose: "services:\n romm:\n image: busybox\n volumes:\n - ${USERDATA_PATH}/roms:/roms\n - ${USERDATA_PATH}/roms-bios:/bios\n", env: map[string]string{"HDD_PATH": lfDriveA}, }, // A media app with no data_paths, on a box with two drives. "plex": { meta: "display_name: Plex\nslug: plex\nmedia_library:\n read_only: true\n", compose: "services:\n plex:\n image: busybox\n volumes:\n - ${USERDATA_PATH}/media:/media:ro\n", env: map[string]string{"HDD_PATH": lfDriveA}, }, // (d) first visit — the default; its data is in a named volume, so no folder and no button. "immich": {meta: "display_name: Immich\nslug: immich\n", compose: "services:\n immich:\n image: busybox\n", env: map[string]string{}}, // (e) the two catalog keys. "rallly": {meta: "display_name: Rallly\nslug: rallly\napp_info:\n login_kind: email\n", compose: "services:\n rallly:\n image: busybox\n", env: map[string]string{}}, "bentopdf": {meta: "display_name: BentoPDF\nslug: bentopdf\napp_info:\n login_kind: none\n", compose: "services:\n bentopdf:\n image: busybox\n", env: map[string]string{}}, // (e) no login of its own, behind the family gate: the family sign-in is its login. "metube": {meta: "display_name: MeTube\nslug: metube\nfamily_gate: true\napp_info:\n login_kind: none\n", compose: "services:\n metube:\n image: busybox\n", env: map[string]string{}}, // FileBrowser: what EnsureBaseStack writes — a compose, a config, NO .felhom.yml and NO app.yaml. "filebrowser": {compose: "services:\n filebrowser:\n image: gtstef/filebrowser:1.5.6-stable\n"}, } // lfHarness builds the box. fbState is FileBrowser's R-513 state ("" / generated / operator); present lists the // drives that are mounted now. func lfHarness(t *testing.T, fbState string, present ...string) *Server { t.Helper() dir := t.TempDir() bin := filepath.Join(dir, "bin") if err := os.MkdirAll(bin, 0o755); err != nil { t.Fatal(err) } var ps strings.Builder for name := range lfApps { ps.WriteString(name + "\\tbusybox\\trunning\\tUp 2 hours\\t" + name + "\\n") } stub := "#!/bin/sh\nif [ \"$1\" = ps ]; then\n printf '" + ps.String() + "'\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") cfg.Stacks.Protected = []string{"traefik", "cloudflared", "felhom-controller", "filebrowser"} for name, a := range lfApps { d := filepath.Join(cfg.Paths.StacksDir, name) if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(d, "docker-compose.yml"), []byte(a.compose), 0o644); err != nil { t.Fatal(err) } if a.meta != "" { if err := os.WriteFile(filepath.Join(d, ".felhom.yml"), []byte(a.meta), 0o644); err != nil { t.Fatal(err) } } if a.env != nil { if err := stacks.SaveAppConfig(d, &stacks.AppConfig{Deployed: true, Env: a.env}, nil, nil); err != nil { t.Fatal(err) } } } sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } for _, sp := range []settings.StoragePath{ {Path: lfDriveA, Label: lfLabelA, IsDefault: true, Schedulable: true}, {Path: lfDriveB, Label: lfLabelB, Schedulable: true}, } { if err := sett.AddStoragePath(sp); err != nil { t.Fatal(err) } } key := []byte("0123456789abcdef0123456789abcdef") switch fbState { case settings.FileBrowserAdminGenerated: enc, err := crypto.Encrypt(key, lfFBPW) if err != nil { t.Fatal(err) } if err := sett.SetFileBrowserAdmin(fbState, enc, "2026-10-16T00:00:00Z"); err != nil { t.Fatal(err) } case settings.FileBrowserAdminOperator: if err := sett.SetFileBrowserAdmin(fbState, "", "2026-10-16T00:00:00Z"); err != nil { t.Fatal(err) } } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } mgr.SetMigrationDeps(sett, func() bool { return false }) // production wires the settings the same way mgr.SetEncryptionKey(key) _ = mgr.ScanStacks() s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test", encKey: key, presentDrivesFn: func() []string { return present }} s.loadTemplates() return s } func lfGet(t *testing.T, s *Server, path string) (int, string, string) { t.Helper() w := httptest.NewRecorder() s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) return w.Code, w.Body.String(), w.Header().Get("Location") } var lfAnswerRe = regexp.MustCompile(`data-login-answer="([a-z_]+)"`) var lfFilesRe = regexp.MustCompile(`href="([^"]+)"[^>]*data-app-files="1"`) // ── Part 1: FileBrowser is part of the box ───────────────────────────────────────────────────────────────────────── // RED-PROOF (2026-10-16, REPORT): with appPageHandler reading `installed := found.Deployed` again (the v0.315.0 // line), this failed at „FileBrowser offers Install" — the measured live defect on demo-hp. func TestAppPage_InfraAppHasNoInstall(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) code, html, _ := lfGet(t, s, "/apps/filebrowser") if code != http.StatusOK { t.Fatalf("GET /apps/filebrowser = %d", code) } if strings.Contains(html, "data-install-cta") || strings.Contains(html, "/apps/filebrowser/install") { t.Fatal("FileBrowser offers Install — an infrastructure app is part of the box") } if strings.Contains(html, `data-run-action="stop"`) || strings.Contains(html, `onclick="removeStack(`) { t.Fatal("FileBrowser offers Stop or Remove") } for _, want := range []string{`data-tab="settings"`, `data-tab="guide"`, `data-tab="logs"`, `data-run-action="restart"`, `href="https://files.example.hu"`, `data-login-answer="initial"`, `id="initcred-reveal"`, "initcred-user"} { if !strings.Contains(html, want) { t.Errorf("the FileBrowser page lacks %s", want) } } if strings.Contains(html, `data-tab="data"`) { t.Error("FileBrowser has a data tab (export / move / backup are not the household's for a part of the box)") } if strings.Contains(html, lfFBPW) { t.Fatal("R-254: FileBrowser's password is in the page") } // The install address and the data tab answer with the page; the deploy POST is refused (stacks + api tests). for _, p := range []string{"/apps/filebrowser/install", "/apps/filebrowser/data"} { if c, _, loc := lfGet(t, s, p); c != http.StatusFound || loc != "/apps/filebrowser" { t.Errorf("GET %s = %d → %q, want 302 → /apps/filebrowser", p, c, loc) } } // The guide says what it is. if _, g, _ := lfGet(t, s, "/apps/filebrowser/guide"); !strings.Contains(g, `data-infra-guide="1"`) || strings.Contains(g, `data-guide-missing`) { t.Error("the FileBrowser guide tab does not say what FileBrowser is") } // The `operator` state: a sentence and the new-password press, no reveal. op := lfHarness(t, settings.FileBrowserAdminOperator, lfDriveA) _, html, _ = lfGet(t, op, "/apps/filebrowser") if !strings.Contains(html, `data-fb-operator="1"`) || !strings.Contains(html, `data-fb-reset="1"`) { t.Fatal("operator state: no sentence or no new-password button") } if strings.Contains(html, `id="initcred-reveal"`) { t.Fatal("operator state offers a reveal for a password the box does not know") } // And the button is ONLY there in that state. if _, gen, _ := lfGet(t, s, "/apps/filebrowser"); strings.Contains(gen, `data-fb-reset`) { t.Fatal("generated state offers the new-password button") } } // ── Part 2: one login answer per installed app ───────────────────────────────────────────────────────────────────── // RED-PROOF (2026-10-16, REPORT): with loginAnswerFor's EffectiveLoginKind fallback returning "" (the v0.319.0 // behaviour: no panel for an app with no login fields), this failed at immich „0 answers". func TestAppPage_EveryInstalledAppHasOneLoginAnswer(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) for _, c := range []struct{ slug, answer, marker string }{ {"calibre-web", "fields", `data-login-reveal="ADMIN_PASSWORD"`}, // (a) box-made {"kimai", "fields", `data-login-not-stored="ADMIN_PASSWORD"`}, // (c) no stored value {"filebrowser", "initial", `id="initcred-reveal"`}, // (b) read / held for the app {"immich", "first_visit", `data-login-kind="first_visit"`}, // (d) default {"rallly", "email", `data-login-kind="email"`}, // (e) {"bentopdf", "none", `data-login-kind="none"`}, // (e) {"metube", "none", `data-login-kind="family"`}, // (e) behind the family gate } { code, html, _ := lfGet(t, s, "/apps/"+c.slug) if code != http.StatusOK { t.Fatalf("%s: GET = %d", c.slug, code) } got := lfAnswerRe.FindAllStringSubmatch(html, -1) if len(got) != 1 { t.Errorf("%s: %d login answers, want exactly 1", c.slug, len(got)) continue } if got[0][1] != c.answer { t.Errorf("%s: answer %q, want %q", c.slug, got[0][1], c.answer) } if !strings.Contains(html, c.marker) { t.Errorf("%s: the %s answer lacks %s", c.slug, c.answer, c.marker) } if strings.Contains(html, lfSecretPW) || strings.Contains(html, lfFBPW) { t.Fatalf("%s: a stored secret is in the page (R-254, R-709)", c.slug) } } } // ── Part 3: the „Fajlok" button and the media folders ────────────────────────────────────────────────────────────── // RED-PROOF (2026-10-16, REPORT): with filesButtonLink's first loop deleted (no library preference), this failed at // calibre-web — the button opened the import drop-zone, the first declared folder. func TestAppPage_FilesButtonTarget(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA, lfDriveB) const srcA = "https://files.example.hu/files/M%C3%A9dia%20&%20k%C3%B6nyvt%C3%A1r" for _, c := range []struct{ slug, want string }{ {"romm", srcA + "/roms"}, {"calibre-web", srcA + "/media/books"}, {"plex", srcA + "/media"}, // the first drive's media folder {"immich", ""}, } { _, html, _ := lfGet(t, s, "/apps/"+c.slug) m := lfFilesRe.FindStringSubmatch(html) switch { case c.want == "" && m != nil: t.Errorf("%s: a Files button (%s) for an app with no folder", c.slug, m[1]) case c.want != "" && m == nil: t.Errorf("%s: no Files button, want %s", c.slug, c.want) case c.want != "" && m[1] != c.want: t.Errorf("%s: Files → %s, want %s", c.slug, m[1], c.want) } if strings.Contains(html, "M%C3%A9dia+") || strings.Contains(html, "%26") { t.Errorf("%s: a link built with QueryEscape (+ for a space, %%26 for &)", c.slug) } } // The data tab lists each drive's media folders for Plex, and points at the file manager's login. _, data, _ := lfGet(t, s, "/apps/plex/data") for _, want := range []string{srcA + "/media/movies", srcA + "/media/tv", "https://files.example.hu/files/HDD%202/media/movies", "https://files.example.hu/files/HDD%202/media/tv", `data-fb-login-note="1"`, `href="/apps/filebrowser"`} { if !strings.Contains(data, want) { t.Errorf("plex data tab lacks %s", want) } } } // RED-PROOF (2026-10-16, REPORT): with `&& s.drivePresent(drive)` removed from the userdata branch, this failed at // „romm links a folder on a drive that is not on the box". func TestAppPage_UnknownSourceIsTextNotLink(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveB) // drive A (romm's, plex's first) is away _, html, _ := lfGet(t, s, "/apps/romm/data") if strings.Contains(html, "/roms") && strings.Contains(html, "files.example.hu/files/M%C3%A9dia") { t.Fatal("romm links a folder on a drive that is not on the box") } if !strings.Contains(html, `data-folder-unavailable="1"`) { t.Fatal("romm's folder is not shown as text when its source is absent") } if _, page, _ := lfGet(t, s, "/apps/romm"); lfFilesRe.MatchString(page) { t.Error("romm has a Files button although no folder of it is on the box") } // Plex: drive A text, drive B links; the button goes to the first PRESENT drive. _, plex, _ := lfGet(t, s, "/apps/plex/data") if !strings.Contains(plex, `data-folder-unavailable="1"`) || !strings.Contains(plex, "HDD%202/media/movies") { t.Error("plex: absent drive not text, or present drive not linked") } if strings.Contains(plex, "files.example.hu/files/M%C3%A9dia") { t.Error("plex links the absent drive") } if _, page, _ := lfGet(t, s, "/apps/plex"); !strings.Contains(page, `href="https://files.example.hu/files/HDD%202/media"`) { t.Error("plex Files button does not go to the first present drive") } } // The reset endpoint answers only for FileBrowser, and only in the `operator` state (no docker reached). func TestFileBrowserResetEndpoint_Refusals(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) post := func(path string) int { w := httptest.NewRecorder() s.appFileBrowserResetHandler(w, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil)), strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reset")) return w.Code } if c := post("/apps/romm/initial-credentials/reset"); c != http.StatusNotFound { t.Errorf("another app's reset = %d, want 404", c) } if c := post("/apps/filebrowser/initial-credentials/reset"); c != http.StatusConflict { t.Errorf("reset in the generated state = %d, want 409", c) } } // R-1101 (v0.321.0): FileBrowser refuses the stored password → a sentence and the new-password press, no eye, and no // „change it after the first login" line (which is how it went stale). // RED-PROOF (2026-10-16, REPORT): with the `s.fbStoredPasswordRefused()` branch removed from appCommonData, this failed // at „stale: the page still offers the reveal". func TestFileBrowserLogin_StoredPasswordRefusedOffersReset(t *testing.T) { s := lfHarness(t, settings.FileBrowserAdminGenerated, lfDriveA) s.fbStoredRefused = func() bool { return true } _, html, _ := lfGet(t, s, "/apps/filebrowser") if strings.Contains(html, `id="initcred-reveal"`) { t.Fatal("stale: the page still offers the reveal of a password FileBrowser refuses") } if !strings.Contains(html, `data-fb-stale="1"`) || !strings.Contains(html, `data-fb-reset="1"`) { t.Fatal("stale: no sentence or no new-password button") } s.fbStoredRefused = func() bool { return false } _, ok, _ := lfGet(t, s, "/apps/filebrowser") if !strings.Contains(ok, `id="initcred-reveal"`) || strings.Contains(ok, `data-fb-stale`) || strings.Contains(ok, `data-fb-reset`) { t.Fatal("a stored password FileBrowser accepts must show the reveal and no reset") } // Positive control first: the line exists in the bundle; then: it is not on the FileBrowser page. if !strings.Contains(s.msgLang("hu", "app_info.az_elso_bejelentkezes_utan_azonnal"), "hagyd figyelmen") { t.Fatal("control: the change-it line no longer carries the fragment this test looks for") } if strings.Contains(ok, "hagyd figyelmen") { t.Fatal("FileBrowser still says to change the password after the first login (R-1101)") } } // ── v0.321.0 — the Data tab's backup part (Part 4) and the install address (R-1095) ──────────────────────────────── var lfOffDriveOrPBS = regexp.MustCompile(`(?i)off-drive|\bPBS\b`) // The Data tab has ONE „Biztonsagi mentes" section, with no „off-drive" and no „PBS" in either language. // RED-PROOF (2026-10-16, REPORT): with the old `tier2_config.a_2_mentes_egy_masik` text put back in hu.json, this // failed at „hu: off-drive/PBS on the Data tab"; with the second