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") } }