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