pegasus/backend/handlers_fs_test.go

448 lines
16 KiB
Go
Raw Normal View History

2026-04-11 00:02:59 -03:00
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"scm.bstein.dev/bstein/Pegasus/backend/internal"
)
func TestResolveLibraryRootFallback(t *testing.T) {
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library", "alt"}}}
got, err := resolveLibraryRoot(um, "brad", "not-allowed")
if err != nil {
t.Fatalf("resolveLibraryRoot failed: %v", err)
}
if got != "library" {
t.Fatalf("expected default root, got %q", got)
}
}
func TestListHandlerErrorPaths(t *testing.T) {
origMediaRoot := mediaRoot
defer func() { mediaRoot = origMediaRoot }()
mediaRoot = t.TempDir()
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
handler := listHandler(um)
t.Run("unauthorized", func(t *testing.T) {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/list", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized status, got %d", rr.Code)
}
})
t.Run("forbidden escape", func(t *testing.T) {
cookie := sessionCookie(t, "brad", "token")
req := httptest.NewRequest(http.MethodGet, "/api/list?path=../secret", nil)
req.Header.Set("Cookie", cookie)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden status, got %d", rr.Code)
}
})
}
func TestRenameDeleteMkdirErrorPaths(t *testing.T) {
origMediaRoot := mediaRoot
origDryRun := internal.DryRun
defer func() {
mediaRoot = origMediaRoot
internal.DryRun = origDryRun
}()
mediaRoot = t.TempDir()
internal.DryRun = true
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
jf := &fakeJellyfin{}
cookie := sessionCookie(t, "brad", "token")
t.Run("rename bad json", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString("{bad"))
req.Header.Set("Cookie", cookie)
renameHandler(um, jf).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected bad request, got %d", rr.Code)
}
})
t.Run("rename invalid name", func(t *testing.T) {
body := []byte(`{"lib":"library","from":"old.txt","to":"bad-name.txt"}`)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewReader(body))
req.Header.Set("Cookie", cookie)
renameHandler(um, jf).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected bad request, got %d", rr.Code)
}
})
t.Run("mkdir bad json and empty folder", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString("{bad"))
req.Header.Set("Cookie", cookie)
mkdirHandler(um, jf).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected bad request, got %d", rr.Code)
}
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`))
req.Header.Set("Cookie", cookie)
mkdirHandler(um, jf).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected bad request, got %d", rr.Code)
}
})
t.Run("delete unauthorized", func(t *testing.T) {
rr := httptest.NewRecorder()
deleteHandler(um, jf).ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized, got %d", rr.Code)
}
})
}
func TestDebugHandlersDisabled(t *testing.T) {
origDebug := internal.Debug
defer func() { internal.Debug = origDebug }()
internal.Debug = false
rr := httptest.NewRecorder()
debugEnvHandler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/env", nil))
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code)
}
rr = httptest.NewRecorder()
debugWriteTestHandler(&internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}).ServeHTTP(
rr,
httptest.NewRequest(http.MethodGet, "/debug/write-test", nil),
)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden when debug disabled, got %d", rr.Code)
}
}
func TestFilesystemHandlersMoreBranches(t *testing.T) {
origMediaRoot := mediaRoot
origDryRun := internal.DryRun
origDebug := internal.Debug
defer func() {
mediaRoot = origMediaRoot
internal.DryRun = origDryRun
internal.Debug = origDebug
}()
root := t.TempDir()
mediaRoot = root
internal.DryRun = false
internal.Debug = true
um := &internal.UserMap{Map: map[string]internal.StringOrList{"brad": {"library"}}}
jf := &fakeJellyfin{}
cookie := sessionCookie(t, "brad", "token")
handlerList := listHandler(um)
handlerRename := renameHandler(um, jf)
handlerDelete := deleteHandler(um, jf)
handlerMkdir := mkdirHandler(um, jf)
if err := os.MkdirAll(filepath.Join(root, "library", "album"), 0o2775); err != nil {
t.Fatalf("mkdir library: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "library", "zeta"), 0o2775); err != nil {
t.Fatalf("mkdir zeta: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "library", "keep.txt"), []byte("keep"), 0o644); err != nil {
t.Fatalf("write keep file: %v", err)
}
t.Run("list same-type sorting", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil)
req.Header.Set("Cookie", cookie)
handlerList.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
}
})
t.Run("list nested path", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=album", nil)
req.Header.Set("Cookie", cookie)
handlerList.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
}
})
t.Run("list missing directory", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library&path=missing", nil)
req.Header.Set("Cookie", cookie)
handlerList.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected internal error, got %d", rr.Code)
}
})
t.Run("list missing mapping", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/list?lib=library", nil)
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
handlerList.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden for missing mapping, got %d", rr.Code)
}
})
t.Run("rename unauthorized and escape", func(t *testing.T) {
rr := httptest.NewRecorder()
handlerRename.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/rename", bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized rename, got %d", rr.Code)
}
rr = httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","from":"../escape.txt","to":"2026.01.02.Note.escape.txt"}`)
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
req.Header.Set("Cookie", cookie)
handlerRename.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError {
t.Fatalf("expected from-path escape or missing-source failure, got %d", rr.Code)
}
})
t.Run("rename forbidden root and dry run", func(t *testing.T) {
rr := httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
handlerRename.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden rename root, got %d", rr.Code)
}
orig := internal.DryRun
internal.DryRun = true
defer func() { internal.DryRun = orig }()
rr = httptest.NewRecorder()
body = bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"2026.01.02.Note.old.txt"}`)
req = httptest.NewRequest(http.MethodPost, "/api/rename", body)
req.Header.Set("Cookie", cookie)
handlerRename.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected dry-run rename success, got %d", rr.Code)
}
})
t.Run("rename to-path escape", func(t *testing.T) {
orig := internal.DryRun
internal.DryRun = false
defer func() { internal.DryRun = orig }()
rr := httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","from":"old.txt","to":"../2026.01.02.Note.escape.txt"}`)
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
req.Header.Set("Cookie", cookie)
handlerRename.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden rename escape, got %d", rr.Code)
}
})
t.Run("rename missing source", func(t *testing.T) {
rr := httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","from":"missing.txt","to":"2026.01.02.Note.missing.txt"}`)
req := httptest.NewRequest(http.MethodPost, "/api/rename", body)
req.Header.Set("Cookie", cookie)
handlerRename.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected rename error, got %d", rr.Code)
}
})
t.Run("delete unauthorized and escape", func(t *testing.T) {
rr := httptest.NewRecorder()
handlerDelete.ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/file?path=a", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized delete, got %d", rr.Code)
}
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=../escape", nil)
req.Header.Set("Cookie", cookie)
handlerDelete.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden && rr.Code != http.StatusInternalServerError {
t.Fatalf("expected delete escape failure, got %d", rr.Code)
}
})
t.Run("delete forbidden root and missing file", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
handlerDelete.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden delete root, got %d", rr.Code)
}
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=missing.txt", nil)
req.Header.Set("Cookie", cookie)
handlerDelete.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected missing-file delete error, got %d", rr.Code)
}
})
t.Run("delete file without recursion", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
req.Header.Set("Cookie", cookie)
handlerDelete.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected delete success, got %d", rr.Code)
}
if _, err := os.Stat(filepath.Join(root, "library", "keep.txt")); !os.IsNotExist(err) {
t.Fatalf("expected keep.txt to be removed")
}
})
t.Run("delete dry run", func(t *testing.T) {
orig := internal.DryRun
internal.DryRun = true
defer func() { internal.DryRun = orig }()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodDelete, "/api/file?lib=library&path=keep.txt", nil)
req.Header.Set("Cookie", cookie)
handlerDelete.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected dry-run delete success, got %d", rr.Code)
}
})
t.Run("mkdir unauthorized and path escape", func(t *testing.T) {
rr := httptest.NewRecorder()
handlerMkdir.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized mkdir, got %d", rr.Code)
}
rr = httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","path":"../escape"}`)
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body)
req.Header.Set("Cookie", cookie)
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest && rr.Code != http.StatusForbidden {
t.Fatalf("expected mkdir escape failure, got %d", rr.Code)
}
})
t.Run("mkdir forbidden root and dry run", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`))
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden mkdir root, got %d", rr.Code)
}
orig := internal.DryRun
internal.DryRun = true
defer func() { internal.DryRun = orig }()
rr = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`))
req.Header.Set("Cookie", cookie)
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected dry-run mkdir success, got %d", rr.Code)
}
})
t.Run("mkdir create failure", func(t *testing.T) {
orig := internal.DryRun
internal.DryRun = false
defer func() { internal.DryRun = orig }()
blocker := filepath.Join(root, "library", "blocker")
if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil {
t.Fatalf("write blocker file: %v", err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":"blocker/child"}`))
req.Header.Set("Cookie", cookie)
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("expected mkdir create failure, got %d", rr.Code)
}
})
t.Run("mkdir empty path", func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", bytes.NewBufferString(`{"lib":"library","path":" "}`))
req.Header.Set("Cookie", cookie)
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected empty folder rejection, got %d", rr.Code)
}
})
t.Run("mkdir missing library root", func(t *testing.T) {
mediaRoot = filepath.Join(root, "file-root")
if err := os.WriteFile(mediaRoot, []byte("not a dir"), 0o644); err != nil {
t.Fatalf("write file root: %v", err)
}
rr := httptest.NewRecorder()
body := bytes.NewBufferString(`{"lib":"library","path":"new-folder"}`)
req := httptest.NewRequest(http.MethodPost, "/api/mkdir", body)
req.Header.Set("Cookie", cookie)
handlerMkdir.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected bad request for missing library root, got %d", rr.Code)
}
})
t.Run("debug write unauthorized and dry run", func(t *testing.T) {
handlerDebug := debugWriteTestHandler(um)
rr := httptest.NewRecorder()
handlerDebug.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/debug/write-test", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected unauthorized debug write, got %d", rr.Code)
}
orig := internal.DryRun
internal.DryRun = true
defer func() { internal.DryRun = orig }()
rr = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil)
req.Header.Set("Cookie", cookie)
handlerDebug.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected dry-run debug write success, got %d", rr.Code)
}
})
t.Run("debug write forbidden root", func(t *testing.T) {
handlerDebug := debugWriteTestHandler(um)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/debug/write-test", nil)
req.Header.Set("Cookie", sessionCookie(t, "nobody", "token"))
handlerDebug.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Fatalf("expected forbidden debug write root, got %d", rr.Code)
}
})
}