package server import ( "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "testing/fstest" "git.unkin.net/unkin/mediamark/internal/arr" "git.unkin.net/unkin/mediamark/internal/config" "git.unkin.net/unkin/mediamark/internal/library" ) const upstreamKey = "upstream-secret-key" // testEnv is a fully wired server over a temp media root and a fake *arr. type testEnv struct { h http.Handler root string lib *library.Library } func newEnv(t *testing.T, arrHandler http.HandlerFunc) *testEnv { t.Helper() root := t.TempDir() for _, tree := range []string{library.SourceTree, library.KidsTree} { for _, s := range library.Sections { if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil { t.Fatal(err) } } } arrs := map[library.Section]*arr.Client{} if arrHandler != nil { srv := httptest.NewServer(arrHandler) t.Cleanup(srv.Close) key := func(string) (string, bool) { return upstreamKey, true } sonarr := arr.New(arr.KindSonarr, srv.URL, key) radarr := arr.New(arr.KindRadarr, srv.URL, key) sonarr.SetCacheTTL(0) radarr.SetCacheTTL(0) arrs[library.SectionTVSeries] = sonarr arrs[library.SectionMovies] = radarr } cfg := &config.Config{ MediaRoot: root, GroupsHeader: "X-Forwarded-Groups", AllowedGroups: []string{"akP-mediamark-user"}, } lib := library.New(root) assets := fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte("mediamark")}, "app.js": &fstest.MapFile{Data: []byte("// js")}, } log := slog.New(slog.NewTextHandler(io.Discard, nil)) return &testEnv{h: New(cfg, lib, arrs, assets, log).Handler(), root: root, lib: lib} } // do issues an authorized request. func (e *testEnv) do(t *testing.T, method, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, nil) req.Header.Set("X-Forwarded-Groups", "akP-other,akP-mediamark-user") rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } func (e *testEnv) writeTitle(t *testing.T, section library.Section, name, rel, content string) { t.Helper() p := filepath.Join(e.root, library.SourceTree, string(section), name, rel) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } } func TestHealthProbesAreUngated(t *testing.T) { e := newEnv(t, nil) for _, p := range []string{"/livez", "/readyz"} { rec := httptest.NewRecorder() e.h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil)) if rec.Code != http.StatusOK { t.Fatalf("%s = %d, want 200", p, rec.Code) } } } func TestReadyzFailsWhenMediaRootMissing(t *testing.T) { cfg := &config.Config{MediaRoot: "/definitely/not/here", GroupsHeader: "X-G", AllowedGroups: []string{"g"}} h := New(cfg, library.New(cfg.MediaRoot), nil, fstest.MapFS{}, slog.New(slog.NewTextHandler(io.Discard, nil))).Handler() rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if rec.Code != http.StatusServiceUnavailable { t.Fatalf("readyz = %d, want 503", rec.Code) } } // Every gated surface — API, mutations, art, and the page shell — must 403 for // a caller outside the allowed group. func TestUnauthorizedGroupIsRejectedEverywhere(t *testing.T) { e := newEnv(t, nil) paths := []struct{ method, path string }{ {http.MethodGet, "/"}, {http.MethodGet, "/movies"}, {http.MethodGet, "/api/library/movies"}, {http.MethodPost, "/api/library/movies/Nemo/mark"}, {http.MethodPost, "/api/library/movies/Nemo/unmark"}, {http.MethodGet, "/api/art/movies/1"}, } for _, p := range paths { for _, groups := range []string{"", "akP-somethingelse"} { req := httptest.NewRequest(p.method, p.path, nil) if groups != "" { req.Header.Set("X-Forwarded-Groups", groups) } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Errorf("%s %s (groups=%q) = %d, want 403", p.method, p.path, groups, rec.Code) } } } } func TestListJSONContract(t *testing.T) { e := newEnv(t, func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Api-Key") != upstreamKey { t.Errorf("upstream request missing key") } _, _ = io.WriteString(w, `[ {"id":9,"title":"Finding Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"} ]`) }) e.writeTitle(t, library.SectionMovies, "Finding Nemo (2003)", "movie.mkv", "0123456789") e.writeTitle(t, library.SectionMovies, "Unknown Film", "movie.mkv", "ab") rec := e.do(t, http.MethodGet, "/api/library/movies") if rec.Code != http.StatusOK { t.Fatalf("status = %d body %s", rec.Code, rec.Body) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type = %q", ct) } var body struct { Section string `json:"section"` Titles []struct { Name string `json:"name"` Marked bool `json:"marked"` NeedsSync bool `json:"needsSync"` SizeBytes int64 `json:"sizeBytes"` FileCount int `json:"fileCount"` ArtURL string `json:"artUrl"` Arr *struct { ID int `json:"id"` Title string `json:"title"` Year int `json:"year"` } `json:"arr"` } `json:"titles"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } if body.Section != "movies" || len(body.Titles) != 2 { t.Fatalf("body = %+v", body) } // Sorted case-insensitively: "Finding Nemo (2003)" then "Unknown Film". matched, unmatched := body.Titles[0], body.Titles[1] if matched.Arr == nil || matched.Arr.ID != 9 || matched.Arr.Year != 2003 { t.Fatalf("matched title missing arr metadata: %+v", matched) } if matched.ArtURL != "/api/art/movies/9" { t.Errorf("artUrl = %q", matched.ArtURL) } if matched.SizeBytes != 10 || matched.FileCount != 1 || matched.Marked { t.Errorf("matched stats = %+v", matched) } if unmatched.Arr != nil || unmatched.ArtURL != "" { t.Errorf("unmatched title should carry no arr metadata: %+v", unmatched) } // The upstream key must never surface in the response. if strings.Contains(rec.Body.String(), upstreamKey) { t.Fatal("list response leaked the upstream api key") } } func TestListDegradesWhenArrIsDown(t *testing.T) { e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "boom "+upstreamKey, http.StatusInternalServerError) }) e.writeTitle(t, library.SectionMovies, "Nemo", "movie.mkv", "x") rec := e.do(t, http.MethodGet, "/api/library/movies") if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 with degraded metadata", rec.Code) } if !strings.Contains(rec.Body.String(), `"name":"Nemo"`) { t.Fatalf("title missing from degraded list: %s", rec.Body) } if strings.Contains(rec.Body.String(), upstreamKey) { t.Fatal("degraded list leaked the upstream api key") } } func TestMarkAndUnmarkRoundTrip(t *testing.T) { e := newEnv(t, nil) e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e01.mkv", "one") e.writeTitle(t, library.SectionTVSeries, "Bluey", "s01/e02.mkv", "two") rec := e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark") if rec.Code != http.StatusOK { t.Fatalf("mark = %d body %s", rec.Code, rec.Body) } var marked struct { Title library.Title `json:"title"` Sync library.SyncResult `json:"sync"` } if err := json.Unmarshal(rec.Body.Bytes(), &marked); err != nil { t.Fatal(err) } if !marked.Title.Marked || marked.Title.NeedsSync || marked.Sync.Linked != 2 { t.Fatalf("mark result = %+v", marked) } if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey", "s01", "e01.mkv")); err != nil { t.Fatalf("hardlink missing: %v", err) } // Marking again is idempotent. rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/mark") if rec.Code != http.StatusOK { t.Fatalf("second mark = %d", rec.Code) } rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark") if rec.Code != http.StatusOK { t.Fatalf("unmark = %d body %s", rec.Code, rec.Body) } var unmarked struct { Title library.Title `json:"title"` } if err := json.Unmarshal(rec.Body.Bytes(), &unmarked); err != nil { t.Fatal(err) } if unmarked.Title.Marked { t.Fatalf("still marked after unmark: %+v", unmarked) } if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "tvseries", "Bluey")); !os.IsNotExist(err) { t.Fatal("kids directory survived unmark") } // Unmarking again is idempotent. if rec = e.do(t, http.MethodPost, "/api/library/tvseries/Bluey/unmark"); rec.Code != http.StatusOK { t.Fatalf("second unmark = %d", rec.Code) } } func TestMutationRejectsBadSectionAndName(t *testing.T) { e := newEnv(t, nil) cases := []struct { path string want int }{ {"/api/library/music/Nemo/mark", http.StatusNotFound}, {"/api/library/movies/Ghost/mark", http.StatusNotFound}, {"/api/library/movies/..%2f..%2fetc/mark", http.StatusBadRequest}, {`/api/library/movies/..\..\etc/mark`, http.StatusBadRequest}, } for _, c := range cases { rec := e.do(t, http.MethodPost, c.path) if rec.Code != c.want { t.Errorf("POST %s = %d, want %d (%s)", c.path, rec.Code, c.want, rec.Body) } } // A literal ../ never reaches a handler at all: net/http's mux normalises // the path and redirects instead. The exact redirect code has varied across // Go releases, so assert the property that matters — it is a redirect and // no mutation ran. rec := e.do(t, http.MethodPost, "/api/library/movies/../mark") if rec.Code < 300 || rec.Code >= 400 { t.Errorf("POST with a literal ../ = %d, want a redirect (%s)", rec.Code, rec.Body) } if _, err := os.Stat(filepath.Join(e.root, library.KidsTree, "movies")); err != nil { t.Fatalf("kids movies dir disturbed: %v", err) } entries, err := os.ReadDir(filepath.Join(e.root, library.KidsTree, "movies")) if err != nil || len(entries) != 0 { t.Fatalf("traversal attempt created kids entries: %v %v", entries, err) } } func TestArtProxyStreamsAndHidesTheKey(t *testing.T) { e := newEnv(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v3/mediacover/9/poster-250.jpg" { t.Errorf("art path = %q", r.URL.Path) } if r.Header.Get("X-Api-Key") != upstreamKey { t.Error("art request missing the api key") } w.Header().Set("Content-Type", "image/jpeg") _, _ = w.Write([]byte("JPEGDATA")) }) rec := e.do(t, http.MethodGet, "/api/art/movies/9") if rec.Code != http.StatusOK { t.Fatalf("art = %d body %s", rec.Code, rec.Body) } if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { t.Errorf("Content-Type = %q", got) } if got := rec.Header().Get("Cache-Control"); got != "public, max-age=86400" { t.Errorf("Cache-Control = %q", got) } if rec.Body.String() != "JPEGDATA" { t.Errorf("body = %q", rec.Body) } for k, vs := range rec.Header() { for _, v := range vs { if strings.Contains(v, upstreamKey) { t.Fatalf("header %s leaked the api key", k) } } } } func TestArtProxyErrorsAreOpaque(t *testing.T) { e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "upstream said "+upstreamKey, http.StatusInternalServerError) }) rec := e.do(t, http.MethodGet, "/api/art/movies/9") if rec.Code != http.StatusBadGateway { t.Fatalf("art error status = %d, want 502", rec.Code) } if strings.Contains(rec.Body.String(), upstreamKey) { t.Fatalf("art error leaked the api key: %s", rec.Body) } } func TestArtRejectsBadIDAndUnknownSection(t *testing.T) { e := newEnv(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) }) for path, want := range map[string]int{ "/api/art/movies/0": http.StatusBadRequest, "/api/art/movies/abc": http.StatusBadRequest, "/api/art/music/1": http.StatusNotFound, } { if rec := e.do(t, http.MethodGet, path); rec.Code != want { t.Errorf("GET %s = %d, want %d", path, rec.Code, want) } } } func TestSPAFallbackAndAssets(t *testing.T) { e := newEnv(t, nil) if rec := e.do(t, http.MethodGet, "/app.js"); rec.Code != http.StatusOK || rec.Body.String() != "// js" { t.Fatalf("asset = %d %q", rec.Code, rec.Body) } for _, p := range []string{"/", "/movies", "/tvseries/Bluey"} { rec := e.do(t, http.MethodGet, p) if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "mediamark") { t.Errorf("SPA fallback for %s = %d %q", p, rec.Code, rec.Body) } } // Unknown API routes must 404 as JSON rather than falling back to the SPA. rec := e.do(t, http.MethodGet, "/api/nope") if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "") { t.Errorf("unknown api route = %d %q", rec.Code, rec.Body) } } // The hardening headers must ride on every response: the page shell, static // assets, the JSON API, the ungated probes, and rejections alike. func TestSecurityHeadersOnEveryResponse(t *testing.T) { e := newEnv(t, nil) want := map[string]string{ "Content-Security-Policy": "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", } check := func(what string, rec *httptest.ResponseRecorder) { t.Helper() for k, v := range want { if got := rec.Header().Get(k); got != v { t.Errorf("%s: %s = %q, want %q", what, k, got, v) } } } for _, p := range []string{"/", "/movies", "/app.js", "/api/library/movies", "/livez", "/readyz"} { check(p, e.do(t, http.MethodGet, p)) } req := httptest.NewRequest(http.MethodGet, "/", nil) rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("unauthorized = %d, want 403", rec.Code) } check("403", rec) }