527 lines
15 KiB
Go
527 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|