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 <noreply@anthropic.com>
This commit is contained in:
@@ -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":"<code>"} 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})
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user