448 lines
16 KiB
Go
448 lines
16 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|