Files
felhom-controller/controller/internal/web/logins_folders_test.go
T
admin 7e791d9b5d
gates / gates (push) Successful in 43s
v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:17:26 +02:00

443 lines
21 KiB
Go

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 <div class="app-section"> put back, at „2 sections".
func TestAppData_OneBackupSection(t *testing.T) {
if !lfOffDriveOrPBS.MatchString("a teljes rendszermentés/PBS — off-drive") {
t.Fatal("control: the matcher does not find the words it looks for")
}
s := i18nTestServer(t)
var c i18nCase
for _, x := range i18nCases() {
if x.name == "app_data_deployed" {
c = x
}
}
if c.name == "" {
t.Fatal("control: the parity case app_data_deployed (a data tab with the 2nd copy) is gone")
}
for _, lang := range []string{"hu", "en"} {
html := renderI18nCase(t, s, lang, c)
if n := strings.Count(html, `data-backup-section="1"`); n != 1 {
t.Errorf("%s: %d backup sections, want 1", lang, n)
}
if !strings.Contains(html, `data-backup-row="nightly"`) || !strings.Contains(html, `data-backup-row="second"`) ||
!strings.Contains(html, `data-second-backup-form="1"`) {
t.Errorf("%s: the nightly row, the 2nd-copy row or its form is missing", lang)
}
if m := lfOffDriveOrPBS.FindString(html); m != "" {
t.Errorf("%s: off-drive/PBS on the Data tab (%q)", lang, m)
}
if strings.Contains(html, "&#128065;") || strings.Contains(html, "\U0001F441") {
t.Errorf("%s: the export password eye is still an emoji", lang)
}
if !strings.Contains(html, `id="exportPwToggle" aria-label=`) {
t.Errorf("%s: the export password toggle has no aria-label", lang)
}
}
}
// R-1095: an INSTALLED app's /install answers 302 to its settings tab (GET only); a not-installed app keeps its form.
// RED-PROOF (2026-10-16, REPORT): with the Deployed redirect removed from appInstallPageHandler, this failed at
// „installed: 200".
func TestInstallAddress_InstalledRedirectsToSettings(t *testing.T) {
s := tuningHarness(t, tuningEnv())
if c, _, loc := lfGet(t, s, "/apps/calibre-web/install"); c != http.StatusFound || loc != "/apps/calibre-web/settings" {
t.Fatalf("installed: %d → %q, want 302 → /apps/calibre-web/settings", c, loc)
}
if c, html, _ := lfGet(t, s, "/apps/calibre-new/install"); c != http.StatusOK || !strings.Contains(html, "deploy-form") {
t.Fatalf("not installed: %d, the install form must still render", c)
}
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/apps/calibre-web/install", nil))
if w.Code == http.StatusFound && w.Header().Get("Location") == "/apps/calibre-web/settings" {
t.Fatal("a POST to /install was redirected — the redirect is for reads only")
}
}