Files
felhom-controller/controller/internal/web/sharing_handlers_test.go
T
admin 4f08e5e7c3 feat(samba): Megosztas page + guarded folder picker (R-7 slice 1, Part 3)
New top-nav category with the Halozati megosztas page: enable/server-name card,
household password, shares table (Nev/Mappa/Irasvedett/Felhomentes/Torles), and
a create flow (new folder under <storage>/shares or an existing folder via the
browse modal). Every customer path goes through sharingResolvePath: absolute ->
EvalSymlinks -> containment in a registered live storage root -> deny-listed
system subtree check -> is-a-directory. Refusals are UNIFORM so the picker is
never a filesystem oracle. Deny-list derived from ProtectedHDDPaths (provably a
subset); the drive root is an exact-match denial so user-data folders under it
stay shareable. samba infra metadata + i-share icon. Gates green.
2026-07-18 11:45:18 +02:00

130 lines
4.6 KiB
Go

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