Files
silo-server/internal/api/handlers/filesystem_test.go
8b3ebc78b6 fix(api): show symlinked directories in folder browser (#257)
os.ReadDir reports lstat semantics, so a symlink pointing at a directory
has IsDir() == false and was silently dropped by HandleBrowse. Library
folders reached through directory symlinks (e.g. a Docker bind mount whose
entries link into a FUSE mount) never appeared in the folder picker even
though the targets were valid and browsable.

Resolve non-directory entries with os.Stat to follow the link and include
them when the target is a directory; entries whose target cannot be stat'd
(broken or looping links) are skipped rather than erroring the request.
Regular directories keep the lstat fast path and take no extra syscall.

Closes #208

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:10:56 -04:00

98 lines
3.2 KiB
Go

package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestHandleBrowseFollowsDirectorySymlinks verifies that the folder browser
// lists symlinks that resolve to directories (issue #208). os.ReadDir returns
// lstat-based entries, so a directory symlink reports IsDir() == false and was
// silently dropped; the handler must os.Stat each entry to follow the link.
func TestHandleBrowseFollowsDirectorySymlinks(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation is unreliable on Windows CI")
}
t.Parallel()
root := t.TempDir()
// A real directory that should always be listed.
realDir := filepath.Join(root, "real-dir")
if err := os.Mkdir(realDir, 0o755); err != nil {
t.Fatalf("mkdir real-dir: %v", err)
}
// The symlink-to-directory target lives outside root so the browser cannot
// reach it except by following the link — mirrors the reported setup where
// entries under a bind mount point into a separate FUSE mount.
linkTarget := filepath.Join(t.TempDir(), "link-target")
if err := os.Mkdir(linkTarget, 0o755); err != nil {
t.Fatalf("mkdir link-target: %v", err)
}
dirSymlink := filepath.Join(root, "symlinked-dir")
if err := os.Symlink(linkTarget, dirSymlink); err != nil {
t.Fatalf("symlink dir: %v", err)
}
// A regular file: must never be listed by the folder browser.
regularFile := filepath.Join(root, "note.txt")
if err := os.WriteFile(regularFile, []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
// A symlink to a file: resolves to a non-directory, must not be listed.
fileSymlink := filepath.Join(root, "note-link")
if err := os.Symlink(regularFile, fileSymlink); err != nil {
t.Fatalf("symlink file: %v", err)
}
// A broken symlink: os.Stat fails; it must be skipped, not error the request.
brokenSymlink := filepath.Join(root, "broken-link")
if err := os.Symlink(filepath.Join(root, "does-not-exist"), brokenSymlink); err != nil {
t.Fatalf("symlink broken: %v", err)
}
handler := NewFilesystemHandler()
req := httptest.NewRequest(http.MethodGet, "/filesystem/browse?path="+root, nil)
rec := httptest.NewRecorder()
handler.HandleBrowse(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp filesystemBrowseResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
got := make(map[string]string, len(resp.Entries))
for _, e := range resp.Entries {
got[e.Name] = e.Path
}
if _, ok := got["real-dir"]; !ok {
t.Errorf("real-dir missing from entries: %+v", resp.Entries)
}
if path, ok := got["symlinked-dir"]; !ok {
t.Errorf("symlinked-dir (directory symlink) missing from entries: %+v", resp.Entries)
} else if want := filepath.Join(root, "symlinked-dir"); path != want {
t.Errorf("symlinked-dir path = %q, want %q", path, want)
}
if _, ok := got["note.txt"]; ok {
t.Errorf("regular file note.txt should not be listed: %+v", resp.Entries)
}
if _, ok := got["note-link"]; ok {
t.Errorf("file symlink note-link should not be listed: %+v", resp.Entries)
}
if _, ok := got["broken-link"]; ok {
t.Errorf("broken symlink broken-link should not be listed: %+v", resp.Entries)
}
}