pegasus/backend/router_test.go

527 lines
15 KiB
Go
Raw Normal View History

2026-04-11 00:02:59 -03:00
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
handler "github.com/tus/tusd/pkg/handler"
"scm.bstein.dev/bstein/Pegasus/backend/internal"
)
type fakeJellyfin struct {
mu sync.Mutex
authResult internal.AuthResult
authErr error
refreshTokens []string
}
func (f *fakeJellyfin) AuthenticateByName(username, password string) (internal.AuthResult, error) {
return f.authResult, f.authErr
}
func (f *fakeJellyfin) RefreshLibrary(userToken string) {
f.mu.Lock()
defer f.mu.Unlock()
f.refreshTokens = append(f.refreshTokens, userToken)
}
func (f *fakeJellyfin) refreshCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.refreshTokens)
}
func testUserMap(t *testing.T, payload string) *internal.UserMap {
t.Helper()
path := filepath.Join(t.TempDir(), "user-map.yaml")
if err := os.WriteFile(path, []byte(payload), 0o644); err != nil {
t.Fatalf("write user map: %v", err)
}
um, err := internal.LoadUserMap(path)
if err != nil {
t.Fatalf("load user map: %v", err)
}
return um
}
func sessionCookie(t *testing.T, username, token string) string {
t.Helper()
rr := httptest.NewRecorder()
if err := internal.SetSession(rr, username, token); err != nil {
t.Fatalf("SetSession: %v", err)
}
cookies := rr.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("expected session cookie")
}
return cookies[0].Name + "=" + cookies[0].Value
}
func requestWithCookie(method, target, cookie string, body []byte) *http.Request {
req := httptest.NewRequest(method, target, bytes.NewReader(body))
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
return req
}
func TestRouterLifecycleAndFilesystemRoutes(t *testing.T) {
origMediaRoot := mediaRoot
origTusDir := tusDir
origDebug := internal.Debug
origDryRun := internal.DryRun
defer func() {
mediaRoot = origMediaRoot
tusDir = origTusDir
internal.Debug = origDebug
internal.DryRun = origDryRun
}()
root := t.TempDir()
mediaRoot = filepath.Join(root, "media")
tusDir = filepath.Join(root, "tus")
internal.Debug = false
internal.DryRun = false
if err := os.MkdirAll(filepath.Join(mediaRoot, "library", "album"), 0o2775); err != nil {
t.Fatalf("mkdir media root: %v", err)
}
if err := os.WriteFile(filepath.Join(mediaRoot, "library", "old.txt"), []byte("old"), 0o644); err != nil {
t.Fatalf("write source file: %v", err)
}
um := testUserMap(t, "map:\n brad: library\n")
jf := &fakeJellyfin{
authResult: internal.AuthResult{
AccessToken: "jf-token",
},
}
jf.authResult.User.Name = "brad"
router := buildRouter(um, jf)
t.Run("health and version", func(t *testing.T) {
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected health status %d", rr.Code)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/version", nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected version status %d", rr.Code)
}
})
t.Run("login whoami logout", func(t *testing.T) {
loginBody, _ := json.Marshal(map[string]string{"username": "brad", "password": "pass"})
rr := httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/login", "", loginBody))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected login status %d: %s", rr.Code, rr.Body.String())
}
cookies := rr.Result().Cookies()
if len(cookies) == 0 {
t.Fatalf("expected login cookie")
}
session := cookies[0].Name + "=" + cookies[0].Value
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/whoami", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected whoami status %d", rr.Code)
}
var profile map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &profile); err != nil {
t.Fatalf("decode whoami: %v", err)
}
if profile["username"] != "brad" {
t.Fatalf("unexpected whoami payload %#v", profile)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/logout", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected logout status %d", rr.Code)
}
})
session := sessionCookie(t, "brad", "jf-token")
t.Run("list mkdir rename delete", func(t *testing.T) {
rr := httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/api/list?lib=library", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected list status %d: %s", rr.Code, rr.Body.String())
}
mkdirBody, _ := json.Marshal(map[string]string{"lib": "library", "path": "new-folder"})
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/mkdir", session, mkdirBody))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected mkdir status %d: %s", rr.Code, rr.Body.String())
}
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); err != nil {
t.Fatalf("expected new folder to exist: %v", err)
}
renameBody, _ := json.Marshal(map[string]string{
"lib": "library",
"from": "old.txt",
"to": "2026.01.02.Note.old.txt",
})
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodPost, "/api/rename", session, renameBody))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected rename status %d: %s", rr.Code, rr.Body.String())
}
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "2026.01.02.Note.old.txt")); err != nil {
t.Fatalf("expected renamed file to exist: %v", err)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodDelete, "/api/file?lib=library&path=new-folder&recursive=true", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected delete status %d: %s", rr.Code, rr.Body.String())
}
if _, err := os.Stat(filepath.Join(mediaRoot, "library", "new-folder")); !os.IsNotExist(err) {
t.Fatalf("expected deleted folder to be absent, err=%v", err)
}
})
t.Run("static metrics and tus auth", func(t *testing.T) {
rr := httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected static status %d", rr.Code)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected metrics status %d", rr.Code)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/tus/123", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected tus auth to reject unauthenticated request, got %d", rr.Code)
}
})
if jf.refreshCount() == 0 {
t.Fatalf("expected at least one Jellyfin refresh call")
}
}
func TestDebugRoutesAndUploadProcessing(t *testing.T) {
origMediaRoot := mediaRoot
origTusDir := tusDir
origDebug := internal.Debug
origDryRun := internal.DryRun
defer func() {
mediaRoot = origMediaRoot
tusDir = origTusDir
internal.Debug = origDebug
internal.DryRun = origDryRun
}()
root := t.TempDir()
mediaRoot = filepath.Join(root, "media")
tusDir = filepath.Join(root, "tus")
internal.Debug = true
internal.DryRun = false
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
t.Fatalf("mkdir media root: %v", err)
}
um := testUserMap(t, "map:\n brad: library\n")
jf := &fakeJellyfin{
authResult: internal.AuthResult{AccessToken: "jf-token"},
}
jf.authResult.User.Name = "brad"
router := buildRouter(um, jf)
session := sessionCookie(t, "brad", "jf-token")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/env", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected debug env status %d", rr.Code)
}
rr = httptest.NewRecorder()
router.ServeHTTP(rr, requestWithCookie(http.MethodGet, "/debug/write-test", session, nil))
if rr.Code != http.StatusOK {
t.Fatalf("unexpected debug write-test status %d", rr.Code)
}
srcID := "upload-id-123"
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
t.Fatalf("mkdir tus dir: %v", err)
}
if err := os.WriteFile(filepath.Join(tusDir, srcID+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
event := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{
Header: http.Header{"Cookie": []string{session}},
},
Upload: handler.FileInfo{
ID: srcID,
MetaData: map[string]string{
"filename": "video.mp4",
"desc": "Trip",
"date": "2026-01-02",
"lib": "library",
},
},
}
done := make(chan struct{})
ch := make(chan handler.HookEvent, 2)
go func() {
watchUploadCompletions(ch, um, jf)
close(done)
}()
ch <- event
ch <- handler.HookEvent{
HTTPRequest: handler.HTTPRequest{
Header: http.Header{"Cookie": []string{session}},
},
Upload: handler.FileInfo{
ID: "bad-id",
MetaData: map[string]string{
"filename": "clip.mp4",
"lib": "library",
},
},
}
close(ch)
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("watchUploadCompletions did not drain channel")
}
expected := filepath.Join(mediaRoot, "library", composeFinalName("2026-01-02", "Trip", "video.mp4"))
if _, err := os.Stat(expected); err != nil {
t.Fatalf("expected uploaded file at destination: %v", err)
}
if jf.refreshCount() == 0 {
t.Fatalf("expected upload completion to refresh Jellyfin")
}
}
func TestProcessCompletedUploadErrorPaths(t *testing.T) {
origMediaRoot := mediaRoot
origTusDir := tusDir
origDryRun := internal.DryRun
defer func() {
mediaRoot = origMediaRoot
tusDir = origTusDir
internal.DryRun = origDryRun
}()
root := t.TempDir()
mediaRoot = filepath.Join(root, "media")
tusDir = filepath.Join(root, "tus")
internal.DryRun = false
um := testUserMap(t, "map:\n brad: library\n")
jf := &fakeJellyfin{authResult: internal.AuthResult{AccessToken: "jf-token"}}
jf.authResult.User.Name = "brad"
session := sessionCookie(t, "brad", "jf-token")
t.Run("missing session", func(t *testing.T) {
ev := handler.HookEvent{}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected missing session error")
}
})
t.Run("missing mapping", func(t *testing.T) {
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{sessionCookie(t, "nobody", "token")}}},
Upload: handler.FileInfo{
ID: "missing-mapping",
MetaData: map[string]string{
"filename": "clip.mp4",
"desc": "Trip",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected missing mapping error")
}
})
t.Run("missing library root isolated", func(t *testing.T) {
origMediaRoot := mediaRoot
origTusDir := tusDir
defer func() {
mediaRoot = origMediaRoot
tusDir = origTusDir
}()
mediaRoot = filepath.Join(t.TempDir(), "media")
tusDir = filepath.Join(t.TempDir(), "tus")
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
t.Fatalf("mkdir tus dir: %v", err)
}
id := "missing-root-isolated"
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"filename": "clip.mp4",
"desc": "Trip",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected missing library root error")
}
})
t.Run("missing description for video", func(t *testing.T) {
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
t.Fatalf("mkdir media root: %v", err)
}
id := "video-no-desc"
if err := os.MkdirAll(tusDir, 0o2775); err != nil {
t.Fatalf("mkdir tus dir: %v", err)
}
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"filename": "video.mp4",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected missing desc error")
}
})
t.Run("missing library root", func(t *testing.T) {
id := "missing-root"
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"filename": "clip.mp4",
"desc": "Trip",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected missing root error")
}
})
t.Run("subdir mkdir failure", func(t *testing.T) {
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
t.Fatalf("mkdir media root: %v", err)
}
blocker := filepath.Join(mediaRoot, "library", "bad")
if err := os.WriteFile(blocker, []byte("file"), 0o644); err != nil {
t.Fatalf("write blocker file: %v", err)
}
id := "subdir-failure"
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"filename": "clip.mp4",
"desc": "Trip",
"lib": "library",
"subdir": "bad/child",
},
},
}
if err := processCompletedUpload(ev, um, jf); err == nil {
t.Fatalf("expected mkdir failure")
}
})
t.Run("non-video defaults description", func(t *testing.T) {
if err := os.MkdirAll(filepath.Join(mediaRoot, "library"), 0o2775); err != nil {
t.Fatalf("mkdir media root: %v", err)
}
id := "image-default-desc"
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"filename": "photo.jpg",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err != nil {
t.Fatalf("expected default-desc upload to succeed: %v", err)
}
expected := filepath.Join(mediaRoot, "library", composeFinalName("", "upload", "photo.jpg"))
if _, err := os.Stat(expected); err != nil {
t.Fatalf("expected defaulted upload output: %v", err)
}
})
t.Run("missing filename falls back to upload.bin", func(t *testing.T) {
id := "missing-filename"
if err := os.WriteFile(filepath.Join(tusDir, id+".bin"), []byte("payload"), 0o644); err != nil {
t.Fatalf("write tus payload: %v", err)
}
ev := handler.HookEvent{
HTTPRequest: handler.HTTPRequest{Header: http.Header{"Cookie": []string{session}}},
Upload: handler.FileInfo{
ID: id,
MetaData: map[string]string{
"desc": "Trip",
"lib": "library",
},
},
}
if err := processCompletedUpload(ev, um, jf); err != nil {
t.Fatalf("expected upload with fallback filename to succeed: %v", err)
}
expected := filepath.Join(mediaRoot, "library", composeFinalName("", "Trip", "upload.bin"))
if _, err := os.Stat(expected); err != nil {
t.Fatalf("expected fallback filename output: %v", err)
}
})
}