380 lines
12 KiB
Go
380 lines
12 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".
|
|
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"])
|
|
}
|
|
})
|
|
}
|
|
}
|