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) } }