Files
mediamark/internal/arr/arr_test.go
T
unkin-agent c129cb99fc
ci/woodpecker/pr/test Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
Add the initial mediamark app
Single Go binary serving the API and an embedded keyboard-first UI for
promoting fafflix titles into the cheeztv kids tree via hardlinks.

- internal/library: hardlink sync, idempotent re-runs, drift reporting,
  strict single-path-element name validation as the traversal guard
- internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and
  a key-brokered poster proxy
- internal/auth: server-side Authentik group enforcement on every route
- internal/server: library JSON API, art proxy, health probes, SPA
- ui: two-tile landing page, fuzzy-filtered title list, detail panel
- Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
2026-08-29 21:27:09 +10:00

209 lines
5.9 KiB
Go

package arr
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
const testKey = "s3cr3t-api-key-value"
func staticKey(string) (string, bool) { return testKey, true }
func TestListSonarrAndRadarrHitTheRightEndpoint(t *testing.T) {
cases := []struct {
kind Kind
path string
body string
}{
{KindSonarr, "/base/api/v3/series", `[{"id":7,"title":"Bluey","year":2018,"overview":"dogs","status":"continuing","path":"/media/fafflix/tvseries/Bluey"}]`},
{KindRadarr, "/base/api/v3/movie", `[{"id":9,"title":"Nemo","year":2003,"overview":"fish","status":"released","path":"/media/fafflix/movies/Finding Nemo (2003)"}]`},
}
for _, c := range cases {
t.Run(string(c.kind), func(t *testing.T) {
var gotPath, gotKey string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath, gotKey = r.URL.Path, r.Header.Get("X-Api-Key")
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, c.body)
}))
defer srv.Close()
cl := New(c.kind, srv.URL+"/base", staticKey)
entries, err := cl.List(context.Background())
if err != nil {
t.Fatal(err)
}
if gotPath != c.path {
t.Errorf("path = %q, want %q", gotPath, c.path)
}
if gotKey != testKey {
t.Errorf("X-Api-Key = %q", gotKey)
}
if len(entries) != 1 || entries[0].Title == "" || entries[0].Year == 0 {
t.Fatalf("entries = %+v", entries)
}
})
}
}
func TestEntryDirMatchesLibraryDirectory(t *testing.T) {
cases := []struct{ path, want string }{
{"/media/fafflix/movies/Finding Nemo (2003)", "Finding Nemo (2003)"},
{"/media/fafflix/tvseries/Bluey/", "Bluey"},
{`C:\media\movies\Cars`, "Cars"},
{"", ""},
}
for _, c := range cases {
if got := (Entry{Path: c.path}).Dir(); got != c.want {
t.Errorf("Dir(%q) = %q, want %q", c.path, got, c.want)
}
}
}
func TestListCachesAndExpires(t *testing.T) {
var calls int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
_, _ = io.WriteString(w, `[{"id":1,"title":"A","path":"/x/A"}]`)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
for i := 0; i < 3; i++ {
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("upstream calls = %d, want 1 (cached)", got)
}
cl.SetCacheTTL(time.Nanosecond)
time.Sleep(time.Millisecond)
if _, err := cl.List(context.Background()); err != nil {
t.Fatal(err)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("upstream calls after expiry = %d, want 2", got)
}
}
func TestListErrorsNeverLeakTheKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Echo the key back the way a chatty upstream error page might.
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, "bad key "+testKey)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.List(context.Background())
if err == nil {
t.Fatal("List succeeded on a 401")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestNoKeyFailsClosed(t *testing.T) {
cl := New(KindSonarr, "http://unused.invalid", func(string) (string, bool) { return "", false })
if _, err := cl.List(context.Background()); !errors.Is(err, ErrNoKey) {
t.Fatalf("List = %v, want ErrNoKey", err)
}
if _, err := cl.Poster(context.Background(), 1); !errors.Is(err, ErrNoKey) {
t.Fatalf("Poster = %v, want ErrNoKey", err)
}
}
func TestPosterStreamsWithContentType(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/mediacover/42/poster-250.jpg" {
t.Errorf("poster path = %q", r.URL.Path)
}
if r.Header.Get("X-Api-Key") != testKey {
t.Error("poster request missing the api key")
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte{0x89, 'P', 'N', 'G'})
}))
defer srv.Close()
cl := New(KindRadarr, srv.URL, staticKey)
art, err := cl.Poster(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
defer func() { _ = art.Body.Close() }()
if art.ContentType != "image/png" {
t.Errorf("ContentType = %q", art.ContentType)
}
b, err := io.ReadAll(art.Body)
if err != nil || len(b) != 4 {
t.Fatalf("body = %v %v", b, err)
}
}
func TestPosterMissingIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "not found "+testKey, http.StatusNotFound)
}))
defer srv.Close()
cl := New(KindSonarr, srv.URL, staticKey)
_, err := cl.Poster(context.Background(), 1)
if err == nil {
t.Fatal("Poster succeeded on a 404")
}
if strings.Contains(err.Error(), testKey) {
t.Fatalf("error leaked the api key: %v", err)
}
}
func TestFileKeysReReadsAfterRotation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sonarr")
if err := os.WriteFile(path, []byte("first\n"), 0o600); err != nil {
t.Fatal(err)
}
kf := FileKeys(dir)
if k, ok := kf("sonarr"); !ok || k != "first" {
t.Fatalf("key = %q %v", k, ok)
}
if err := os.WriteFile(path, []byte(" second "), 0o600); err != nil {
t.Fatal(err)
}
if k, ok := kf("sonarr"); !ok || k != "second" {
t.Fatalf("rotated key = %q %v, want trimmed second", k, ok)
}
if _, ok := kf("radarr"); ok {
t.Fatal("missing key file reported ok")
}
if err := os.WriteFile(filepath.Join(dir, "empty"), []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := kf("empty"); ok {
t.Fatal("blank key file reported ok")
}
if _, ok := kf("../etc/passwd"); ok {
t.Fatal("traversal key name reported ok")
}
}
func TestFileKeysEnvOverride(t *testing.T) {
t.Setenv("ARR_SONARR_APIKEY", "from-env")
kf := FileKeys(t.TempDir())
if k, ok := kf("sonarr"); !ok || k != "from-env" {
t.Fatalf("key = %q %v, want from-env", k, ok)
}
}