package server import ( "io" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "vidarchive/internal/config" "vidarchive/internal/database" "vidarchive/internal/handler" "vidarchive/internal/repository" "vidarchive/internal/service" "vidarchive/internal/worker" ) func setupTestServer(t *testing.T) (*Server, *config.Config, func()) { t.Helper() dataDir := t.TempDir() t.Setenv("VIDARCHIVE_DATA_DIR", dataDir) cfg := config.New() if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil { t.Fatalf("create library dir: %v", err) } if err := os.MkdirAll(cfg.TempDir, 0755); err != nil { t.Fatalf("create temp dir: %v", err) } db, err := database.New(cfg) if err != nil { t.Fatalf("init db: %v", err) } presetRepo := repository.NewPresetRepository(db) downloadRepo := repository.NewDownloadRepository(db) settingsRepo := repository.NewSettingsRepository(db) presetSvc := service.NewPresetService(presetRepo) librarySvc := service.NewLibraryService(cfg.LibraryDir) settingsSvc := service.NewSettingsService(settingsRepo) downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg) workerPool := worker.New(downloadSvc, cfg.Workers) h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, workerPool) if err != nil { t.Fatalf("init handler: %v", err) } srv := New(cfg, h) cleanup := func() { workerPool.Stop() db.Close() } return srv, cfg, cleanup } func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) { t.Helper() itemDir := filepath.Join(libraryDir, relPath) if err := os.MkdirAll(itemDir, 0755); err != nil { t.Fatalf("create item dir: %v", err) } marker := filepath.Join(itemDir, ".vidarchive-item.toml") if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil { t.Fatalf("write marker: %v", err) } for filename, content := range files { path := filepath.Join(itemDir, filename) if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("write file %s: %v", filename, err) } } } func TestNestedLibraryItem(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{ "My Item [id].mp4": "dummy video", }) router := srv.Router() req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { body, _ := io.ReadAll(w.Body) t.Errorf("expected 200, got %d: %s", w.Code, string(body)) } } // A directory whose name contains a literal '+' must round-trip: in a URL path // '+' is a literal plus (not a space), reachable raw or percent-encoded. func TestLiteralPlusInPathSegment(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{ "C++ Tutorial.mp4": "dummy video", }) router := srv.Router() for _, path := range []string{ "/library/item/C++%20Tutorial", "/library/item/C%2B%2B%20Tutorial", } { req := httptest.NewRequest("GET", path, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { body, _ := io.ReadAll(w.Body) t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body)) } } } func TestMediaFileQueryDecoding(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{ "My Item [id].mp4": "dummy video", "My+Other.mp4": "dummy video plus", }) router := srv.Router() tests := []struct { path string expected int }{ {"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK}, {"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK}, {"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK}, {"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound}, } for _, tc := range tests { req := httptest.NewRequest("GET", tc.path, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != tc.expected { body, _ := io.ReadAll(w.Body) t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body)) } } } func TestPathTraversalBlocked(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() outside := filepath.Join(cfg.DataDir, "secret") if err := os.MkdirAll(outside, 0755); err != nil { t.Fatalf("create outside dir: %v", err) } marker := filepath.Join(outside, ".vidarchive-item.toml") if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil { t.Fatalf("write marker: %v", err) } router := srv.Router() req := httptest.NewRequest("GET", "/library/item/../secret", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404 for path traversal, got %d", w.Code) } } func TestPerFileExistingThumbnailServed(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() // A pre-existing per-file thumbnail (.thumbnail.webp) is served for the // matching ?file= request without re-extraction. createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{ "video.mp4": "dummy video", "video.thumbnail.webp": "GENERATED-THUMB", }) router := srv.Router() req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { body, _ := io.ReadAll(w.Body) t.Fatalf("expected 200, got %d: %s", w.Code, string(body)) } if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" { t.Errorf("expected the existing per-file thumbnail, got %q", string(body)) } } func TestAudioThumbnailPlaceholder(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{ "song.mp3": "dummy audio", }) router := srv.Router() req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { body, _ := io.ReadAll(w.Body) t.Fatalf("expected 200, got %d: %s", w.Code, string(body)) } body, _ := io.ReadAll(w.Body) if len(body) == 0 { t.Errorf("placeholder thumbnail body was empty") } } func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() // Filenames with spaces are the case that broke: the template must emit a // query value that the handler decodes back to the exact filename (the bug // was double-escaping spaces to %2b, which decodes to '+'). files := map[string]string{ "01 - Color Bars.mp4": "v", "02 - Test Pattern.mp4": "v", } createItem(t, cfg.LibraryDir, "multi", "Multi", files) req := httptest.NewRequest("GET", "/library", nil) w := httptest.NewRecorder() srv.Router().ServeHTTP(w, req) body, _ := io.ReadAll(w.Body) re := regexp.MustCompile(`thumbnail\?file=([^"]+)`) matches := re.FindAllStringSubmatch(string(body), -1) if len(matches) != len(files) { t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches)) } for _, m := range matches { vals, err := url.ParseQuery("file=" + m[1]) if err != nil { t.Fatalf("bad query %q: %v", m[1], err) } got := vals.Get("file") if _, ok := files[got]; !ok { t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got) } } } func TestListingDoesNotExtractThumbnails(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{ "video.mp4": "dummy video", }) router := srv.Router() req := httptest.NewRequest("GET", "/library", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } // Rendering the listing must not have created any thumbnail file. entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid")) if err != nil { t.Fatal(err) } for _, e := range entries { if strings.Contains(e.Name(), ".thumbnail.") { t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name()) } } } func TestGeneratedThumbnailServedOverIcon(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{ "video.mp4": "dummy video", "video.thumbnail.webp": "WEBPDATA", }) router := srv.Router() req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" { t.Errorf("expected generated thumbnail contents, got %q", string(body)) } } func TestThumbnailExtractedOnRequest(t *testing.T) { if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("ffmpeg not on PATH") } srv, cfg, cleanup := setupTestServer(t) defer cleanup() itemDir := filepath.Join(cfg.LibraryDir, "realvid") createItem(t, cfg.LibraryDir, "realvid", "Real", nil) cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5", "-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("make test video: %v\n%s", err, out) } router := srv.Router() req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") { t.Errorf("expected image content-type, got %q", ct) } body, _ := io.ReadAll(w.Body) if len(body) == 0 { t.Error("served thumbnail body was empty") } // A real thumbnail file should now exist on disk, with no temp leftovers. entries, _ := os.ReadDir(itemDir) var found bool for _, e := range entries { if strings.Contains(e.Name(), ".thumbnail.") { found = true } if strings.Contains(e.Name(), ".tmp") { t.Errorf("leftover temp file %q", e.Name()) } } if !found { t.Error("no thumbnail file written to disk after request") } } func TestLibraryPageIsFast(t *testing.T) { srv, cfg, cleanup := setupTestServer(t) defer cleanup() for i := 0; i < 50; i++ { createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{ "video.mp4": "dummy", }) } router := srv.Router() req := httptest.NewRequest("GET", "/library", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) if w.Code != http.StatusOK { body, _ := io.ReadAll(w.Body) t.Fatalf("expected 200, got %d: %s", w.Code, string(body)) } }