Files
darroyo 50ed8abe95 feat: file manager with browse, search, upload, chmod/chown
- Full file browser page at /files with lazy-load, breadcrumbs, drag&drop
- FileBrowserModal component for path selection from Samba/NFS forms
- PathField component replaces bare inputs in share/export forms
- Backend: /api/files/* routes with List, Mkdir, Rename, Delete, Chmod, Chown, Upload, Download, Preview, Search
- Reuses NASCTL_ALLOWED_ROOTS for path validation
- NASCTL_UPLOAD_MAX_BYTES (100MB) and NASCTL_PREVIEW_MAX_BYTES (256KB) env vars
- Capabilities endpoint returns chmod/chown availability (requires root)
- Version bump: 0.3.2 -> 0.4.0
2026-07-06 01:56:40 -04:00

298 lines
6.6 KiB
Go

package files
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestList(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "subdir")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
f1 := filepath.Join(dir, "a.txt")
if err := os.WriteFile(f1, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
f2 := filepath.Join(dir, "b.log")
if err := os.WriteFile(f2, []byte("world"), 0o644); err != nil {
t.Fatal(err)
}
res, err := List(dir, nil, 1, 50)
if err != nil {
t.Fatalf("List: %v", err)
}
if res.Total != 3 { // subdir, a.txt, b.log
t.Errorf("Total=%d, want 3", res.Total)
}
if res.HasMore {
t.Errorf("HasMore=true, want false")
}
}
func TestListPaged(t *testing.T) {
dir := t.TempDir()
for i := 0; i < 10; i++ {
if err := os.WriteFile(filepath.Join(dir, string(rune('a'+i))+".txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
page1, err := List(dir, nil, 1, 3)
if err != nil {
t.Fatalf("page1: %v", err)
}
if len(page1.Entries) != 3 {
t.Errorf("page1 len=%d, want 3", len(page1.Entries))
}
if !page1.HasMore {
t.Errorf("page1.HasMore=false, want true")
}
page2, err := List(dir, nil, 2, 3)
if err != nil {
t.Fatalf("page2: %v", err)
}
if len(page2.Entries) != 3 {
t.Errorf("page2 len=%d, want 3", len(page2.Entries))
}
page4, err := List(dir, nil, 4, 3)
if err != nil {
t.Fatalf("page4: %v", err)
}
if len(page4.Entries) != 1 {
t.Errorf("page4 len=%d, want 1", len(page4.Entries))
}
if page4.HasMore {
t.Errorf("page4.HasMore=true, want false")
}
}
func TestListRejectsNotDir(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "file.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
_, err := List(f, nil, 1, 50)
if err == nil {
t.Error("expected error for non-directory")
}
}
func TestInfo(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "test.txt")
if err := os.WriteFile(f, []byte("hello world"), 0o644); err != nil {
t.Fatal(err)
}
info, err := Info(f, nil)
if err != nil {
t.Fatalf("Info: %v", err)
}
if info.Name != "test.txt" {
t.Errorf("Name=%q, want test.txt", info.Name)
}
if info.IsDir {
t.Errorf("IsDir=true, want false")
}
if info.Size != 11 {
t.Errorf("Size=%d, want 11", info.Size)
}
}
func TestMkdir(t *testing.T) {
dir := t.TempDir()
newDir := filepath.Join(dir, "newdir", "nested")
if err := Mkdir(newDir, nil); err != nil {
t.Fatalf("Mkdir: %v", err)
}
info, err := os.Stat(newDir)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Error("expected dir")
}
}
func TestMkdirDisallowed(t *testing.T) {
dir := t.TempDir()
err := Mkdir(filepath.Join(dir, "ok"), []string{"/tmp"})
if err == nil {
t.Error("expected error for disallowed path")
}
}
func TestRename(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "old.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := Rename(f, "new.txt", nil); err != nil {
t.Fatalf("Rename: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "new.txt")); err != nil {
t.Errorf("new file not found: %v", err)
}
if _, err := os.Stat(f); err == nil {
t.Error("old file still exists")
}
}
func TestRenameBadName(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "old.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
tests := []string{"/etc/passwd", "..", ".", "-f", ""}
for _, name := range tests {
err := Rename(f, name, nil)
if err == nil {
t.Errorf("Rename(%q): expected error", name)
}
}
}
func TestDelete(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "todelete.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := Delete(f, nil); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(f); err == nil {
t.Error("file still exists after delete")
}
}
func TestDeleteRecursively(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(subdir, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := Delete(subdir, nil); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := os.Stat(subdir); err == nil {
t.Error("subdir still exists")
}
}
func TestDeleteRejectsRoot(t *testing.T) {
err := Delete("/", nil)
if err == nil {
t.Error("expected error deleting root")
}
}
func TestChmod(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "file.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := Chmod(f, 0o600, nil); err != nil {
t.Fatalf("Chmod: %v", err)
}
info, _ := os.Stat(f)
if info.Mode().Perm() != 0o600 {
t.Errorf("mode=%o, want 600", info.Mode().Perm())
}
}
func TestChown(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "file.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(f)
st := stat.Sys().(*syscall.Stat_t)
if err := Chown(f, int(st.Uid), int(st.Gid), nil); err != nil {
t.Fatalf("Chown: %v", err)
}
}
func TestSearch(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "photos")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
files := map[string]string{
"myphoto.jpg": "xxx",
"photo_album.txt": "yyy",
"report.pdf": "zzz",
"data.csv": "aaa",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(sub, "vacation.png"), []byte("img"), 0o644); err != nil {
t.Fatal(err)
}
hits, err := Search(dir, "photo", 50, nil)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(hits) < 2 {
t.Errorf("got %d hits, want at least 2", len(hits))
}
}
func TestSearchLimit(t *testing.T) {
dir := t.TempDir()
for i := 0; i < 20; i++ {
if err := os.WriteFile(filepath.Join(dir, "file_match_"+string(rune('a'+i))+".txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
hits, err := Search(dir, "file_match", 5, nil)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(hits) != 5 {
t.Errorf("got %d hits, want 5", len(hits))
}
}
func TestValidatePathAllowed(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "allowed.txt")
if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
errs := []string{
filepath.Join(dir, "..", "etc", "passwd"),
"/etc/passwd",
"/tmp/../../../secret",
}
for _, path := range errs {
_, err := List(path, []string{dir}, 1, 50)
if err == nil {
t.Errorf("expected error for %q", path)
}
}
}