server_test.go
⎇
Raw
1package server
2
3import (
4 "io"
5 "net/http"
6 "net/http/httptest"
7 "net/url"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "strings"
13 "testing"
14
15 "vidarchive/internal/config"
16 "vidarchive/internal/database"
17 "vidarchive/internal/handler"
18 "vidarchive/internal/repository"
19 "vidarchive/internal/service"
20 "vidarchive/internal/worker"
21)
22
23func setupTestServer(t *testing.T) (*Server, *config.Config, func()) {
24 t.Helper()
25 dataDir := t.TempDir()
26 t.Setenv("VIDARCHIVE_DATA_DIR", dataDir)
27
28 cfg := config.New()
29 if err := os.MkdirAll(cfg.LibraryDir, 0755); err != nil {
30 t.Fatalf("create library dir: %v", err)
31 }
32 if err := os.MkdirAll(cfg.TempDir, 0755); err != nil {
33 t.Fatalf("create temp dir: %v", err)
34 }
35
36 db, err := database.New(cfg)
37 if err != nil {
38 t.Fatalf("init db: %v", err)
39 }
40
41 presetRepo := repository.NewPresetRepository(db)
42 downloadRepo := repository.NewDownloadRepository(db)
43 settingsRepo := repository.NewSettingsRepository(db)
44
45 presetSvc := service.NewPresetService(presetRepo)
46 librarySvc := service.NewLibraryService(cfg.LibraryDir)
47 settingsSvc := service.NewSettingsService(settingsRepo)
48 downloadSvc := service.NewDownloadService(downloadRepo, librarySvc, presetSvc, settingsSvc, cfg)
49 workerPool := worker.New(downloadSvc, cfg.Workers)
50
51 h, err := handler.New(cfg, presetSvc, downloadSvc, librarySvc, settingsSvc, workerPool)
52 if err != nil {
53 t.Fatalf("init handler: %v", err)
54 }
55
56 srv := New(cfg, h)
57 cleanup := func() {
58 workerPool.Stop()
59 db.Close()
60 }
61 return srv, cfg, cleanup
62}
63
64func createItem(t *testing.T, libraryDir, relPath, name string, files map[string]string) {
65 t.Helper()
66 itemDir := filepath.Join(libraryDir, relPath)
67 if err := os.MkdirAll(itemDir, 0755); err != nil {
68 t.Fatalf("create item dir: %v", err)
69 }
70 marker := filepath.Join(itemDir, ".vidarchive-item.toml")
71 if err := os.WriteFile(marker, []byte("name = \""+name+"\"\nduration = -1\n"), 0644); err != nil {
72 t.Fatalf("write marker: %v", err)
73 }
74 for filename, content := range files {
75 path := filepath.Join(itemDir, filename)
76 if err := os.WriteFile(path, []byte(content), 0644); err != nil {
77 t.Fatalf("write file %s: %v", filename, err)
78 }
79 }
80}
81
82func TestNestedLibraryItem(t *testing.T) {
83 srv, cfg, cleanup := setupTestServer(t)
84 defer cleanup()
85
86 createItem(t, cfg.LibraryDir, "test/My Item [id]", "My Item", map[string]string{
87 "My Item [id].mp4": "dummy video",
88 })
89
90 router := srv.Router()
91
92 req := httptest.NewRequest("GET", "/library/item/test/My%20Item%20%5Bid%5D", nil)
93 w := httptest.NewRecorder()
94 router.ServeHTTP(w, req)
95 if w.Code != http.StatusOK {
96 body, _ := io.ReadAll(w.Body)
97 t.Errorf("expected 200, got %d: %s", w.Code, string(body))
98 }
99}
100
101// A directory whose name contains a literal '+' must round-trip: in a URL path
102// '+' is a literal plus (not a space), reachable raw or percent-encoded.
103func TestLiteralPlusInPathSegment(t *testing.T) {
104 srv, cfg, cleanup := setupTestServer(t)
105 defer cleanup()
106
107 createItem(t, cfg.LibraryDir, "C++ Tutorial", "C++ Tutorial", map[string]string{
108 "C++ Tutorial.mp4": "dummy video",
109 })
110
111 router := srv.Router()
112 for _, path := range []string{
113 "/library/item/C++%20Tutorial",
114 "/library/item/C%2B%2B%20Tutorial",
115 } {
116 req := httptest.NewRequest("GET", path, nil)
117 w := httptest.NewRecorder()
118 router.ServeHTTP(w, req)
119 if w.Code != http.StatusOK {
120 body, _ := io.ReadAll(w.Body)
121 t.Errorf("%s: expected 200, got %d: %s", path, w.Code, string(body))
122 }
123 }
124}
125
126func TestMediaFileQueryDecoding(t *testing.T) {
127 srv, cfg, cleanup := setupTestServer(t)
128 defer cleanup()
129
130 createItem(t, cfg.LibraryDir, "My Item [id]", "My Item", map[string]string{
131 "My Item [id].mp4": "dummy video",
132 "My+Other.mp4": "dummy video plus",
133 })
134
135 router := srv.Router()
136
137 tests := []struct {
138 path string
139 expected int
140 }{
141 {"/media/item/My%20Item%20%5Bid%5D?file=My+Item+%5Bid%5D.mp4", http.StatusOK},
142 {"/media/item/My%20Item%20%5Bid%5D?file=My%20Item%20%5Bid%5D.mp4", http.StatusOK},
143 {"/media/item/My%20Item%20%5Bid%5D?file=My%2BOther.mp4", http.StatusOK},
144 {"/media/item/My%20Item%20%5Bid%5D?file=missing.mp4", http.StatusNotFound},
145 }
146 for _, tc := range tests {
147 req := httptest.NewRequest("GET", tc.path, nil)
148 w := httptest.NewRecorder()
149 router.ServeHTTP(w, req)
150 if w.Code != tc.expected {
151 body, _ := io.ReadAll(w.Body)
152 t.Errorf("%s: expected %d, got %d: %s", tc.path, tc.expected, w.Code, string(body))
153 }
154 }
155}
156
157func TestPathTraversalBlocked(t *testing.T) {
158 srv, cfg, cleanup := setupTestServer(t)
159 defer cleanup()
160
161 outside := filepath.Join(cfg.DataDir, "secret")
162 if err := os.MkdirAll(outside, 0755); err != nil {
163 t.Fatalf("create outside dir: %v", err)
164 }
165 marker := filepath.Join(outside, ".vidarchive-item.toml")
166 if err := os.WriteFile(marker, []byte("name = \"secret\"\nduration = -1\n"), 0644); err != nil {
167 t.Fatalf("write marker: %v", err)
168 }
169
170 router := srv.Router()
171
172 req := httptest.NewRequest("GET", "/library/item/../secret", nil)
173 w := httptest.NewRecorder()
174 router.ServeHTTP(w, req)
175 if w.Code != http.StatusNotFound {
176 t.Errorf("expected 404 for path traversal, got %d", w.Code)
177 }
178}
179
180func TestPerFileExistingThumbnailServed(t *testing.T) {
181 srv, cfg, cleanup := setupTestServer(t)
182 defer cleanup()
183
184 // A pre-existing per-file thumbnail (<stem>.thumbnail.webp) is served for the
185 // matching ?file= request without re-extraction.
186 createItem(t, cfg.LibraryDir, "thumb-item", "Thumb Item", map[string]string{
187 "video.mp4": "dummy video",
188 "video.thumbnail.webp": "GENERATED-THUMB",
189 })
190
191 router := srv.Router()
192 req := httptest.NewRequest("GET", "/media/item/thumb-item/thumbnail?file=video.mp4", 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, got %d: %s", w.Code, string(body))
198 }
199 if body, _ := io.ReadAll(w.Body); string(body) != "GENERATED-THUMB" {
200 t.Errorf("expected the existing per-file thumbnail, got %q", string(body))
201 }
202}
203
204func TestAudioThumbnailPlaceholder(t *testing.T) {
205 srv, cfg, cleanup := setupTestServer(t)
206 defer cleanup()
207
208 createItem(t, cfg.LibraryDir, "audio-item", "Audio Item", map[string]string{
209 "song.mp3": "dummy audio",
210 })
211
212 router := srv.Router()
213 req := httptest.NewRequest("GET", "/media/item/audio-item/thumbnail", nil)
214 w := httptest.NewRecorder()
215 router.ServeHTTP(w, req)
216 if w.Code != http.StatusOK {
217 body, _ := io.ReadAll(w.Body)
218 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
219 }
220 body, _ := io.ReadAll(w.Body)
221 if len(body) == 0 {
222 t.Errorf("placeholder thumbnail body was empty")
223 }
224}
225
226func TestMultiFileCardThumbnailURLsDecodeToFilenames(t *testing.T) {
227 srv, cfg, cleanup := setupTestServer(t)
228 defer cleanup()
229
230 // Filenames with spaces are the case that broke: the template must emit a
231 // query value that the handler decodes back to the exact filename (the bug
232 // was double-escaping spaces to %2b, which decodes to '+').
233 files := map[string]string{
234 "01 - Color Bars.mp4": "v",
235 "02 - Test Pattern.mp4": "v",
236 }
237 createItem(t, cfg.LibraryDir, "multi", "Multi", files)
238
239 req := httptest.NewRequest("GET", "/library", nil)
240 w := httptest.NewRecorder()
241 srv.Router().ServeHTTP(w, req)
242 body, _ := io.ReadAll(w.Body)
243
244 re := regexp.MustCompile(`thumbnail\?file=([^"]+)`)
245 matches := re.FindAllStringSubmatch(string(body), -1)
246 if len(matches) != len(files) {
247 t.Fatalf("expected %d per-file thumbnail URLs in the card, got %d", len(files), len(matches))
248 }
249 for _, m := range matches {
250 vals, err := url.ParseQuery("file=" + m[1])
251 if err != nil {
252 t.Fatalf("bad query %q: %v", m[1], err)
253 }
254 got := vals.Get("file")
255 if _, ok := files[got]; !ok {
256 t.Errorf("thumbnail file=%q decodes to %q, which is not a real filename (double-encoding regression)", m[1], got)
257 }
258 }
259}
260
261func TestListingDoesNotExtractThumbnails(t *testing.T) {
262 srv, cfg, cleanup := setupTestServer(t)
263 defer cleanup()
264
265 createItem(t, cfg.LibraryDir, "novid", "No Thumb", map[string]string{
266 "video.mp4": "dummy video",
267 })
268
269 router := srv.Router()
270 req := httptest.NewRequest("GET", "/library", nil)
271 w := httptest.NewRecorder()
272 router.ServeHTTP(w, req)
273 if w.Code != http.StatusOK {
274 t.Fatalf("expected 200, got %d", w.Code)
275 }
276
277 // Rendering the listing must not have created any thumbnail file.
278 entries, err := os.ReadDir(filepath.Join(cfg.LibraryDir, "novid"))
279 if err != nil {
280 t.Fatal(err)
281 }
282 for _, e := range entries {
283 if strings.Contains(e.Name(), ".thumbnail.") {
284 t.Errorf("listing extracted a thumbnail (%q) — should happen on request only", e.Name())
285 }
286 }
287}
288
289func TestGeneratedThumbnailServedOverIcon(t *testing.T) {
290 srv, cfg, cleanup := setupTestServer(t)
291 defer cleanup()
292
293 createItem(t, cfg.LibraryDir, "gen", "Gen", map[string]string{
294 "video.mp4": "dummy video",
295 "video.thumbnail.webp": "WEBPDATA",
296 })
297
298 router := srv.Router()
299 req := httptest.NewRequest("GET", "/media/item/gen/thumbnail?file=video.mp4", nil)
300 w := httptest.NewRecorder()
301 router.ServeHTTP(w, req)
302 if w.Code != http.StatusOK {
303 t.Fatalf("expected 200, got %d", w.Code)
304 }
305 if body, _ := io.ReadAll(w.Body); string(body) != "WEBPDATA" {
306 t.Errorf("expected generated thumbnail contents, got %q", string(body))
307 }
308}
309
310func TestThumbnailExtractedOnRequest(t *testing.T) {
311 if _, err := exec.LookPath("ffmpeg"); err != nil {
312 t.Skip("ffmpeg not on PATH")
313 }
314 srv, cfg, cleanup := setupTestServer(t)
315 defer cleanup()
316
317 itemDir := filepath.Join(cfg.LibraryDir, "realvid")
318 createItem(t, cfg.LibraryDir, "realvid", "Real", nil)
319 cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
320 "-f", "lavfi", "-i", "testsrc=duration=3:size=64x64:rate=5",
321 "-pix_fmt", "yuv420p", filepath.Join(itemDir, "realvid.mp4"), "-y")
322 if out, err := cmd.CombinedOutput(); err != nil {
323 t.Fatalf("make test video: %v\n%s", err, out)
324 }
325
326 router := srv.Router()
327 req := httptest.NewRequest("GET", "/media/item/realvid/thumbnail?file=realvid.mp4", nil)
328 w := httptest.NewRecorder()
329 router.ServeHTTP(w, req)
330 if w.Code != http.StatusOK {
331 t.Fatalf("expected 200, got %d", w.Code)
332 }
333 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
334 t.Errorf("expected image content-type, got %q", ct)
335 }
336 body, _ := io.ReadAll(w.Body)
337 if len(body) == 0 {
338 t.Error("served thumbnail body was empty")
339 }
340
341 // A real thumbnail file should now exist on disk, with no temp leftovers.
342 entries, _ := os.ReadDir(itemDir)
343 var found bool
344 for _, e := range entries {
345 if strings.Contains(e.Name(), ".thumbnail.") {
346 found = true
347 }
348 if strings.Contains(e.Name(), ".tmp") {
349 t.Errorf("leftover temp file %q", e.Name())
350 }
351 }
352 if !found {
353 t.Error("no thumbnail file written to disk after request")
354 }
355}
356
357func TestLibraryPageIsFast(t *testing.T) {
358 srv, cfg, cleanup := setupTestServer(t)
359 defer cleanup()
360
361 for i := 0; i < 50; i++ {
362 createItem(t, cfg.LibraryDir, "item-"+string(rune('a'+i)), "Item", map[string]string{
363 "video.mp4": "dummy",
364 })
365 }
366
367 router := srv.Router()
368 req := httptest.NewRequest("GET", "/library", nil)
369 w := httptest.NewRecorder()
370 router.ServeHTTP(w, req)
371 if w.Code != http.StatusOK {
372 body, _ := io.ReadAll(w.Body)
373 t.Fatalf("expected 200, got %d: %s", w.Code, string(body))
374 }
375}
376