Files
felhom-controller/controller/internal/web/filebrowser_link_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).

Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.

Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.

Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.

One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.

Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.

Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.

Tests 915 -> 949, all green. MinAgent unchanged.
2026-07-26 08:12:57 +02:00

96 lines
4.0 KiB
Go

package web
import (
"html/template"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
)
// The deep-link template, pinned against the shipped FileBrowser Quantum router (SPIKE P2).
func TestFileBrowserLink_Template(t *testing.T) {
const dom = "demo-felhom.eu"
for _, tc := range []struct{ name, source, rel, want string }{
{"ascii source, one segment", "hdd_1", "media/movies",
"https://files.demo-felhom.eu/files/hdd_1/media/movies"},
{"accented source (the import label)", infra.FileBrowserImportLabel, "paperless",
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s/paperless"},
{"source root, empty relpath", infra.FileBrowserImportLabel, "",
"https://files.demo-felhom.eu/files/Beolvas%C3%A1s"},
{"leading/trailing slashes are ignored", "hdd_1", "/media/books/",
"https://files.demo-felhom.eu/files/hdd_1/media/books"},
{"accented path segment", "hdd_1", "media/könyvek",
"https://files.demo-felhom.eu/files/hdd_1/media/k%C3%B6nyvek"},
} {
if got := fileBrowserLink(dom, tc.source, tc.rel); got != tc.want {
t.Errorf("%s:\n got %q\n want %q", tc.name, got, tc.want)
}
}
}
// THE trap, measured in SPIKE P2. QueryEscape turns a space into "+", which in a path segment is a
// literal plus and lands the customer on a folder that does not exist. This test fails if anyone
// swaps the escaper.
func TestFileBrowserLink_UsesPathEscapeNotQueryEscape(t *testing.T) {
const spaced = "Média & könyvtár"
got := fileBrowserLink("x.eu", spaced, "a b")
if strings.Contains(got, "+") {
t.Errorf("link contains '+' — QueryEscape was used somewhere; a '+' in a path segment is a literal plus, not a space: %q", got)
}
if want := url.PathEscape(spaced); !strings.Contains(got, want) {
t.Errorf("source not PathEscape'd: got %q, want it to contain %q", got, want)
}
// Guard the exact divergence the spike measured, so the two escapers can never be confused here.
if url.PathEscape(spaced) == url.QueryEscape(spaced) {
t.Fatal("fixture no longer distinguishes the two escapers — pick a name where they differ")
}
if strings.Contains(got, url.QueryEscape(spaced)) {
t.Errorf("link used QueryEscape: %q", got)
}
}
// The link is embedded in HTML. Percent-encoding and attribute-escaping must COMPOSE: PathEscape
// leaves "&" bare, html/template turns it into "&amp;", and the browser decodes it back to "&".
// Anything that double-encodes (a hand-rolled HTML escape before the template) breaks the path.
func TestFileBrowserLink_ComposesWithHTMLEscaping(t *testing.T) {
link := fileBrowserLink("x.eu", "Média & könyvtár", "docs")
var sb strings.Builder
tmpl := template.Must(template.New("a").Parse(`<a href="{{.}}">x</a>`))
if err := tmpl.Execute(&sb, link); err != nil {
t.Fatal(err)
}
out := sb.String()
if !strings.Contains(out, "&amp;") {
t.Errorf("html/template should escape the bare & in the href: %q", out)
}
// It must NOT have been percent-encoded a second time (%2526 etc. would be double-encoding).
if strings.Contains(out, "%25") {
t.Errorf("double percent-encoding detected — do not pre-escape before the template: %q", out)
}
// And no raw quote/angle escaped into the attribute.
if strings.Contains(out, `href=""`) {
t.Errorf("html/template refused the URL (would render an empty href): %q", out)
}
}
// importFolderLink targets the CANONICAL source, so a drop-zone link is identical regardless of
// which drive the app itself sits on.
func TestImportFolderLink_IsCanonical(t *testing.T) {
a := importFolderLink("demo-felhom.eu", "paperless")
b := importFolderLink("demo-felhom.eu", "calibre")
for _, l := range []string{a, b} {
if !strings.Contains(l, "/files/"+url.PathEscape(infra.FileBrowserImportLabel)+"/") {
t.Errorf("import link must go through the canonical source: %q", l)
}
if strings.Contains(l, "hdd_1") || strings.Contains(l, "felhom-drives") {
t.Errorf("import link must never name a data drive: %q", l)
}
}
if a == b {
t.Error("different apps must get different folders")
}
}