2958946517
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).
Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.
Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.
Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.
One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.
Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.
Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.
Tests 915 -> 949, all green. MinAgent unchanged.
221 lines
8.3 KiB
Go
221 lines
8.3 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// R-75 Scenario E — the system share's delete refusal is SERVER-SIDE.
|
|
//
|
|
// Two independent checks, tested independently on purpose (the v0.70.1 lesson): a handler test that
|
|
// POSTs directly proves nothing about UI reachability, and a render gate proves nothing about
|
|
// enforcement. Both are required; neither substitutes for the other.
|
|
|
|
func serverWithImportShare(t *testing.T) *Server {
|
|
t.Helper()
|
|
s := testServer(t)
|
|
// A real Manager so the handler's post-delete ReconcileSamba has a receiver. Sharing is left
|
|
// DISABLED, so reconcileSambaAt early-returns and no docker call is made.
|
|
s.cfg.Paths.SystemDataPath = "/mnt/sys_drive"
|
|
s.cfg.Paths.StacksDir = t.TempDir()
|
|
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mgr.SetMigrationDeps(s.settings, func() bool { return false })
|
|
s.stackMgr = mgr
|
|
if err := s.settings.AddSMBShare(settings.SMBShare{
|
|
Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import",
|
|
System: true,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.settings.AddSMBShare(settings.SMBShare{
|
|
Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Scenario E, enforcement half: POST the delete endpoint directly. The share must survive.
|
|
func TestScenarioE_SystemShareDeleteRefusedServerSide(t *testing.T) {
|
|
s := serverWithImportShare(t)
|
|
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/sharing/shares/delete",
|
|
strings.NewReader(url.Values{"name": {settings.SystemImportShareName}}.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
s.sharingShareDeleteHandler(rr, req)
|
|
|
|
found := false
|
|
for _, sh := range s.settings.GetSMBShares() {
|
|
if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("the system share was DELETED by a direct POST — the refusal is not server-side")
|
|
}
|
|
|
|
// And a non-system share is still deletable, so this is a targeted refusal and not a broken
|
|
// endpoint that happens to refuse everything.
|
|
rr2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest("POST", "/sharing/shares/delete",
|
|
strings.NewReader(url.Values{"name": {"csalad"}}.Encode()))
|
|
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
s.sharingShareDeleteHandler(rr2, req2)
|
|
for _, sh := range s.settings.GetSMBShares() {
|
|
if sh.Name == "csalad" {
|
|
t.Error("an ordinary share must still be deletable")
|
|
}
|
|
}
|
|
}
|
|
|
|
// The store layer refuses too, so no future caller can bypass the handler.
|
|
func TestScenarioE_StoreLayerRefusesSystemShare(t *testing.T) {
|
|
s := serverWithImportShare(t)
|
|
if err := s.settings.RemoveSMBShare(settings.SystemImportShareName); err == nil {
|
|
t.Error("RemoveSMBShare must refuse a System share")
|
|
}
|
|
if err := s.settings.RemoveSMBShare("csalad"); err != nil {
|
|
t.Errorf("RemoveSMBShare must still delete an ordinary share: %v", err)
|
|
}
|
|
}
|
|
|
|
// Scenario E, reachability half: the template must not render a delete button for a system share —
|
|
// and must still render one for an ordinary share.
|
|
func TestScenarioE_SharingTemplateOmitsSystemDeleteButton(t *testing.T) {
|
|
data := map[string]interface{}{
|
|
"Page": "sharing", "Title": "Hálózati megosztás",
|
|
"SMBEnabled": true,
|
|
// The SAME type the handler passes — see ShareRow's comment.
|
|
"SMBShares": []ShareRow{
|
|
{Name: settings.SystemImportShareName, Path: "/mnt/sys_drive/felhom-data/userdata/import", System: true, Available: true},
|
|
{Name: "csalad", Path: "/mnt/felhom-drives/hdd_1/shares/csalad", Available: true},
|
|
},
|
|
"CSRFField": "",
|
|
}
|
|
html := renderBackupPage(t, "sharing", data)
|
|
|
|
rows := strings.Split(html, "<tr>")
|
|
var sysRow, normalRow string
|
|
for _, row := range rows {
|
|
if strings.Contains(row, settings.SystemImportShareName) {
|
|
sysRow = row
|
|
}
|
|
if strings.Contains(row, "csalad") {
|
|
normalRow = row
|
|
}
|
|
}
|
|
if sysRow == "" || normalRow == "" {
|
|
t.Fatalf("both share rows must render; sys=%v normal=%v", sysRow != "", normalRow != "")
|
|
}
|
|
if strings.Contains(sysRow, "/sharing/shares/delete") {
|
|
t.Error("the system share row must NOT carry a delete form")
|
|
}
|
|
if !strings.Contains(normalRow, "/sharing/shares/delete") {
|
|
t.Error("an ordinary share row must still carry its delete form")
|
|
}
|
|
}
|
|
|
|
// R-75 Scenario F — sharing stays OPT-IN. Deploying a drop-zone app must not put SMB on the LAN.
|
|
func TestScenarioF_SharingStaysOptIn(t *testing.T) {
|
|
s := testServer(t)
|
|
if s.settings.GetSMBSettings().Enabled {
|
|
t.Fatal("precondition: sharing must start disabled")
|
|
}
|
|
// The auto-create is wired to the ENABLE handler only; nothing in the deploy path calls it.
|
|
// Assert the state a fresh box is in: no shares at all.
|
|
if got := s.settings.GetSMBShares(); len(got) != 0 {
|
|
t.Errorf("a fresh box must have no shares before sharing is enabled, got %v", got)
|
|
}
|
|
if s.settings.GetSMBSettings().Enabled {
|
|
t.Error("sharing must not have been switched on")
|
|
}
|
|
}
|
|
|
|
// ensureImportShare is idempotent and correctly shaped.
|
|
func TestEnsureImportShare_IdempotentAndCorrect(t *testing.T) {
|
|
s := testServer(t)
|
|
// A writable stand-in for /mnt/sys_drive so EnsureImportRoot really creates the dir (the test
|
|
// user is not root, so the real path is not writable).
|
|
s.cfg.Paths.SystemDataPath = t.TempDir()
|
|
s.cfg.Paths.StacksDir = t.TempDir()
|
|
mgr, err := stacks.NewManager(s.cfg, s.logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.stackMgr = mgr
|
|
|
|
for i := 0; i < 3; i++ {
|
|
if err := s.ensureImportShare(); err != nil {
|
|
t.Fatalf("call %d: %v", i+1, err)
|
|
}
|
|
}
|
|
shares := s.settings.GetSMBShares()
|
|
if len(shares) != 1 {
|
|
t.Fatalf("expected exactly 1 share after 3 calls (idempotent), got %d: %v", len(shares), shares)
|
|
}
|
|
sh := shares[0]
|
|
if sh.Name != settings.SystemImportShareName {
|
|
t.Errorf("share name = %q, want %q", sh.Name, settings.SystemImportShareName)
|
|
}
|
|
if !sh.System {
|
|
t.Error("the import share must be marked System")
|
|
}
|
|
if sh.Offsite {
|
|
t.Error("the drop-zone is class `excluded` — Offsite must be false, or the UI would contradict the backup engines")
|
|
}
|
|
if sh.ReadOnly {
|
|
t.Error("a drop-zone the customer copies INTO must be writable")
|
|
}
|
|
if want := mgr.GetImportRoot(); sh.Path != want {
|
|
t.Errorf("share path = %q, want the canonical import root %q", sh.Path, want)
|
|
}
|
|
// The name must be NetBIOS-safe — it is an SMB share name.
|
|
if err := settings.ValidateSMBShareName(sh.Name); err != nil {
|
|
t.Errorf("share name is not NetBIOS-safe: %v", err)
|
|
}
|
|
}
|
|
|
|
// The brief's re-assertion: SPIKE P4 proved <root>/userdata/import is shareable against a GENERIC
|
|
// registered root. This pins the SYSTEM-root shape specifically, because ProtectedHDDPaths has a
|
|
// legacy felhom-data double-nest branch that only fires there.
|
|
//
|
|
// It documents the actual live shape, which is why ensureImportShare does not route through the
|
|
// picker guard: the system drive is NOT a registered storage path on either demo box (verified
|
|
// 2026-07-26), so sharingResolvePath — whose job is to validate CUSTOMER-supplied paths — refuses it.
|
|
// A controller-generated constant is a different trust class.
|
|
func TestImportRoot_NotReachableViaTheCustomerPicker(t *testing.T) {
|
|
s := testServer(t)
|
|
root := t.TempDir() // stands in for the system drive; deliberately NOT registered
|
|
importRoot := filepath.Join(root, "felhom-data", "userdata", "import")
|
|
if err := os.MkdirAll(importRoot, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A registered data drive exists, so this is not "the registry is empty" trivially refusing.
|
|
dataDrive := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dataDrive, "userdata", "import"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.settings.AddStoragePath(settings.StoragePath{Path: dataDrive, Label: "hdd_1", IsDefault: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := s.sharingResolvePath(importRoot); err == nil {
|
|
t.Error("the customer picker must NOT accept the unregistered system-drive import root")
|
|
}
|
|
// Control: the data drive's own userdata subtree IS pickable, so the guard is not refusing all.
|
|
if _, err := s.sharingResolvePath(filepath.Join(dataDrive, "userdata", "import")); err != nil {
|
|
t.Errorf("a registered drive's userdata/import must stay shareable: %v", err)
|
|
}
|
|
}
|