Files
unkin-agent df07085ecb
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Close the review gaps: CI lint, server timeouts, security headers
The initial scaffold left three holes the review caught. CI only checked
gofmt and go vet, so golangci-lint and the pre-commit hooks were advisory
rather than enforced. The HTTP server bounded only the header read, so a
slow or stalled peer could hold a connection indefinitely. And the browser
got no content-security policy at all, leaving the SPA's same-origin
assumption unenforced.

Add golangci-lint and pre-commit hook steps to the existing pre-commit
workflow, mirroring the estate's images so the required context name stays
ci/woodpecker/pr/pre-commit.
Bound the server with ReadTimeout, WriteTimeout, and IdleTimeout, keeping
the write budget generous enough for the poster proxy's streamed responses.
Stamp Content-Security-Policy, X-Content-Type-Options, and Referrer-Policy
onto every response from a single middleware wrapping the root handler.
Assert the headers across the API, UI, assets, probes, and rejections.
Guard the CSP's no-unsafe-inline assumption with a ui test that fails if a
shipped asset grows an inline script, style block, or event handler.
Extend the make pre-commit target to match the widened CI checks.
2026-08-29 21:42:39 +10:00

408 lines
14 KiB
Go

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("<html>mediamark</html>")},
"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(), "<html>") {
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)
}