7e791d9b5d
gates / gates (push) Successful in 43s
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
443 lines
21 KiB
Go
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, "👁") || 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")
|
|
}
|
|
}
|