2958946517
${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.
163 lines
7.3 KiB
Go
163 lines
7.3 KiB
Go
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 A–D 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")
|
||
}
|
||
}
|