Files
minstrel/internal/api/admin_lidarr_test.go
bvandeusen 53a02322fb fix: A2 (relax art-source CHECK) + D + E integration residual
A2 — migration 0030: the albums/artists art `*_source` CHECK was a
fixed provider-ID allowlist fighting the extensible coverart.Register
registry (per-provider migration churn at 0016/0018/0020; rejected
test stub providers → ~11 enricher tests failed). Relax to "NULL or
non-empty"; the registry is the source of truth. Same brittleness
class as the #433 discovery-mix CHECK.

D — lidarr Approve resolves metadata/quality profiles (JSON arrays)
before the add; the test stubs returned {"id":1} for every path, so
ListMetadataProfiles failed to unmarshal → 500/Approve errors. Make
the lidarrrequests + admin_requests stubs path-aware (arrays for
/metadataprofile, /qualityprofile).

E:
- auth: requireUser (prelude.go) emitted code "auth_required"; the
  canonical code is "unauthenticated" (operator decision). Change the
  code; drop the now-dead "auth_required" web error-copy key
  ("unauthenticated" already has copy).
- playlists_system_test wrapped the real auth.RequireUser middleware
  but only injected withUser() context → 401. Like every other api
  handler test, drop the middleware and rely on requireUser().
- admin_users dup-username test seeded "test-existing" (seedUser
  prefixes) but POSTed "existing" → no collision; POST the prefixed
  name.
- me_timezone test decoded a top-level {"code"} but the envelope is
  {"error":{"code"}}; decode the nested shape.
- audit test asserted compact JSON; Postgres jsonb::text is spaced.
- put-lidarr-config tests predated the missing_defaults gate (correct
  handler behavior); supply the required defaults in the bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:22:30 -04:00

456 lines
16 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
)
// newAdminLidarrRouter builds a test chi router with the five admin/lidarr
// endpoints. RequireAdmin middleware is applied. Tests inject a user into
// context directly (skipping RequireUser).
func newAdminLidarrRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
admin.Put("/lidarr/config", h.handlePutLidarrConfig)
admin.Post("/lidarr/test", h.handleTestLidarrConnection)
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
})
return r
}
// seedAdminUser creates a test user with is_admin=true.
func seedAdminUser(t *testing.T, h *handlers) dbq.User {
t.Helper()
return seedUser(t, h.pool, "admin", "pw", true)
}
// doAdminReq fires an HTTP request at the admin router with the given user
// injected into context (bypassing RequireUser).
func doAdminReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
var reqBody *bytes.Buffer
if body != nil {
reqBody = bytes.NewBuffer(body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = withUser(req, user)
w := httptest.NewRecorder()
newAdminLidarrRouter(h).ServeHTTP(w, req)
return w
}
// saveLidarrConfigFull writes a lidarr config with the given key directly
// (for admin tests that need precise api_key control).
func saveLidarrConfigFull(t *testing.T, h *handlers, cfg lidarrconfig.Config) {
t.Helper()
svc := lidarrconfig.New(h.pool)
if err := svc.Save(context.Background(), cfg); err != nil {
t.Fatalf("saveLidarrConfigFull: %v", err)
}
}
// TestHandleGetLidarrConfig_MasksAPIKey verifies that a set api_key is
// returned as "***" and never in plaintext.
func TestHandleGetLidarrConfig_MasksAPIKey(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "secret123",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey == "secret123" {
t.Error("api_key leaked in response; want ***")
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
}
// TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty verifies that when api_key is
// unset in the DB, GET returns api_key="" (not "***").
func TestHandleGetLidarrConfig_EmptyKeyReturnsEmpty(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Save config with empty api_key (null in DB via lidarrconfig.Service).
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: false,
BaseURL: "",
APIKey: "",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/config", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.APIKey != "" {
t.Errorf("api_key = %q, want empty string", resp.APIKey)
}
}
// TestHandlePutLidarrConfig_EmptyKeyPreservesSaved verifies that a PUT with
// api_key="" does not overwrite the saved api_key.
func TestHandlePutLidarrConfig_EmptyKeyPreservesSaved(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Seed a config with a known api_key.
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: "http://lidarr.lan:8686",
APIKey: "originalkey",
})
// PUT with empty api_key — should preserve "originalkey". enabled=true
// requires the defaults gate (missing_defaults) to be satisfied.
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"","default_quality_profile_id":1,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
// Confirm the saved api_key is still "originalkey" by reading raw config.
svc := lidarrconfig.New(h.pool)
saved, err := svc.Get(context.Background())
if err != nil {
t.Fatalf("get saved config: %v", err)
}
if saved.APIKey != "originalkey" {
t.Errorf("saved api_key = %q, want originalkey", saved.APIKey)
}
}
// TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400 verifies that enabling
// Lidarr without a base_url returns 400 with missing_required_field.
func TestHandlePutLidarrConfig_EnabledRequiresBaseURL_400(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"","api_key":"somekey"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "missing_required_field" {
t.Errorf("error = %q, want missing_required_field", resp["error"])
}
}
// TestHandlePutLidarrConfig_HappyPath verifies a valid PUT returns 200 with
// the updated config and the api_key masked.
func TestHandlePutLidarrConfig_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":"newkey","default_quality_profile_id":2,"default_metadata_profile_id":1,"default_root_folder_path":"/music"}`)
w := doAdminReq(t, h, http.MethodPut, "/api/admin/lidarr/config", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp lidarrConfigView
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if !resp.Enabled {
t.Error("enabled = false, want true")
}
if resp.BaseURL != "http://lidarr.lan:8686" {
t.Errorf("base_url = %q, want http://lidarr.lan:8686", resp.BaseURL)
}
if resp.APIKey != "***" {
t.Errorf("api_key = %q, want ***", resp.APIKey)
}
if resp.DefaultQualityProfileID != 2 {
t.Errorf("default_quality_profile_id = %d, want 2", resp.DefaultQualityProfileID)
}
if resp.DefaultRootFolderPath != "/music" {
t.Errorf("default_root_folder_path = %q, want /music", resp.DefaultRootFolderPath)
}
}
// TestHandleTestLidarrConnection_HappyPath verifies that POST /test against a
// stub returning valid status JSON responds 200 with ok=true and the version.
func TestHandleTestLidarrConnection_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"},{"id":2,"name":"MP3-320"}]`))
case "/api/v1/metadataprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"Standard"}]`))
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
// POST with empty body — falls back to saved config.
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`), admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if resp["version"] != "2.0.5" {
t.Errorf("version = %v, want 2.0.5", resp["version"])
}
qp, ok := resp["quality_profiles"].([]any)
if !ok || len(qp) != 2 {
t.Errorf("quality_profiles = %v, want 2 entries", resp["quality_profiles"])
}
mp, ok := resp["metadata_profiles"].([]any)
if !ok || len(mp) != 1 {
t.Errorf("metadata_profiles = %v, want 1 entry", resp["metadata_profiles"])
}
rf, ok := resp["root_folders"].([]any)
if !ok || len(rf) != 1 {
t.Errorf("root_folders = %v, want 1 entry", resp["root_folders"])
}
if _, present := resp["list_errors"]; present {
t.Errorf("list_errors should be omitted when all lists succeed; got %v", resp["list_errors"])
}
}
// TestHandleTestLidarrConnection_PartialListFailure verifies that POST /test
// against a Lidarr that responds OK to the ping + two of three lists still
// reports ok=true with the failing list omitted and named in list_errors.
func TestHandleTestLidarrConnection_PartialListFailure(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/system/status":
_, _ = w.Write([]byte(`{"version":"2.0.5"}`))
case "/api/v1/qualityprofile":
_, _ = w.Write([]byte(`[{"id":1,"name":"FLAC"}]`))
case "/api/v1/metadataprofile":
http.Error(w, "boom", http.StatusInternalServerError)
case "/api/v1/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/music","accessible":true,"freeSpace":12345}]`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(stub.Close)
body := []byte(`{"base_url":"` + stub.URL + `","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != true {
t.Errorf("ok = %v, want true", resp["ok"])
}
if _, present := resp["metadata_profiles"]; present {
t.Errorf("metadata_profiles should be omitted on failure; got %v", resp["metadata_profiles"])
}
listErrs, ok := resp["list_errors"].(map[string]any)
if !ok {
t.Fatalf("list_errors missing or wrong shape: %v", resp["list_errors"])
}
if _, present := listErrs["metadata_profiles"]; !present {
t.Errorf("list_errors.metadata_profiles should be present, got %v", listErrs)
}
}
// TestHandleTestLidarrConnection_Unreachable verifies that POST /test against
// an unreachable URL returns 200 with ok=false and lidarr_unreachable.
func TestHandleTestLidarrConnection_Unreachable(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
// Point at a URL that will immediately refuse connections.
body := []byte(`{"base_url":"http://127.0.0.1:1","api_key":"k"}`)
w := doAdminReq(t, h, http.MethodPost, "/api/admin/lidarr/test", body, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["ok"] != false {
t.Errorf("ok = %v, want false", resp["ok"])
}
if resp["error"] != "lidarr_unreachable" {
t.Errorf("error = %v, want lidarr_unreachable", resp["error"])
}
}
// TestHandleListQualityProfiles_HappyPath verifies that GET /quality-profiles
// proxies and normalizes the Lidarr response.
func TestHandleListQualityProfiles_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"id":1,"name":"Lossless"},{"id":2,"name":"Standard"}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/quality-profiles", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var profiles []qualityProfileView
if err := json.Unmarshal(w.Body.Bytes(), &profiles); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(profiles) != 2 {
t.Fatalf("len(profiles) = %d, want 2", len(profiles))
}
if profiles[0].ID != 1 || profiles[0].Name != "Lossless" {
t.Errorf("profiles[0] = %+v, want {1 Lossless}", profiles[0])
}
if profiles[1].ID != 2 || profiles[1].Name != "Standard" {
t.Errorf("profiles[1] = %+v, want {2 Standard}", profiles[1])
}
}
// TestHandleListRootFolders_HappyPath verifies that GET /root-folders proxies
// and normalizes the Lidarr response.
func TestHandleListRootFolders_HappyPath(t *testing.T) {
h, _ := testHandlers(t)
resetLidarrState(t, h)
admin := seedAdminUser(t, h)
stub := newLidarrStub(t, `[{"path":"/music","accessible":true,"freeSpace":1234567890}]`)
saveLidarrConfigFull(t, h, lidarrconfig.Config{
Enabled: true,
BaseURL: stub.URL,
APIKey: "test-key",
})
w := doAdminReq(t, h, http.MethodGet, "/api/admin/lidarr/root-folders", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var folders []rootFolderView
if err := json.Unmarshal(w.Body.Bytes(), &folders); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(folders) != 1 {
t.Fatalf("len(folders) = %d, want 1", len(folders))
}
f := folders[0]
if f.Path != "/music" {
t.Errorf("path = %q, want /music", f.Path)
}
if !f.Accessible {
t.Error("accessible = false, want true")
}
if f.FreeSpace != 1234567890 {
t.Errorf("free_space = %d, want 1234567890", f.FreeSpace)
}
}
// TestAdminEndpoints_NonAdmin403 is a table-driven test verifying that every
// admin endpoint returns 403 with not_authorized for a non-admin user.
func TestAdminEndpoints_NonAdmin403(t *testing.T) {
h, pool := testHandlers(t)
resetLidarrState(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
cases := []struct {
method string
path string
body []byte
}{
{http.MethodGet, "/api/admin/lidarr/config", nil},
{http.MethodPut, "/api/admin/lidarr/config", []byte(`{}`)},
{http.MethodPost, "/api/admin/lidarr/test", []byte(`{}`)},
{http.MethodGet, "/api/admin/lidarr/quality-profiles", nil},
{http.MethodGet, "/api/admin/lidarr/root-folders", nil},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := doAdminReq(t, h, tc.method, tc.path, tc.body, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp["error"] != "not_authorized" {
t.Errorf("error = %q, want not_authorized", resp["error"])
}
})
}
}