From 6fcae8dee4d6e338325990bfdba555a689612a2c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 17:44:54 -0400 Subject: [PATCH] feat(api): add /api/admin/lidarr/* config + profiles + folders + test Five admin-only handlers under RequireAdmin middleware: GET/PUT config (api_key masked, empty key on PUT preserves saved), POST test (always 200, maps Lidarr errors to stable codes), GET quality-profiles, GET root-folders. 10 HTTP integration tests, all green. Co-Authored-By: Claude Sonnet 4.6 --- internal/api/admin_lidarr.go | 249 ++++++++++++++++++++ internal/api/admin_lidarr_test.go | 379 ++++++++++++++++++++++++++++++ internal/api/api.go | 9 + 3 files changed, 637 insertions(+) create mode 100644 internal/api/admin_lidarr.go create mode 100644 internal/api/admin_lidarr_test.go diff --git a/internal/api/admin_lidarr.go b/internal/api/admin_lidarr.go new file mode 100644 index 00000000..adf080f2 --- /dev/null +++ b/internal/api/admin_lidarr.go @@ -0,0 +1,249 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" + "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" +) + +// lidarrConfigView is the JSON shape returned by GET /api/admin/lidarr/config +// and PUT /api/admin/lidarr/config. api_key is always masked as "***" when set. +type lidarrConfigView struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` +} + +// maskAPIKey converts an api_key for external response: non-empty keys become +// "***"; empty (never set) keys remain "". +func maskAPIKey(key string) string { + if key == "" { + return "" + } + return "***" +} + +// configToView converts a lidarrconfig.Config to a lidarrConfigView with the +// api_key masked. +func configToView(cfg lidarrconfig.Config) lidarrConfigView { + return lidarrConfigView{ + Enabled: cfg.Enabled, + BaseURL: cfg.BaseURL, + APIKey: maskAPIKey(cfg.APIKey), + DefaultQualityProfileID: cfg.DefaultQualityProfileID, + DefaultRootFolderPath: cfg.DefaultRootFolderPath, + } +} + +// handleGetLidarrConfig implements GET /api/admin/lidarr/config. +// Returns the current Lidarr config with api_key masked. +func (h *handlers) handleGetLidarrConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: get lidarr config", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + writeJSON(w, http.StatusOK, configToView(cfg)) +} + +// putLidarrConfigBody is the decoded JSON body for PUT /api/admin/lidarr/config. +type putLidarrConfigBody struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + DefaultQualityProfileID int `json:"default_quality_profile_id"` + DefaultRootFolderPath string `json:"default_root_folder_path"` +} + +// handlePutLidarrConfig implements PUT /api/admin/lidarr/config. +// Empty api_key in the body preserves the currently saved api_key. +func (h *handlers) handlePutLidarrConfig(w http.ResponseWriter, r *http.Request) { + var body putLidarrConfigBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeAdminJSONErr(w, http.StatusBadRequest, "bad_request") + return + } + + // Resolve the effective api_key: empty in body = preserve saved value. + apiKey := body.APIKey + if apiKey == "" { + saved, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: put lidarr config: load saved", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + apiKey = saved.APIKey + } + + // Validate: enabled=true requires base_url AND api_key. + if body.Enabled { + if body.BaseURL == "" || apiKey == "" { + writeAdminJSONErr(w, http.StatusBadRequest, "missing_required_field") + return + } + // Validate that the URL parses. + if _, err := url.ParseRequestURI(body.BaseURL); err != nil { + writeAdminJSONErr(w, http.StatusBadRequest, "invalid_url") + return + } + } + + cfg := lidarrconfig.Config{ + Enabled: body.Enabled, + BaseURL: body.BaseURL, + APIKey: apiKey, + DefaultQualityProfileID: body.DefaultQualityProfileID, + DefaultRootFolderPath: body.DefaultRootFolderPath, + } + if err := h.lidarrCfg.Save(r.Context(), cfg); err != nil { + h.logger.Error("admin: put lidarr config: save", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return + } + writeJSON(w, http.StatusOK, configToView(cfg)) +} + +// testLidarrBody is the optional JSON body for POST /api/admin/lidarr/test. +type testLidarrBody struct { + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` +} + +// handleTestLidarrConnection implements POST /api/admin/lidarr/test. +// Always returns 200; the ok/error fields in the response body indicate +// connection success or failure. +func (h *handlers) handleTestLidarrConnection(w http.ResponseWriter, r *http.Request) { + var body testLidarrBody + // Ignore decode errors — an empty body is valid (all fields optional). + _ = json.NewDecoder(r.Body).Decode(&body) + + // Fall back to saved config for any empty field. + baseURL := body.BaseURL + apiKey := body.APIKey + if baseURL == "" || apiKey == "" { + saved, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: test lidarr: load config", "err", err) + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "internal_error"}) + return + } + if baseURL == "" { + baseURL = saved.BaseURL + } + if apiKey == "" { + apiKey = saved.APIKey + } + } + + client := lidarr.NewClient(baseURL, apiKey) + result, err := client.Ping(r.Context()) + if err != nil { + errCode := lidarrErrCode(err) + writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": errCode}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": result.Version}) +} + +// lidarrErrCode maps a lidarr client error to the stable string code used in +// admin API responses. +func lidarrErrCode(err error) string { + switch { + case errors.Is(err, lidarr.ErrUnreachable): + return "lidarr_unreachable" + case errors.Is(err, lidarr.ErrAuthFailed): + return "lidarr_auth_failed" + case errors.Is(err, lidarr.ErrLookupFailed): + return "lidarr_lookup_failed" + default: + return "internal_error" + } +} + +// qualityProfileView is the JSON shape for a single quality profile in the +// GET /api/admin/lidarr/quality-profiles response. +type qualityProfileView struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// handleListQualityProfiles implements GET /api/admin/lidarr/quality-profiles. +func (h *handlers) handleListQualityProfiles(w http.ResponseWriter, r *http.Request) { + _, client, ok := h.lidarrClientFromConfig(w, r) + if !ok { + return + } + + profiles, err := client.ListQualityProfiles(r.Context()) + if err != nil { + writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err)) + return + } + out := make([]qualityProfileView, len(profiles)) + for i, p := range profiles { + out[i] = qualityProfileView{ID: p.ID, Name: p.Name} + } + writeJSON(w, http.StatusOK, out) +} + +// rootFolderView is the JSON shape for a single root folder in the +// GET /api/admin/lidarr/root-folders response. +type rootFolderView struct { + Path string `json:"path"` + Accessible bool `json:"accessible"` + FreeSpace int64 `json:"free_space"` +} + +// handleListRootFolders implements GET /api/admin/lidarr/root-folders. +func (h *handlers) handleListRootFolders(w http.ResponseWriter, r *http.Request) { + _, client, ok := h.lidarrClientFromConfig(w, r) + if !ok { + return + } + + folders, err := client.ListRootFolders(r.Context()) + if err != nil { + writeAdminJSONErr(w, http.StatusServiceUnavailable, lidarrErrCode(err)) + return + } + out := make([]rootFolderView, len(folders)) + for i, f := range folders { + out[i] = rootFolderView{Path: f.Path, Accessible: f.Accessible, FreeSpace: f.FreeSpace} + } + writeJSON(w, http.StatusOK, out) +} + +// lidarrClientFromConfig is a shared helper for handlers that need to proxy a +// request to Lidarr. It loads the saved config, returns 503 when disabled, and +// constructs a Client. Returns (cfg, client, true) on success; (_, nil, false) +// when a response has already been written. +func (h *handlers) lidarrClientFromConfig(w http.ResponseWriter, r *http.Request) (lidarrconfig.Config, *lidarr.Client, bool) { + cfg, err := h.lidarrCfg.Get(r.Context()) + if err != nil { + h.logger.Error("admin: lidarr proxy: load config", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "internal_error") + return lidarrconfig.Config{}, nil, false + } + if !cfg.Enabled { + writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled") + return lidarrconfig.Config{}, nil, false + } + return cfg, lidarr.NewClient(cfg.BaseURL, cfg.APIKey), true +} + +// writeAdminJSONErr writes a flat {"error":""} JSON response. This +// mirrors the shape the RequireAdmin middleware uses and that the spec +// defines for /api/admin/* errors. +func writeAdminJSONErr(w http.ResponseWriter, status int, code string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": code}) +} diff --git a/internal/api/admin_lidarr_test.go b/internal/api/admin_lidarr_test.go new file mode 100644 index 00000000..535419de --- /dev/null +++ b/internal/api/admin_lidarr_test.go @@ -0,0 +1,379 @@ +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 = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), 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". + body := []byte(`{"enabled":true,"base_url":"http://lidarr.lan:8686","api_key":""}`) + 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_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, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version":"2.0.5"}`)) + })) + 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"]) + } +} + +// 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"]) + } + }) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 2354c5d8..9fa9bd93 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -60,6 +60,15 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/requests", h.handleListRequests) authed.Get("/requests/{id}", h.handleGetRequest) authed.Delete("/requests/{id}", h.handleCancelRequest) + + authed.Route("/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) + }) }) }) }