Files
felhom-controller/controller/internal/web/network_app_namespace_test.go
T
admin 2f27a363d5 R-108: network storage may not host an app's data namespace (v0.187.0)
This is D5's precondition and it is now met.

An app's namespace root IS its backup root: namespaceRoot returns a non-system
drive path as-is, so the recovery unit lands at <HDD_PATH>/backups/primary/<stack>/.
On a NAS that sits inside the share, which FileBrowser binds WHOLE — share root,
:rslave, download:true.

The bind was NOT narrowed, and establishing why inverted the fix. The share-root
:rslave bind is load-bearing (a 2026-07-22 probe proved an in-container access
through it wakes the idle automount trigger), and scoping is undefinable anyway:
apps on a share store at <share>/<app>, there is no userdata/ layer, and creating
one would write Felhom convention onto a customer's own NAS, which R-67 forbids.
So the browsing surface cannot be narrowed and the backup tree must never be
placed under it. Operator ruling: refuse the placement, keep the browse bind.
Tier 2 already refuses network targets for this reason (F-6C-1).

Nothing stranded: zero apps on network storage across all six hub customers
including Peti. R-67's browse capability is byte-identical.

FIVE surfaces, not the four the register named — settings.RefuseAsAppNamespace is
the single predicate. The deploy POST is the real boundary (it accepts any
caller-supplied HDD_PATH; DeployStack validates only os.Stat). Surface 4,
handleStorageDecommission mode=migrate, guarded only its SOURCE, so a whole
namespace could be decommissioned ONTO a NAS — that one is not in the register.

Fails closed: /mnt/felhom-drives holds both kinds, Kind exists only on a
registered path, so an unregistered path under that root refuses.

Supersedes README's "NAS backup locality — decision A" (v0.118.0).

9 tests, all non-effect (nil stackMgr, so a guard that misses panics rather than
passing). 4 red-proofs, each mutation asserted to have landed.
Suite rc=0, 27 packages, 0 FAIL. vet rc=0. Template + emoji gates OK.
2026-07-30 14:10:20 +02:00

377 lines
16 KiB
Go

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 `<share>/backups/primary/<stack>/` — 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 `<share>/<app>` 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)")
}
}
}