package storage import ( "context" "os" "path/filepath" "runtime" "strings" "testing" ) // F2/F1 (CAMPAIGN-3): a remove (and, via the same path, a rolled-back add) must leave ZERO residue — // no failed-state units, no leftover mountpoint dir. RemoveNetworkMount must reset-failed the stuck // unit BEFORE removing the files (or systemd keeps it as not-found/failed) and rmdir the mountpoint. func TestRemoveNetworkMount_ZeroResidue(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server") } unitDir := t.TempDir() stageDir := t.TempDir() spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000} where := spec.Where() mountName, err := UnitNameForMount(where) if err != nil { t.Fatalf("unit name: %v", err) } autoName := strings.TrimSuffix(mountName, ".mount") + ".automount" // Both unit files present on disk (rm is recorded, so they stay for the assertion). if err := os.WriteFile(filepath.Join(unitDir, mountName), []byte(renderNetworkMountUnit(spec)), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(unitDir, autoName), []byte(renderNetworkAutomountUnit(spec)), 0o644); err != nil { t.Fatal(err) } rr := &recordingRunner{} ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: stageDir, Host: &fakeHostReader{}, Logger: quietLogger()}) // The automount is in the failed state (start-limit residue) — must be reset-failed. ops.unitFailed = func(_ context.Context, unit string) bool { return unit == autoName } if err := ops.RemoveNetworkMount(context.Background(), "media"); err != nil { t.Fatalf("remove: %v", err) } var sawReset, sawRmUnit, sawRmdir, sawReload bool for _, c := range rr.calls { j := strings.Join(c, " ") switch { case strings.Contains(j, "reset-failed -- "+autoName): sawReset = true case strings.Contains(j, "rm -f") && strings.Contains(j, autoName): sawRmUnit = true case strings.Contains(j, "rmdir") && strings.Contains(j, where): sawRmdir = true case strings.Contains(j, "daemon-reload"): sawReload = true } } if !sawReset { t.Errorf("F2: a failed unit must be reset-failed on remove; calls: %v", rr.calls) } if !sawRmUnit { t.Errorf("the unit files must be removed; calls: %v", rr.calls) } if !sawRmdir { t.Errorf("F1: the empty mountpoint dir must be rmdir'd; calls: %v", rr.calls) } if !sawReload { t.Errorf("daemon-reload must run after removal; calls: %v", rr.calls) } } // rmdir is used (never rm -rf) — the fail-safe: a non-empty dir is left in place, not force-removed. func TestRemoveNetworkMount_NeverForceRemovesMountpoint(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("systemd-escaped unit filename contains a backslash; exercised on the Linux build server") } unitDir := t.TempDir() spec := NetworkMountSpec{Name: "media", Protocol: ProtocolNFS, Server: "10.0.0.5", Export: "/srv/media", MappedUID: 1000, MappedGID: 1000} mountName, _ := UnitNameForMount(spec.Where()) autoName := strings.TrimSuffix(mountName, ".mount") + ".automount" _ = os.WriteFile(filepath.Join(unitDir, mountName), []byte(renderNetworkMountUnit(spec)), 0o644) _ = os.WriteFile(filepath.Join(unitDir, autoName), []byte(renderNetworkAutomountUnit(spec)), 0o644) rr := &recordingRunner{} ops := NewSudoHostOps(SudoHostOpsConfig{Runner: rr, Bins: Binaries{}.withDefaults(), UnitDir: unitDir, StageDir: t.TempDir(), Host: &fakeHostReader{}, Logger: quietLogger()}) if err := ops.RemoveNetworkMount(context.Background(), "media"); err != nil { t.Fatalf("remove: %v", err) } for _, c := range rr.calls { j := strings.Join(c, " ") if strings.Contains(j, "rm -rf") || (strings.Contains(j, "rm ") && strings.Contains(j, "/mnt/felhom-drives/media") && !strings.Contains(j, "rmdir")) { t.Errorf("mountpoint cleanup must be rmdir-only (never rm -rf); offending call: %s", j) } } }