Files
felhom-controller/controller/internal/web/logins_folders_test.go
T
admin f30a80841e
gates / gates (push) Successful in 50s
v0.320.0: FileBrowser is part of the box again; every app says where its login is; a „Fájlok" button
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the
app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login.
A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every
other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab.
The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now
refuse a protected stack.

In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel
offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp:
new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated.

Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the
login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind
(first_visit default, email, none; none behind the family gate says the family sign-in is the login).

"Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present
drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound
into FileBrowser is text, never a link.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 15:19:14 +02:00

355 lines
17 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)
}
}