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, "") 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 /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) } }