123 lines
5.5 KiB
Go
123 lines
5.5 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// T4: skipFileBrowserPath skips ONLY an external drive path (under StableParentDir) that is not a live
|
|
// mountpoint. A mounted external path and any system/local path are never skipped.
|
|
// Companion red-proof: dropping the gate (always-false) makes the absent-usb case fail.
|
|
func TestSkipFileBrowserPath(t *testing.T) {
|
|
// flash is mounted; usb is not.
|
|
isMount := func(p string) bool { return p == StableParentDir+"/flash" }
|
|
|
|
cases := []struct {
|
|
path string
|
|
want bool
|
|
}{
|
|
{StableParentDir + "/flash", false}, // external + mounted → keep
|
|
{StableParentDir + "/usb", true}, // external + NOT mounted → skip
|
|
{"/mnt/sys_drive/felhom-data", false}, // system path (not under StableParentDir) → never skip
|
|
}
|
|
for _, c := range cases {
|
|
if got := skipFileBrowserPath(c.path, isMount); got != c.want {
|
|
t.Errorf("skipFileBrowserPath(%q) = %v, want %v", c.path, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// F2: fbNeedsRecreate gates the FileBrowser --force-recreate so a no-op controller restart / storage
|
|
// sync no longer bounces the customer's file UI. Recreate only when config OR compose actually changed;
|
|
// the first-ever run (empty old files) differs from the generated content → recreate (creates it).
|
|
// Companion red-proof: hard-wiring the helper to always return true (the OLD unconditional behaviour)
|
|
// makes the "unchanged → no recreate" case fail — restoring the byte-equality gate turns it green.
|
|
func TestFbNeedsRecreate(t *testing.T) {
|
|
cfg := []byte("sources:\n - /srv/usb\n")
|
|
compose := []byte("services:\n filebrowser:\n image: x\n")
|
|
|
|
cases := []struct {
|
|
name string
|
|
oldCfg, newCfg, oldCmp, newCmp []byte
|
|
want bool
|
|
}{
|
|
{"unchanged → no recreate", cfg, cfg, compose, compose, false},
|
|
{"config differs → recreate", cfg, []byte("sources:\n - /srv/hdd\n"), compose, compose, true},
|
|
{"compose differs → recreate", cfg, cfg, compose, []byte("services:\n filebrowser:\n image: y\n"), true},
|
|
{"first run (no old files) → recreate", nil, cfg, nil, compose, true},
|
|
}
|
|
for _, c := range cases {
|
|
if got := fbNeedsRecreate(c.oldCfg, c.newCfg, c.oldCmp, c.newCmp); got != c.want {
|
|
t.Errorf("%s: fbNeedsRecreate = %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// R-1339: a per-app folder under a MOUNTED drive (/mnt/felhom-drives/x/userdata/<app>) is never a mountpoint itself. It is
|
|
// shown through the drive's own bind, so it still gets no bind and no skeleton of its own (a skeleton there would write
|
|
// userdata/ inside the app's folder), and the log no longer says its drive is „not mounted". A drive that really is
|
|
// absent keeps its line, and a folder of such a drive names the absent drive.
|
|
//
|
|
// RED-PROOF (2026-11-02): before the fix the sub-folder logged „drive …/userdata/nextcloud not mounted" (nine lines at every
|
|
// start on 9202, scratch_hdd a live mountpoint) and this fails on that line.
|
|
func TestR1339_AFolderOfAMountedDriveIsNotReportedAsUnmounted(t *testing.T) {
|
|
sub := StableParentDir + "/scratch_hdd/userdata/nextcloud"
|
|
mounted := StableParentDir + "/scratch_hdd"
|
|
absent := StableParentDir + "/usb"
|
|
absentSub := absent + "/userdata/romm"
|
|
isMount := func(p string) bool { return p == mounted }
|
|
var buf bytes.Buffer
|
|
var skeletons []string
|
|
mounts, cfg := buildFileBrowserPaths([]settings.StoragePath{{Path: sub}, {Path: mounted}, {Path: absent}, {Path: absentSub}}, fbPathDeps{
|
|
isMount: isMount,
|
|
ensureSkeleton: func(p string) error { skeletons = append(skeletons, p); return nil },
|
|
logger: log.New(&buf, "", 0),
|
|
})
|
|
logged := buf.String()
|
|
if strings.Contains(logged, sub) {
|
|
t.Errorf("a folder of a mounted drive is reported: %q", logged)
|
|
}
|
|
if !strings.Contains(logged, "drive "+absent+" not mounted") {
|
|
t.Errorf("a drive that is really absent keeps its line: %q", logged)
|
|
}
|
|
if !strings.Contains(logged, "drive "+absent+" not mounted") || strings.Count(logged, "not mounted") != 2 || !strings.Contains(logged, absentSub) {
|
|
t.Errorf("a folder of an absent drive is reported, naming that drive: %q", logged)
|
|
}
|
|
// The behaviour stays: only the mounted drive itself gets a skeleton and a bind.
|
|
if len(skeletons) != 1 || skeletons[0] != mounted {
|
|
t.Errorf("skeletons %v — only the mounted drive gets one", skeletons)
|
|
}
|
|
if len(mounts) != 1 || !strings.Contains(mounts[0], ":/srv/scratch_hdd") {
|
|
t.Errorf("mounts %v — only the mounted drive's userdata is bound", mounts)
|
|
}
|
|
if len(cfg) != 4 {
|
|
t.Errorf("the source list keeps every registered path: %v", cfg)
|
|
}
|
|
}
|
|
|
|
// R-1351: the web sync's compose call runs inside the stack lock it shares with the base stack's bring-up. The lock's
|
|
// behaviour is TestR1351_TheTwoFileBrowserStartPathsNeverOverlap (internal/stacks); this pins the web side's use of it.
|
|
func TestR1351_TheWebSyncTakesTheLockBeforeCompose(t *testing.T) {
|
|
b, err := os.ReadFile("handlers.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := string(b)
|
|
start := strings.Index(src, "func (s *Server) syncFileBrowserMounts(")
|
|
if start < 0 {
|
|
t.Fatal("syncFileBrowserMounts not found")
|
|
}
|
|
body := src[start:]
|
|
lock := strings.Index(body, "s.stackMgr.LockFileBrowserStack()")
|
|
down := strings.Index(body, `"docker", "compose", "down", "-v"`)
|
|
up := strings.Index(body, `[]string{"compose", "up", "-d", "--remove-orphans"}`)
|
|
if lock < 0 || down < 0 || up < 0 || lock > down || lock > up {
|
|
t.Fatalf("the lock must come before both compose calls (lock %d, down %d, up %d)", lock, down, up)
|
|
}
|
|
}
|