package web import ( "io" "log" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // newSharingServer returns a Server with ONE registered storage root laid out like a real drive // (appdata/, backups/, media/filmek, shares/) so the guard runs against a realistic tree. func newSharingServer(t *testing.T) (*Server, string) { t.Helper() lg := log.New(io.Discard, "", 0) root := t.TempDir() drive := filepath.Join(root, "drive") for _, d := range []string{"appdata/paperless", "backups/unit", "media/filmek", "shares"} { if err := os.MkdirAll(filepath.Join(drive, filepath.FromSlash(d)), 0o755); err != nil { t.Fatal(err) } } sett, err := settings.Load(filepath.Join(root, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "teszt", Schedulable: true}); err != nil { t.Fatal(err) } return &Server{settings: sett, logger: lg, cfg: &config.Config{}}, drive } // Scenario C: the picker/create guard matrix. Every refusal must ALSO be a non-effect. func TestSharingResolvePath_GuardMatrix(t *testing.T) { s, drive := newSharingServer(t) outside := t.TempDir() // a real dir, but under no registered storage root refused := []struct{ name, path string }{ {"appdata subtree (live app DB)", filepath.Join(drive, "appdata", "paperless")}, {"appdata root", filepath.Join(drive, "appdata")}, {"backups subtree", filepath.Join(drive, "backups", "unit")}, {"the drive root itself", drive}, {"outside every registered root", outside}, {"relative path", "nem/abszolut"}, {"empty", ""}, {"traversal out of the root", filepath.Join(drive, "..", "escape")}, {"nonexistent", filepath.Join(drive, "nincs-ilyen")}, } for _, tc := range refused { if got, err := s.sharingResolvePath(tc.path); err == nil { t.Errorf("%s: must be refused, got %q", tc.name, got) } } // The guard is not "deny everything": a real user-data folder IS shareable (Scenario B shares // media/filmek). Without this the refusal tests above would pass on a broken always-deny guard. good := filepath.Join(drive, "media", "filmek") if _, err := s.sharingResolvePath(good); err != nil { t.Errorf("a user-data folder must be shareable, got %v", err) } if _, err := s.sharingResolvePath(filepath.Join(drive, "shares")); err != nil { t.Errorf("the shares dir must be shareable, got %v", err) } } // A symlink planted INSIDE a registered root that points OUTSIDE it must not escape the guard — // this is why EvalSymlinks runs before the containment assert. func TestSharingResolvePath_SymlinkEscapeRefused(t *testing.T) { s, drive := newSharingServer(t) outside := t.TempDir() link := filepath.Join(drive, "media", "escape-link") if err := os.Symlink(outside, link); err != nil { t.Skipf("symlinks unavailable on this platform/privilege level: %v", err) } if got, err := s.sharingResolvePath(link); err == nil { t.Errorf("a symlink escaping the storage root must be refused, resolved to %q", got) } } // A decommissioned storage root stops being shareable. func TestSharingResolvePath_DecommissionedRootRefused(t *testing.T) { s, drive := newSharingServer(t) good := filepath.Join(drive, "media", "filmek") if _, err := s.sharingResolvePath(good); err != nil { t.Fatalf("precondition: %v", err) } if err := s.settings.SetDecommissioned(drive, ""); err != nil { t.Fatal(err) } if _, err := s.sharingResolvePath(good); err == nil { t.Error("a decommissioned storage root must not be shareable") } } // Every refusal returns the SAME message — a per-reason message would make the picker an oracle // for the existence/contents of paths outside the customer's storage. func TestSharingResolvePath_UniformRefusal(t *testing.T) { s, drive := newSharingServer(t) outside := t.TempDir() for _, p := range []string{ filepath.Join(drive, "appdata"), outside, filepath.Join(drive, "nincs-ilyen"), } { _, err := s.sharingResolvePath(p) if err == nil { t.Fatalf("%s should refuse", p) } if err.Error() != errNotShareable.Error() { t.Errorf("refusal message must be uniform, got %q for %s", err.Error(), p) } } } // The "new folder" flow validates the storage ROOT, which the share-target guard rightly refuses. // Regression guard for the live-validation bug: reusing sharingResolvePath there broke share // creation entirely (the root is never a valid share TARGET, but is the valid PARENT). func TestSharingResolveStorageRoot(t *testing.T) { s, drive := newSharingServer(t) // The registered root is accepted here... if got, err := s.sharingResolveStorageRoot(drive); err != nil || got == "" { t.Errorf("a registered storage root must be accepted as a new-folder parent, got %q err=%v", got, err) } // ...even though it is (correctly) refused as a share TARGET. if _, err := s.sharingResolvePath(drive); err == nil { t.Error("the drive root must still be refused as a share target") } // Anything that is not EXACTLY a registered live root is refused. for _, bad := range []string{ filepath.Join(drive, "media"), // a subdir is not a root filepath.Join(drive, "appdata"), t.TempDir(), // unregistered "relative", "", } { if _, err := s.sharingResolveStorageRoot(bad); err == nil { t.Errorf("%q must not be accepted as a storage root", bad) } } // A decommissioned root stops being a valid parent. if err := s.settings.SetDecommissioned(drive, ""); err != nil { t.Fatal(err) } if _, err := s.sharingResolveStorageRoot(drive); err == nil { t.Error("a decommissioned root must not accept new folders") } } // pathWithin must be segment-wise: a sibling directory sharing a name PREFIX is not containment. func TestPathWithin_SiblingPrefixIsNotContainment(t *testing.T) { if pathWithin("/mnt/drive-evil/x", "/mnt/drive") { t.Error("/mnt/drive-evil must NOT count as inside /mnt/drive") } if !pathWithin("/mnt/drive/x", "/mnt/drive") { t.Error("/mnt/drive/x must count as inside /mnt/drive") } if !pathWithin("/mnt/drive", "/mnt/drive") { t.Error("a root is within itself") } }