Files
felhom-controller/controller/internal/web/filebrowser_network_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

163 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"log"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// R-67 — the NAS share appears in FileBrowser. Scenarios AD drive buildFileBrowserPaths with
// every edge seamed (no drive, no NAS, no docker anywhere near these tests).
func fbTestDrive() settings.StoragePath {
return settings.StoragePath{Path: "/mnt/felhom-drives/hdd_1", Label: "Külső HDD", Schedulable: true}
}
func fbTestShare() settings.StoragePath {
return settings.StoragePath{
Path: "/mnt/felhom-drives/Felhom-Share", Label: "Felhom Share", Schedulable: true,
Kind: settings.StorageKindNetwork, Protocol: "smb", Server: "192.168.0.104", Export: "Felhom-Share",
}
}
// fbDeps returns deps where the drive is a live mountpoint, the classifier is scripted, and the
// skeleton fake RECORDS every invocation — the A red-proof hangs off that recording.
func fbDeps(classifyResult string, skeletonCalls *[]string, logBuf *bytes.Buffer) fbPathDeps {
var lg *log.Logger
if logBuf != nil {
lg = log.New(logBuf, "", 0)
}
return fbPathDeps{
isMount: func(string) bool { return true },
classify: func(string) string { return classifyResult },
ensureSkeleton: func(path string) error {
*skeletonCalls = append(*skeletonCalls, path)
return nil
},
logger: lg,
}
}
// Scenario A: one drive + one network share (healthy verdict) → the drive's userdata line is
// BYTE-IDENTICAL to a drives-only pass (the B invariant), the share binds its ROOT with :rslave,
// the config carries both sources, and the skeleton ran for the drive ONLY — never toward the NAS.
func TestFileBrowserNetworkShareIncluded(t *testing.T) {
drive, share := fbTestDrive(), fbTestShare()
var calls []string
mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassNetwork, &calls, nil))
var onlyCalls []string
onlyMounts, _ := buildFileBrowserPaths([]settings.StoragePath{drive}, fbDeps(system.FSClassNetwork, &onlyCalls, nil))
// B: the drive line with the share present is byte-identical to the drives-only render.
if len(onlyMounts) != 1 || len(mounts) != 2 {
t.Fatalf("mounts: got %v / drives-only %v", mounts, onlyMounts)
}
if mounts[0] != onlyMounts[0] {
t.Errorf("drive line changed by the network branch:\n with: %q\n only: %q", mounts[0], onlyMounts[0])
}
wantDrive := " - " + appbackup.UserdataDir(drive.Path) + ":/srv/hdd_1"
if mounts[0] != wantDrive {
t.Errorf("drive line = %q, want %q", mounts[0], wantDrive)
}
// The share binds its ROOT (not a userdata subtree) with :rslave — the propagation flag is
// load-bearing (idle-wake events must reach the running container).
wantShare := " - /mnt/felhom-drives/Felhom-Share:/srv/Felhom-Share:rslave"
if mounts[1] != wantShare {
t.Errorf("share line = %q, want %q", mounts[1], wantShare)
}
// Skeleton: exactly the drive, NEVER the NAS. (Red-proof: routing network paths through the
// drive branch makes this fail with the share path recorded.)
if len(calls) != 1 || calls[0] != drive.Path {
t.Errorf("skeleton calls = %v, want exactly [%s] — a skeleton toward the NAS writes Felhom convention dirs onto the customer's own NAS", calls, drive.Path)
}
// Config has both sources, share named by its display label.
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
for _, m := range []string{`- path: "/srv/hdd_1"`, `- path: "/srv/Felhom-Share"`, `name: "Felhom Share"`} {
if !strings.Contains(cfg, m) {
t.Errorf("config missing %q:\n%s", m, cfg)
}
}
// And the full compose render carries both lines (the renderer passes propagation through).
compose := infra.RenderFileBrowserCompose("example.hu", mounts)
if !strings.Contains(compose, wantShare) || !strings.Contains(compose, wantDrive) {
t.Errorf("compose lost a mount line:\n%s", compose)
}
}
// Scenario B: stub verdict → the share is excluded from BOTH mounts and sources this pass, the
// drive is untouched, and the warn is logged. The wrong case this gate kills: a customer uploads
// into a local stub dir that the real mount later shadows — their files silently vanish from view.
// Red-proof: dropping the stub gate fails the absent-line assertions.
func TestFileBrowserNetworkStubExcluded(t *testing.T) {
drive, share := fbTestDrive(), fbTestShare()
var calls []string
var logBuf bytes.Buffer
mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassStub, &calls, &logBuf))
if len(mounts) != 1 || strings.Contains(mounts[0], "Felhom-Share") {
t.Errorf("stub share leaked into mounts: %v", mounts)
}
cfg := infra.RenderFileBrowserConfig(cfgPaths, false)
if strings.Contains(cfg, "Felhom-Share") {
t.Errorf("stub share leaked into the source list:\n%s", cfg)
}
if !strings.Contains(cfg, `- path: "/srv/hdd_1"`) {
t.Errorf("drive source lost while excluding the stub share:\n%s", cfg)
}
if !strings.Contains(logBuf.String(), "local stub") {
t.Errorf("stub exclusion not logged: %q", logBuf.String())
}
}
// Scenario C: the autofs-trigger verdict (idle automount) INCLUDES the share — idle is healthy;
// the wrong case here is treating idle like a detached drive (the pre-R-67 gate skipped an idle
// share forever). unknown also includes (fail open), as does a nil classifier.
func TestFileBrowserNetworkIdleIncluded(t *testing.T) {
share := fbTestShare()
for _, verdict := range []string{system.FSClassAutofs, system.FSClassNetwork, system.FSClassUnknown} {
var calls []string
mounts, cfgPaths := buildFileBrowserPaths([]settings.StoragePath{share}, fbDeps(verdict, &calls, nil))
if len(mounts) != 1 || !strings.Contains(mounts[0], ":rslave") {
t.Errorf("verdict %q: share not mounted: %v", verdict, mounts)
}
if len(cfgPaths) != 1 {
t.Errorf("verdict %q: share not in sources", verdict)
}
}
// nil classifier (no seam wired) must fail OPEN — never hide the share.
var calls []string
d := fbDeps("", &calls, nil)
d.classify = nil
if mounts, _ := buildFileBrowserPaths([]settings.StoragePath{share}, d); len(mounts) != 1 {
t.Errorf("nil classifier hid the share: %v", mounts)
}
}
// Scenario D: removal — a registry without the share renders with no trace of it, and the F2
// change detection sees the difference (forces the recreate that drops the live mount).
func TestFileBrowserNetworkRemoval(t *testing.T) {
drive, share := fbTestDrive(), fbTestShare()
var calls []string
withMounts, withCfg := buildFileBrowserPaths([]settings.StoragePath{drive, share}, fbDeps(system.FSClassNetwork, &calls, nil))
withoutMounts, withoutCfg := buildFileBrowserPaths([]settings.StoragePath{drive}, fbDeps(system.FSClassNetwork, &calls, nil))
oldCompose := infra.RenderFileBrowserCompose("example.hu", withMounts)
newCompose := infra.RenderFileBrowserCompose("example.hu", withoutMounts)
oldCfg := infra.RenderFileBrowserConfig(withCfg, false)
newCfg := infra.RenderFileBrowserConfig(withoutCfg, false)
if strings.Contains(newCompose, "Felhom-Share") || strings.Contains(newCfg, "Felhom-Share") {
t.Error("removed share left a trace in the renders")
}
if !fbNeedsRecreate([]byte(oldCfg), []byte(newCfg), []byte(oldCompose), []byte(newCompose)) {
t.Error("F2 change detection missed the share removal — the live mount would linger")
}
}