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