server_test.go
⎇
Raw
1package server
2
3import (
4 "database/sql"
5 "html"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "regexp"
14 "strings"
15 "testing"
16
17 "vidarchive/internal/config"
18 "vidarchive/internal/database"
19 "vidarchive/internal/handler"
20 "vidarchive/internal/repository"
21 "vidarchive/internal/service"
22 "vidarchive/internal/worker"
23)
24
25func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
26 srv, cfg, _, cleanup := setupTestServerDB(t)
27 return srv, cfg, cleanup
28}
29
30// setupTestServerDB is setupTestServer with the database handle exposed, for
31// tests that need to break the database on purpose.
32func setupTestServerDB(t *testing.T) (*Server, *config.Config, *sql.DB, func()) {
33 t.Helper()
34 dataDir := t.TempDir()
35 t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
36
37 cfg := config.New()
38 if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
39 t.Fatalf("create library dir: %v", err)
40 }
41 if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
42 t.Fatalf("create temp dir: %v", err)
43 }
44
45 db, err := database.New(cfg)
46 if err != nil {
47 t.Fatalf("init db: %v", err)
48 }
49
50 presetRepo := repository.NewPresetRepository(db)
51 downloadRepo := repository.NewDownloadRepository(db)
52 settingsRepo := repository.NewSettingsRepository(db)
53 subscriptionRepo := repository.NewSubscriptionRepository(db)
54
55 presetSvc := service.NewPresetService(presetRepo)
56 librarySvc := service.NewLibraryService(cfg.LibraryDir, cfg.FFmpegPath, cfg.FFprobePath)
57 settingsSvc := service.NewSettingsService(settingsRepo)
58 subscriptionSvc := service.NewSubscriptionService(subscriptionRepo, cfg)
59 downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, subscriptionSvc, cfg)
60 workerPool := worker.New(downloadSvc, cfg.Workers)
61
62 h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, subscriptionSvc, workerPool)
63 if err != nil {
64 t.Fatalf("init handler: %v", err)
65 }
66
67 srv := New(cfg, h)
68 cleanup := func() {
69 workerPool.Stop()
70 db.Close()
71 }
72 return srv, cfg, db, cleanup
73}
74
75func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
76 t.Helper()
77 itemDir := filepath.Join(libraryDir, relPath)
78 if err := os.MkdirAll(itemDir, 0755); err != nil {
79 t.Fatalf("create item dir: %v", err)
80 }
81 marker := filepath.Join(itemDir, ".vidarchive-item.toml")
82 if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil {
83 t.Fatalf("write marker: %v", err)
84 }
85 for filename, content := range files {
86 path := filepath.Join(itemDir, filename)
87 if err := os.WriteFile(path, []byte(content), 0644); err != nil {
88 t.Fatalf("write file %s: %v", filename, err)
89 }
90 }
91}
92
93func TestNestedLibraryItem(t *testing.T) {
94 srv, cfg, cleanup := setupTestServer(t)
95 defer cleanup()
96
97 createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
98 "My Item [id].mp4": "dummy video",
99 })
100
101 router := srv.Router()
102
103 req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil)
104 w := httptest.NewRecorder()
105 router.ServeHTTP(w, req)
106 if w.Code != http.StatusOK {
107 body, _ := io.ReadAll(w.Body)
108 t.Errorf("expected 200, got %d: %s", w.Code, string(body))
109 }
110}
111
112// A directory whose name contains a literal '+' must round-trip: in a URL path
113// '+' is a literal plus (not a space), reachable raw or percent-encoded.
114func TestLiteralPlusInPathSegment(t *testing.T) {
115 srv, cfg, cleanup := setupTestServer(t)
116 defer cleanup()
117
118 createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{
119 "C++ Tutorial.mp4": "dummy video",
120 })
121
122 router := srv.Router()
123 for _, path := range []string{
124 "/library/item/C++%20Tutorial",
125 "/library/item/C%2B%2B%20Tutorial",
126 } {
127 req := httptest.NewRequest("GET", path, nil)
128 w := httptest.NewRecorder()
129 router.ServeHTTP(w, req)
130 if w.Code != http.StatusOK {
131 body, _ := io.ReadAll(w.Body)
132 t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
133 }
134 }
135}
136
137func TestMediaFileQueryDecoding(t *testing.T) {
138 srv, cfg, cleanup := setupTestServer(t)
139 defer cleanup()
140
141 createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
142 "My Item [id].mp4": "dummy video",
143 "My+Other.mp4": "dummy video plus",
144 })
145
146 router := srv.Router()
147
148 tests := []struct {
149 path string
150 expected int
151 }{
152 {"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
153 {"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
154 {"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
155 {"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
156 }
157 for _, tc := range tests {
158 req := httptest.NewRequest("GET", tc.path, nil)
159 w := httptest.NewRecorder()
160 router.ServeHTTP(w, req)
161 if w.Code != tc.expected {
162 body, _ := io.ReadAll(w.Body)
163 t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
164 }
165 }
166}
167
168// TestSubtitleServedByPathSegment guards the regression where subtitle tracks
169// are linked as <item>/subtitles/<lang> (language as a trailing path segment),
170// but the handler only recognized the "/subtitles" suffix with a ?lang= query.
171// The path form fell through to media serving and returned 400 "Missing file".
172func TestSubtitleServedByPathSegment(t *testing.T) {
173 srv, cfg, cleanup := setupTestServer(t)
174 defer cleanup()
175
176 // An item whose name contains non-ASCII + spaces, like the reported URL.
177 const item = "ずんだパーリナイ ⧸ なみぐる [ywXQ9SqsaBQ]"
178 createItem(t, cfg.LibraryDir, item, "Vid", map[string]string{
179 "video.mp4": "dummy video",
180 })
181 vtt := "WEBVTT\n\n00:00:00.000 --> 00:00:01.000\nhi\n"
182 subDir := filepath.Join(cfg.LibraryDir, item, "subtitles")
183 if err := os.MkdirAll(subDir, 0755); err != nil {
184 t.Fatal(err)
185 }
186 if err := os.WriteFile(filepath.Join(subDir, "eng.vtt"), []byte(vtt), 0644); err != nil {
187 t.Fatal(err)
188 }
189
190 router := srv.Router()
191 reqURL := "/media/item/" + (&url.URL{Path: item}).EscapedPath() + "/subtitles/eng"
192 req := httptest.NewRequest("GET", reqURL, nil)
193 w := httptest.NewRecorder()
194 router.ServeHTTP(w, req)
195 if w.Code != http.StatusOK {
196 body, _ := io.ReadAll(w.Body)
197 t.Fatalf("expected 200 for %s, got %d: %s", reqURL, w.Code, string(body))
198 }
199 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/vtt") {
200 t.Errorf("expected text/vtt content-type, got %q", ct)
201 }
202 if body, _ := io.ReadAll(w.Body); !strings.Contains(string(body), "WEBVTT") {
203 t.Errorf("expected the .vtt contents, got %q", string(body))
204 }
205
206 // A traversal attempt in the language segment must be rejected, not served.
207 bad := httptest.NewRequest("GET", "/media/item/"+(&url.URL{Path: item}).EscapedPath()+"/subtitles/..%2f..%2fsecret", nil)
208 bw := httptest.NewRecorder()
209 router.ServeHTTP(bw, bad)
210 if bw.Code == http.StatusOK {
211 t.Errorf("traversal in language segment was served (status %d)", bw.Code)
212 }
213}
214
215func TestPathTraversalBlocked(t *testing.T) {
216 srv, cfg, cleanup := setupTestServer(t)
217 defer cleanup()
218
219 outside := filepath.Join(cfg.DataDir, "secret")
220 if err := os.MkdirAll(outside, 0755); err != nil {
221 t.Fatalf("create outside dir: %v", err)
222 }
223 marker := filepath.Join(outside, ".vidarchive-item.toml")
224 if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil {
225 t.Fatalf("write marker: %v", err)
226 }
227
228 router := srv.Router()
229
230 req := httptest.NewRequest("GET", "/library/item/../secret", nil)
231 w := httptest.NewRecorder()
232 router.ServeHTTP(w, req)
233 if w.Code != http.StatusNotFound {
234 t.Errorf("expected 404 for path traversal, got %d", w.Code)
235 }
236}
237
238func TestPerFileExistingThumbnailServed(t *testing.T) {
239 srv, cfg, cleanup := setupTestServer(t)
240 defer cleanup()
241
242 // A pre-existing per-file thumbnail (<stem>.thumbnail.webp) is served for the
243 // matching ?file= request without re-extraction.
244 createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
245 "video.mp4": "dummy video",
246 "video.thumbnail.webp": "GENERATED-THUMB",
247 })
248
249 router := srv.Router()
250 req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", nil)
251 w := httptest.NewRecorder()
252 router.ServeHTTP(w, req)
253 if w.Code != http.StatusOK {
254 body, _ := io.ReadAll(w.Body)
255 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
256 }
257 if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
258 t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
259 }
260}
261
262func TestAudioThumbnailPlaceholder(t *testing.T) {
263 srv, cfg, cleanup := setupTestServer(t)
264 defer cleanup()
265
266 createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
267 "song.mp3": "dummy audio",
268 })
269
270 router := srv.Router()
271 req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
272 w := httptest.NewRecorder()
273 router.ServeHTTP(w, req)
274 if w.Code != http.StatusOK {
275 body, _ := io.ReadAll(w.Body)
276 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
277 }
278 body, _ := io.ReadAll(w.Body)
279 if len(body) == 0 {
280 t.Errorf("placeholder thumbnail body was empty")
281 }
282}
283
284func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
285 srv, cfg, cleanup := setupTestServer(t)
286 defer cleanup()
287
288 // Filenames with spaces are the case that broke: the template must emit a
289 // query value that the handler decodes back to the exact filename (the bug
290 // was double-escaping spaces to %2b, which decodes to '+').
291 files := map[string]string{
292 "01 - Color Bars.mp4": "v",
293 "02 - Test Pattern.mp4": "v",
294 }
295 createItem(t, cfg.LibraryDir, "multi", "Multi", files)
296
297 req := httptest.NewRequest("GET", "/library", nil)
298 w := httptest.NewRecorder()
299 srv.Router().ServeHTTP(w, req)
300 body, _ := io.ReadAll(w.Body)
301
302 re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
303 matches := re.FindAllStringSubmatch(string(body), -1)
304 if len(matches) != len(files) {
305 t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
306 }
307 for _, m := range matches {
308 vals, err := url.ParseQuery("file=" + m[1])
309 if err != nil {
310 t.Fatalf("bad query %q: %v", m[1], err)
311 }
312 got := vals.Get("file")
313 if _, ok := files[got]; !ok {
314 t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
315 }
316 }
317}
318
319// TestNestedFolderLinkRoundTrip guards the double-encoding regression: a folder
320// whose name contains a space was linked with urlEncodePath *inside* a ?path=
321// query, which html/template then re-escaped (%20 -> %2520). Clicking the link
322// landed on a path the server decoded to "playlist%20test%202" — a directory
323// that doesn't exist — so the folder rendered empty and the breadcrumb showed
324// the literal "%20". The link must round-trip: its decoded ?path must be the
325// real directory, the item inside must render, and the breadcrumb must show the
326// human-readable name.
327func TestNestedFolderLinkRoundTrip(t *testing.T) {
328 srv, cfg, cleanup := setupTestServer(t)
329 defer cleanup()
330
331 createItem(t, cfg.LibraryDir, "subs/playlist test 2/Vid One", "Vid One", map[string]string{
332 "video.mp4": "dummy video",
333 })
334 router := srv.Router()
335
336 // List the parent and pull out the generated folder link.
337 req := httptest.NewRequest("GET", "/library?path=subs", nil)
338 w := httptest.NewRecorder()
339 router.ServeHTTP(w, req)
340 if w.Code != http.StatusOK {
341 t.Fatalf("list /library?path=subs: got %d", w.Code)
342 }
343 body := w.Body.String()
344
345 folderHref := regexp.MustCompile(`href="(/library\?path=[^"]+)" class="folder-item"`).FindStringSubmatch(body)
346 if folderHref == nil {
347 t.Fatalf("no folder link rendered for nested folder; body:\n%s", body)
348 }
349 href := html.UnescapeString(folderHref[1])
350
351 // The link's decoded ?path must be the real directory, not a still-encoded one.
352 u, err := url.Parse(href)
353 if err != nil {
354 t.Fatalf("parse folder href %q: %v", href, err)
355 }
356 if got := u.Query().Get("path"); got != "subs/playlist test 2" {
357 t.Fatalf("folder link path decodes to %q, want %q (double-encoding regression)", got, "subs/playlist test 2")
358 }
359
360 // Follow the link exactly as a browser would. The folder must not be empty,
361 // and the breadcrumb must show the readable name (never the encoded form).
362 req = httptest.NewRequest("GET", href, nil)
363 w = httptest.NewRecorder()
364 router.ServeHTTP(w, req)
365 if w.Code != http.StatusOK {
366 t.Fatalf("follow folder link %q: got %d", href, w.Code)
367 }
368 nested := w.Body.String()
369 if !strings.Contains(nested, "Vid One") {
370 t.Errorf("nested folder rendered empty — item 'Vid One' missing; body:\n%s", nested)
371 }
372 // The breadcrumb must display the readable name, not the percent-encoded form.
373 if !strings.Contains(nested, ">playlist test 2<") {
374 t.Errorf("breadcrumb missing readable folder name 'playlist test 2'")
375 }
376 if strings.Contains(nested, ">playlist%20test%202<") {
377 t.Errorf("breadcrumb displays the encoded name instead of a space (regression)")
378 }
379 // No link may carry a double-encoded path (%2520 == %25 + 20 == re-escaped %20).
380 if strings.Contains(nested, "%2520") {
381 t.Errorf("a link is double-encoded (%%2520) — urlEncodePath inside a ?path= query (regression)")
382 }
383}
384
385func TestListingDoesNotExtractThumbnails(t *testing.T) {
386 srv, cfg, cleanup := setupTestServer(t)
387 defer cleanup()
388
389 createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
390 "video.mp4": "dummy video",
391 })
392
393 router := srv.Router()
394 req := httptest.NewRequest("GET", "/library", nil)
395 w := httptest.NewRecorder()
396 router.ServeHTTP(w, req)
397 if w.Code != http.StatusOK {
398 t.Fatalf("expected 200, got %d", w.Code)
399 }
400
401 // Rendering the listing must not have created any thumbnail file.
402 entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
403 if err != nil {
404 t.Fatal(err)
405 }
406 for _, e := range entries {
407 if strings.Contains(e.Name(), ".thumbnail.") {
408 t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
409 }
410 }
411}
412
413func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
414 srv, cfg, cleanup := setupTestServer(t)
415 defer cleanup()
416
417 createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
418 "video.mp4": "dummy video",
419 "video.thumbnail.webp": "WEBPDATA",
420 })
421
422 router := srv.Router()
423 req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
424 w := httptest.NewRecorder()
425 router.ServeHTTP(w, req)
426 if w.Code != http.StatusOK {
427 t.Fatalf("expected 200, got %d", w.Code)
428 }
429 if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
430 t.Errorf("expected generated thumbnail contents, got %q", string(body))
431 }
432}
433
434func TestThumbnailExtractedOnRequest(t *testing.T) {
435 if _, err := exec.LookPath("ffmpeg"); err != nil {
436 t.Skip("ffmpeg not on PATH")
437 }
438 srv, cfg, cleanup := setupTestServer(t)
439 defer cleanup()
440
441 itemDir := filepath.Join(cfg.LibraryDir, "realvid")
442 createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
443 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
444 "-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
445 "-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
446 if out, err := cmd.CombinedOutput(); err != nil {
447 t.Fatalf("make test video: %v\n%s", err, out)
448 }
449
450 router := srv.Router()
451 req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
452 w := httptest.NewRecorder()
453 router.ServeHTTP(w, req)
454 if w.Code != http.StatusOK {
455 t.Fatalf("expected 200, got %d", w.Code)
456 }
457 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
458 t.Errorf("expected image content-type, got %q", ct)
459 }
460 body, _ := io.ReadAll(w.Body)
461 if len(body) == 0 {
462 t.Error("served thumbnail body was empty")
463 }
464
465 // A real thumbnail file should now exist on disk, with no temp leftovers.
466 entries, _ := os.ReadDir(itemDir)
467 var found bool
468 for _, e := range entries {
469 if strings.Contains(e.Name(), ".thumbnail.") {
470 found = true
471 }
472 if strings.Contains(e.Name(), ".tmp") {
473 t.Errorf("leftover temp file %q", e.Name())
474 }
475 }
476 if !found {
477 t.Error("no thumbnail file written to disk after request")
478 }
479}
480
481func TestLibraryPageIsFast(t *testing.T) {
482 srv, cfg, cleanup := setupTestServer(t)
483 defer cleanup()
484
485 for i := 0; i < 50; i++ {
486 createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
487 "video.mp4": "dummy",
488 })
489 }
490
491 router := srv.Router()
492 req := httptest.NewRequest("GET", "/library", nil)
493 w := httptest.NewRecorder()
494 router.ServeHTTP(w, req)
495 if w.Code != http.StatusOK {
496 body, _ := io.ReadAll(w.Body)
497 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
498 }
499}
500