Files
felhom-controller/controller/internal/web/import_share_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${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.
2026-07-26 08:12:57 +02:00

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)
}
}