package web import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // R-108 — network storage may not host an app's data namespace. // // WHY, in one line: an app's namespace root is also its backup root, so a NAS-hosted app would put its // recovery unit at `/backups/primary//` — inside the share-ROOT bind FileBrowser serves // with download:true. That bind CANNOT be narrowed (R-67: `:rslave` at the share root is load-bearing // for automount wake, and apps on a share store at `/` so there is no `userdata/` layer to // scope to), therefore the backup tree must never be placed under it. Operator ruling 2026-07-30: // REFUSE the placement, KEEP the browse bind. // // These tests assert the NON-EFFECT. A handler returning an error proves nothing on its own — what // matters is that nothing was written, so each refusal test inspects the resulting state. // netShare / localDrive are the two storage classes, shaped as the live demo-hp box really has them: // BOTH under /mnt/felhom-drives (which is why a path prefix cannot classify — Kind is the only // discriminator, and that is the trap RefuseAsAppNamespace exists to handle). // // PROVENANCE: captured from demo-hp guest 9201 on 2026-07-30 — // // /mnt/felhom-drives/Felhom-Share (Kind=network, the NAS; holds the customer's own files) // /mnt/felhom-drives/nvme-1tb (Kind=drive, the enrolled data drive; paperless-ngx lives here) func netShare() settings.StoragePath { return settings.StoragePath{ Path: settings.NetworkMountRoot + "/Felhom-Share", Label: "Felhom-Share", Kind: settings.StorageKindNetwork, Schedulable: true, } } func localDrive() settings.StoragePath { return settings.StoragePath{ Path: settings.NetworkMountRoot + "/nvme-1tb", Label: "NVMe 1TB", Kind: settings.StorageKindDrive, Schedulable: true, IsDefault: true, } } // --------------------------------------------------------------------------------------------- // 1. The predicate itself, including the fail-closed cases. // --------------------------------------------------------------------------------------------- func TestRefuseAsAppNamespace_Table(t *testing.T) { s := testServer(t) if err := s.settings.AddStoragePath(localDrive()); err != nil { t.Fatal(err) } if err := s.settings.AddStoragePath(netShare()); err != nil { t.Fatal(err) } cases := []struct { name string path string refuse bool }{ {"registered local drive is allowed", settings.NetworkMountRoot + "/nvme-1tb", false}, {"a subpath of a local drive is allowed", settings.NetworkMountRoot + "/nvme-1tb/appdata", false}, {"registered NAS share is REFUSED", settings.NetworkMountRoot + "/Felhom-Share", true}, {"a subpath of the NAS share is REFUSED", settings.NetworkMountRoot + "/Felhom-Share/media", true}, {"empty means SSD-resident — allowed", "", false}, {"whitespace-only is treated as empty", " ", false}, // FAIL CLOSED: unregistered under the shared mount root is un-classifiable. Both kinds live // there, so nothing can decide it — and the deploy POST accepts a caller-supplied path whose // only other validation is os.Stat existence. {"UNREGISTERED under the mount root is REFUSED (cannot tell)", settings.NetworkMountRoot + "/mystery", true}, {"the mount root itself is REFUSED", settings.NetworkMountRoot, true}, // Outside the mount root nothing can be a NAS by construction (a share is always registered as // NetworkMountRoot + "/" + name), so the pre-existing behaviour stands. {"a path outside the mount root is unchanged", "/mnt/sys_drive/felhom-data", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { refuse, why := s.settings.RefuseAsAppNamespace(c.path) if refuse != c.refuse { t.Errorf("RefuseAsAppNamespace(%q) = %v, want %v (reason %q)", c.path, refuse, c.refuse, why) } if refuse && strings.TrimSpace(why) == "" { t.Errorf("a refusal must carry a reason for the customer; got empty for %q", c.path) } if !refuse && why != "" { t.Errorf("an allowed path must carry no reason; got %q for %q", why, c.path) } }) } } // TestRefuseAsAppNamespace_NilSettingsFailsClosed: with no registry to consult we cannot tell, so we // refuse. (Reached only in a degraded/setup state; the guard is what makes "cannot tell" unrepresentable // as "allowed".) func TestRefuseAsAppNamespace_NilSettingsFailsClosed(t *testing.T) { var s *settings.Settings if refuse, _ := s.RefuseAsAppNamespace("/mnt/felhom-drives/anything"); !refuse { t.Error("nil settings must REFUSE — an unconsultable registry is not an allow") } // ...but an empty path is still allowed: there is no external namespace to place at all. if refuse, _ := s.RefuseAsAppNamespace(""); refuse { t.Error("an empty HDD_PATH is SSD-resident and must stay allowed even with nil settings") } } // --------------------------------------------------------------------------------------------- // 2. The refusals, asserted by NON-EFFECT on real handlers. // --------------------------------------------------------------------------------------------- // migrateAppServer builds a Server whose stackMgr is deliberately NIL. That is the non-effect assertion // made structural: if the refusal does not fire, the handler reaches s.stackMgr.MigrateApp and the test // PANICS instead of quietly passing. A nil-pointer panic is a louder proof than any recorded call count. func migrateAppServer(t *testing.T) *Server { t.Helper() s := testServer(t) if err := s.settings.AddStoragePath(localDrive()); err != nil { t.Fatal(err) } if err := s.settings.AddStoragePath(netShare()); err != nil { t.Fatal(err) } s.stackMgr = nil return s } func postJSON(t *testing.T, h func(http.ResponseWriter, *http.Request), body any) *httptest.ResponseRecorder { t.Helper() b, err := json.Marshal(body) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() h(rec, httptest.NewRequest(http.MethodPost, "/api/storage/x", bytes.NewReader(b))) return rec } // TestMigrateApp_RefusesNetworkTarget_AndStartsNothing: the per-app migrate endpoint refuses a NAS // target. The whole-namespace sibling has always refused (storage_handlers.go handleStorageMigrate); // this path never followed, which is the asymmetry R-108 was filed on. func TestMigrateApp_RefusesNetworkTarget_AndStartsNothing(t *testing.T) { s := migrateAppServer(t) rec := postJSON(t, s.handleStorageMigrateApp, map[string]string{ "app": "immich", "target": settings.NetworkMountRoot + "/Felhom-Share", }) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } // NON-EFFECT: no job id came back. A started migration always returns one. var resp struct { OK bool `json:"ok"` Error string `json:"error"` Data map[string]any `json:"data"` } _ = json.Unmarshal(rec.Body.Bytes(), &resp) if resp.OK { t.Error("refusal reported ok:true") } if _, started := resp.Data["started"]; started { t.Errorf("a refused migration reported a started job: %v", resp.Data) } if _, hasID := resp.Data["id"]; hasID { t.Errorf("a refused migration handed back a job id: %v", resp.Data) } if !strings.Contains(resp.Error, "NAS") { t.Errorf("refusal must name the storage class for the customer; got %q", resp.Error) } // And the registry is untouched — the target did not become the app's namespace. for _, sp := range s.settings.GetStoragePaths() { if sp.MigratedTo != "" { t.Errorf("a refused migration wrote MigratedTo=%q on %s", sp.MigratedTo, sp.Path) } } } // TestMigrateApp_AllowsLocalDriveTarget proves the refusal is not over-broad. stackMgr is nil, so // reaching MigrateApp panics — which is exactly what must happen: it shows the guard let the call // through. Recovered so the assertion is explicit rather than a red test. func TestMigrateApp_AllowsLocalDriveTarget(t *testing.T) { s := migrateAppServer(t) reached := false func() { defer func() { if recover() != nil { reached = true // got past the guard, into the nil stackMgr } }() _ = postJSON(t, s.handleStorageMigrateApp, map[string]string{ "app": "immich", "target": settings.NetworkMountRoot + "/nvme-1tb", }) }() if !reached { t.Error("a LOCAL drive target was refused — the R-108 guard is over-broad and blocks the supported case") } } // TestDecommissionMigrate_RefusesNetworkTarget_AndDecommissionsNothing covers the surface the R-108 row // does NOT name (§3.2). handleStorageDecommission guards `where` (the SOURCE) via refuseNetworkLifecycle; // the migrate TARGET was unchecked, so decommission-with-migrate could move a whole namespace onto a NAS. func TestDecommissionMigrate_RefusesNetworkTarget_AndDecommissionsNothing(t *testing.T) { s := migrateAppServer(t) rec := postJSON(t, s.handleStorageDecommission, map[string]string{ "where": settings.NetworkMountRoot + "/nvme-1tb", // a real local drive: passes the SOURCE guard "mode": "migrate", "target": settings.NetworkMountRoot + "/Felhom-Share", // the NAS: must be refused }) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } var resp struct { OK bool `json:"ok"` Data map[string]any `json:"data"` } _ = json.Unmarshal(rec.Body.Bytes(), &resp) if resp.OK { t.Error("refusal reported ok:true") } if _, started := resp.Data["started"]; started { t.Errorf("a refused decommission-migrate started a job: %v", resp.Data) } // NON-EFFECT, the one that matters here: the SOURCE must not be marked decommissioned. if s.settings.IsDecommissioned(settings.NetworkMountRoot + "/nvme-1tb") { t.Error("a refused decommission-migrate soft-marked the source — the drive is now unusable") } for _, sp := range s.settings.GetStoragePaths() { if sp.MigratedTo != "" { t.Errorf("a refused decommission-migrate wrote MigratedTo=%q", sp.MigratedTo) } } } // TestDecommissionMigrate_RefusesUnclassifiableTarget is the FAIL-CLOSED case on a real handler: an // unregistered path under the shared mount root cannot be classified, so it is refused rather than // assumed to be a drive. func TestDecommissionMigrate_RefusesUnclassifiableTarget(t *testing.T) { s := migrateAppServer(t) rec := postJSON(t, s.handleStorageDecommission, map[string]string{ "where": settings.NetworkMountRoot + "/nvme-1tb", "mode": "migrate", "target": settings.NetworkMountRoot + "/not-registered", }) if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400 — an unclassifiable target must fail CLOSED", rec.Code) } if s.settings.IsDecommissioned(settings.NetworkMountRoot + "/nvme-1tb") { t.Error("source soft-marked despite the refusal") } } // --------------------------------------------------------------------------------------------- // 3. The R-67 browse bind is UNCHANGED — the capability this ruling deliberately preserves. // --------------------------------------------------------------------------------------------- // TestFileBrowserBind_ShareRootPreserved_DriveStillScoped pins BOTH shapes at once, because the ruling // is precisely that they stay different: the share keeps its ROOT `:rslave` bind (load-bearing for // automount wake — R-67) and the drive keeps its `userdata` scoping. // // SEAM (R-125): this injects at `fbPathDeps` — the isMount/classify/ensureSkeleton funcs — and runs the // real buildFileBrowserPaths. NOT injected: the bind-string construction itself, which is what the // assertion is about. What this does NOT cover is RenderFileBrowserConfig and the compose template // downstream; TestFileBrowserCompose_* below closes that span. func TestFileBrowserBind_ShareRootPreserved_DriveStillScoped(t *testing.T) { var calls []string mounts, cfgPaths := buildFileBrowserPaths( []settings.StoragePath{localDrive(), netShare()}, fbDeps(system.FSClassNetwork, &calls, nil), ) joined := strings.Join(mounts, "\n") wantShare := " - " + settings.NetworkMountRoot + "/Felhom-Share:/srv/Felhom-Share:rslave" if !strings.Contains(joined, wantShare) { t.Errorf("the R-67 share-ROOT :rslave bind is GONE — automount wake no longer propagates.\nwant %q\ngot:\n%s", wantShare, joined) } wantDrive := " - " + settings.NetworkMountRoot + "/nvme-1tb/userdata:/srv/nvme-1tb" if !strings.Contains(joined, wantDrive) { t.Errorf("the local drive lost its userdata scoping.\nwant %q\ngot:\n%s", wantDrive, joined) } // The drive must NOT be bound at its root (that would be the R-108 exposure on a local drive). if strings.Contains(joined, "- "+settings.NetworkMountRoot+"/nvme-1tb:/srv") { t.Errorf("the local drive is bound at its ROOT — backups/ would be browsable:\n%s", joined) } // Never a skeleton toward the NAS (R-67: no Felhom convention on a customer's own NAS). for _, c := range calls { if strings.Contains(c, "Felhom-Share") { t.Errorf("ensureSkeleton was called toward the NAS: %v", calls) } } if len(cfgPaths) != 2 { t.Errorf("both paths must stay in the FileBrowser source list, got %d", len(cfgPaths)) } } // TestFileBrowserCompose_NoBackupsTreeUnderAnyBind is the CONSEQUENCE assertion, made against the // generated compose text rather than an intermediate struct. // // Under this ruling the share-root bind is retained, so the guarantee cannot be "no bind reaches a // backups/ dir" by path shape — it is "no app namespace, hence no backups/ tree, can exist on a share". // This test therefore pins the paired invariant the safety rests on: the ONLY root-bound path is the // network share, and every drive-bound path is userdata-scoped. If a future change root-binds a drive, // or userdata-scopes the share, this fails and the D5 argument needs re-deriving. func TestFileBrowserCompose_NoBackupsTreeUnderAnyBind(t *testing.T) { var calls []string mounts, _ := buildFileBrowserPaths( []settings.StoragePath{localDrive(), netShare()}, fbDeps(system.FSClassNetwork, &calls, nil), ) for _, m := range mounts { src := strings.TrimSpace(strings.SplitN(strings.TrimPrefix(strings.TrimSpace(m), "- "), ":", 2)[0]) isShare := strings.Contains(src, "Felhom-Share") scoped := strings.HasSuffix(src, "/userdata") switch { case isShare && scoped: t.Errorf("the share became userdata-scoped — impossible on a customer NAS, and it breaks the rslave wake: %q", src) case !isShare && !scoped: t.Errorf("a DRIVE is bound unscoped at %q — its backups/ tree is browsable", src) } } } // --------------------------------------------------------------------------------------------- // 4. The deploy dropdown is marked, not silently emptied (§5). // --------------------------------------------------------------------------------------------- // TestDeployStoragePath_NetworkMarkedNotHidden: the NAS stays in the list, disabled, with a reason, and // never pre-selected. A registered share vanishing from the list the customer expects it in reads as a // bug; present-with-a-reason answers the question in place. func TestDeployStoragePath_NetworkMarkedNotHidden(t *testing.T) { s := testServer(t) // The NAS is the IsDefault one here on purpose: the template must not pre-select a disabled option. share := netShare() share.IsDefault = true if err := s.settings.AddStoragePath(share); err != nil { t.Fatal(err) } drive := localDrive() drive.IsDefault = false if err := s.settings.AddStoragePath(drive); err != nil { t.Fatal(err) } var got []DeployStoragePath for _, sp := range s.settings.GetSchedulableStoragePaths() { dp := DeployStoragePath{StoragePath: sp} if refuse, _ := s.settings.RefuseAsAppNamespace(sp.Path); refuse { dp.NotAllowed = true dp.NotAllowedNote = "hálózati tárhely — alkalmazáshoz nem választható" } got = append(got, dp) } if len(got) != 2 { t.Fatalf("both paths must be listed (marked, not hidden), got %d", len(got)) } for _, dp := range got { isShare := strings.Contains(dp.Path, "Felhom-Share") if isShare != dp.NotAllowed { t.Errorf("%s: NotAllowed=%v, want %v", dp.Path, dp.NotAllowed, isShare) } if dp.NotAllowed && dp.NotAllowedNote == "" { t.Errorf("%s: disabled with no reason shown", dp.Path) } if dp.NotAllowed && dp.IsDefault { // The data still says IsDefault; the TEMPLATE must not honour it. Guarded by the // `and .IsDefault (not .NotAllowed)` condition in deploy.html — pinned here so a template // edit that drops it is visible. t.Log("share is IsDefault in the registry — deploy.html must not pre-select it (template guard)") } } }