c129cb99fc
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
209 lines
5.9 KiB
Go
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)
|
|
}
|
|
}
|