feat(api): /api/quarantine user + admin endpoints + Service wiring

User-facing /api/quarantine handlers (POST flag, DELETE unflag, GET mine)
plus the five /api/admin/quarantine endpoints (queue, resolve, delete-file,
delete-via-lidarr, actions). Mount() and the handlers struct now accept the
lidarrquarantine.Service, constructed in server.go alongside the existing
lidarrrequests wiring.
This commit is contained in:
2026-04-30 20:19:39 -04:00
parent ba81e4f2ac
commit 6fedd495be
8 changed files with 1159 additions and 12 deletions
+243
View File
@@ -0,0 +1,243 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// adminQueueRowView is a single aggregated row in the admin queue response.
type adminQueueRowView struct {
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
AlbumID string `json:"album_id"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
ReportCount int32 `json:"report_count"`
LatestAt string `json:"latest_at"`
ReasonCounts map[string]int `json:"reason_counts"`
Reports []adminQueueReportView `json:"reports"`
}
// adminQueueReportView is one user's report under an aggregated admin row.
type adminQueueReportView struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Reason string `json:"reason"`
Notes *string `json:"notes,omitempty"`
CreatedAt string `json:"created_at"`
}
// formatTimestamp renders a pgtype.Timestamptz as RFC3339 or empty string.
func formatTimestamp(ts pgtype.Timestamptz) string {
if !ts.Valid {
return ""
}
return ts.Time.Format("2006-01-02T15:04:05Z07:00")
}
// handleListAdminQuarantine implements GET /api/admin/quarantine.
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
if err != nil {
h.logger.Error("admin: list quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out := make([]adminQueueRowView, 0, len(rows))
for _, row := range rows {
reports := make([]adminQueueReportView, 0, len(row.Reports))
for _, rep := range row.Reports {
reports = append(reports, adminQueueReportView{
UserID: uuidToString(rep.UserID),
Username: rep.Username,
Reason: rep.Reason,
Notes: rep.Notes,
CreatedAt: formatTimestamp(rep.CreatedAt),
})
}
out = append(out, adminQueueRowView{
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
AlbumID: uuidToString(row.AlbumID),
LidarrAlbumMBID: row.LidarrAlbumMBID,
ReportCount: row.ReportCount,
LatestAt: formatTimestamp(row.LatestAt),
ReasonCounts: row.ReasonCounts,
Reports: reports,
})
}
writeJSON(w, http.StatusOK, out)
}
// actionResultView is the response shape for the three admin destructive
// actions (resolve, delete-file, delete-via-lidarr).
type actionResultView struct {
ActionID string `json:"action_id"`
AffectedUsers int32 `json:"affected_users"`
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
}
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
if err != nil {
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
return
}
h.logger.Error("admin: resolve quarantine", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
})
}
// handleDeleteQuarantineFile implements POST /api/admin/quarantine/{track_id}/delete-file.
func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
default:
h.logger.Error("admin: delete file", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed")
}
return
}
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
})
}
// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
admin, ok := auth.UserFromContext(r.Context())
if !ok {
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
return
}
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
case errors.Is(err, lidarr.ErrUnreachable):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
case errors.Is(err, lidarr.ErrAuthFailed):
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
default:
h.logger.Error("admin: delete via lidarr", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
}
return
}
writeJSON(w, http.StatusOK, actionResultView{
ActionID: uuidToString(action.ID),
AffectedUsers: action.AffectedUsers,
DeletedTrackCount: &deleted,
})
}
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
type actionLogView struct {
ID string `json:"id"`
TrackID string `json:"track_id"`
TrackTitle string `json:"track_title"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
Action string `json:"action"`
AdminID *string `json:"admin_id,omitempty"`
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
AffectedUsers int32 `json:"affected_users"`
CreatedAt string `json:"created_at"`
}
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
// ?limit= defaults to 50, capped at 200.
func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := int32(50)
if limitStr != "" {
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 {
if v > 200 {
v = 200
}
limit = int32(v)
}
}
rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit)
if err != nil {
h.logger.Error("admin: list actions", "err", err)
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
return
}
out := make([]actionLogView, 0, len(rows))
for _, row := range rows {
var adminID *string
if row.AdminID.Valid {
s := uuidToString(row.AdminID)
adminID = &s
}
out = append(out, actionLogView{
ID: uuidToString(row.ID),
TrackID: uuidToString(row.TrackID),
TrackTitle: row.TrackTitle,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
Action: string(row.Action),
AdminID: adminID,
LidarrAlbumMBID: row.LidarrAlbumMbid,
AffectedUsers: row.AffectedUsers,
CreatedAt: formatTimestamp(row.CreatedAt),
})
}
writeJSON(w, http.StatusOK, out)
}
+429
View File
@@ -0,0 +1,429 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// newAdminQuarantineRouter builds a test chi router with the five admin
// quarantine endpoints. RequireAdmin middleware is applied; tests inject
// the user into context manually (RequireUser is bypassed).
func newAdminQuarantineRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
return r
}
// doAdminQuarantineReq fires an HTTP request against the admin quarantine
// router with the given user in context.
func doAdminQuarantineReq(t *testing.T, h *handlers, method, path string, body []byte, user dbq.User) *httptest.ResponseRecorder {
t.Helper()
var buf *bytes.Buffer
if body != nil {
buf = bytes.NewBuffer(body)
} else {
buf = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(method, path, buf)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), user))
w := httptest.NewRecorder()
newAdminQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// installQuarantineClientFn rewires h.lidarrQuarantine to a fresh Service
// with a real clientFn pointing at the (current) saved lidarr_config. This
// mirrors testHandlersWithClientFn for the requests test file.
func installQuarantineClientFn(t *testing.T, h *handlers) {
t.Helper()
cfg := lidarrconfig.New(h.pool)
clientFn := func() *lidarr.Client {
c, err := cfg.Get(context.Background())
if err != nil || !c.Enabled || c.BaseURL == "" {
return nil
}
return lidarr.NewClient(c.BaseURL, c.APIKey)
}
h.lidarrQuarantine = lidarrquarantine.NewService(h.pool, cfg, clientFn)
}
// flagDirect bypasses the HTTP handler to seed a quarantine row via the
// Service for setup. Used to construct admin queue scenarios.
func flagDirect(t *testing.T, h *handlers, userID, trackID pgtype.UUID, reason string) {
t.Helper()
if _, err := h.lidarrQuarantine.Flag(context.Background(), userID, trackID, reason, ""); err != nil {
t.Fatalf("flagDirect: %v", err)
}
}
// seedTrackOnDisk creates a real file under a temp dir and an artist+album+
// track row pointing at it. Used for delete-file tests that need the file
// to exist before the handler removes it.
func seedTrackOnDisk(t *testing.T, h *handlers, unique string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "AdmQ Artist "+unique)
album := seedAlbum(t, h.pool, artist.ID, "AdmQ Album "+unique, 0)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
one := int32(1)
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "AdmQ Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// seedTrackWithAlbumMBID seeds an artist+album-with-mbid+track. The mbid is
// required for delete-via-lidarr scenarios.
func seedTrackWithAlbumMBID(t *testing.T, h *handlers, unique, albumMBID string) (dbq.Track, string) {
t.Helper()
artist := seedArtist(t, h.pool, "DvL Artist "+unique)
dir := t.TempDir()
path := filepath.Join(dir, "track-"+unique+".mp3")
if err := os.WriteFile(path, []byte("audio"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mbid := albumMBID
album, err := dbq.New(h.pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "DvL Album " + unique, SortTitle: "DvL Album " + unique,
ArtistID: artist.ID, Mbid: &mbid,
})
if err != nil {
t.Fatalf("UpsertAlbum: %v", err)
}
one := int32(1)
tr, err := dbq.New(h.pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "DvL Track " + unique,
AlbumID: album.ID,
ArtistID: artist.ID,
TrackNumber: &one,
DurationMs: 30000,
FilePath: path,
FileSize: 5,
FileFormat: "mp3",
})
if err != nil {
t.Fatalf("UpsertTrack: %v", err)
}
return tr, path
}
// TestListAdminQuarantine_AggregatedShape seeds 3 users flagging the same
// track with mixed reasons. Verifies one aggregated row with report_count=3,
// reason_counts populated, and a Reports list of length 3.
func TestListAdminQuarantine_AggregatedShape(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
carol := seedUser(t, pool, "carol", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackOnDisk(t, h, "agg")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
flagDirect(t, h, bob.ID, track.ID, "bad_rip")
flagDirect(t, h, carol.ID, track.ID, "wrong_tags")
w := doAdminQuarantineReq(t, h, http.MethodGet, "/api/admin/quarantine", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var rows []adminQueueRowView
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 aggregated row", len(rows))
}
r := rows[0]
if r.ReportCount != 3 {
t.Errorf("report_count = %d, want 3", r.ReportCount)
}
if r.ReasonCounts["bad_rip"] != 2 || r.ReasonCounts["wrong_tags"] != 1 {
t.Errorf("reason_counts = %+v", r.ReasonCounts)
}
if len(r.Reports) != 3 {
t.Errorf("reports len = %d, want 3", len(r.Reports))
}
}
// TestResolveQuarantine_HappyPath seeds two flags, resolves, verifies 200,
// and confirms an audit row was written.
func TestResolveQuarantine_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackOnDisk(t, h, "resolve")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
flagDirect(t, h, bob.ID, track.ID, "wrong_tags")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/resolve", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 2 {
t.Errorf("affected_users = %d, want 2", got.AffectedUsers)
}
if got.ActionID == "" {
t.Error("action_id empty")
}
// Audit row must exist with action=resolved.
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindResolved {
t.Errorf("audit action = %v, want resolved", actions[0].Action)
}
}
// TestDeleteQuarantineFile_HappyPath seeds a flag and verifies POST
// /delete-file removes the file and the track row.
func TestDeleteQuarantineFile_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, path := seedTrackOnDisk(t, h, "delfile")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-file", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
}
// File and track row should be gone.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists: %v", err)
}
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
t.Error("track row still exists")
}
}
// TestDeleteQuarantineViaLidarr_HappyPath uses a stub Lidarr that returns
// a single album for the lookup and 200 for the delete. Verifies the
// audit row is written and the local track row is deleted.
func TestDeleteQuarantineViaLidarr_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path == "/api/v1/album" && r.Method == http.MethodGet {
_, _ = w.Write([]byte(`[{"id":42,"foreignAlbumId":"al-mbid-dvl","title":"Al","artistId":7}]`))
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h) // re-install after config save so clientFn picks it up
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-happy", "al-mbid-dvl")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
}
var got actionResultView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.AffectedUsers != 1 {
t.Errorf("affected_users = %d, want 1", got.AffectedUsers)
}
if got.DeletedTrackCount == nil || *got.DeletedTrackCount != 1 {
t.Errorf("deleted_track_count = %v, want 1", got.DeletedTrackCount)
}
// Track row should be gone.
if _, err := dbq.New(pool).GetTrackByID(context.Background(), track.ID); err == nil {
t.Error("track row still exists after delete-via-lidarr")
}
// Audit row written with the lidarr_album_mbid.
actions, err := dbq.New(pool).ListQuarantineActions(context.Background(), 50)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actions) != 1 {
t.Fatalf("audit rows = %d, want 1", len(actions))
}
if actions[0].Action != dbq.LidarrQuarantineActionKindDeletedViaLidarr {
t.Errorf("audit action = %v, want deleted_via_lidarr", actions[0].Action)
}
if actions[0].LidarrAlbumMbid == nil || *actions[0].LidarrAlbumMbid != "al-mbid-dvl" {
t.Errorf("audit lidarr_album_mbid = %v", actions[0].LidarrAlbumMbid)
}
}
// TestDeleteQuarantineViaLidarr_LidarrDisabled verifies 503 when Lidarr is
// not configured.
func TestDeleteQuarantineViaLidarr_LidarrDisabled(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
track, _ := seedTrackWithAlbumMBID(t, h, "dvl-disabled", "al-mbid-disabled")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
// Lidarr config is reset (disabled).
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; 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"] != "lidarr_disabled" {
t.Errorf("error = %q, want lidarr_disabled", resp["error"])
}
}
// TestDeleteQuarantineViaLidarr_AlbumMBIDMissing verifies 404 album_mbid_missing
// when the parent album has no mbid.
func TestDeleteQuarantineViaLidarr_AlbumMBIDMissing(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(stub.Close)
saveLidarrConfig(t, h, stub.URL, true)
installQuarantineClientFn(t, h)
alice := seedUser(t, pool, "alice", "pw", false)
admin := seedUser(t, pool, "admin", "pw", true)
// Track whose album has NO mbid.
track, _ := seedTrackOnDisk(t, h, "no-mbid")
flagDirect(t, h, alice.ID, track.ID, "bad_rip")
w := doAdminQuarantineReq(t, h, http.MethodPost,
"/api/admin/quarantine/"+uuidToString(track.ID)+"/delete-via-lidarr", nil, admin)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; 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"] != "album_mbid_missing" {
t.Errorf("error = %q, want album_mbid_missing", resp["error"])
}
}
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
// endpoints reject non-admin callers with 403.
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
resetLidarrState(t, h)
installQuarantineClientFn(t, h)
nonAdmin := seedUser(t, pool, "regular", "pw", false)
fakeID := "00000000-0000-0000-0000-000000000001"
cases := []struct {
method string
path string
}{
{http.MethodGet, "/api/admin/quarantine"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/resolve"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-file"},
{http.MethodPost, "/api/admin/quarantine/" + fakeID + "/delete-via-lidarr"},
{http.MethodGet, "/api/admin/quarantine/actions"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
w := doAdminQuarantineReq(t, h, tc.method, tc.path, nil, nonAdmin)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body = %s", w.Code, w.Body.String())
}
})
}
}
+27 -9
View File
@@ -14,6 +14,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -21,9 +22,15 @@ import (
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service) {
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{pool: pool, logger: logger, events: events, recCfg: recCfg, rng: rng.Float64, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs}
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
rng: rng.Float64,
lidarrCfg: lidarrCfg,
lidarrRequests: lidarrReqs,
lidarrQuarantine: lidarrQuar,
}
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", h.handleLogin)
@@ -61,6 +68,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/requests/{id}", h.handleGetRequest)
authed.Delete("/requests/{id}", h.handleCancelRequest)
authed.Post("/quarantine", h.handleFlag)
authed.Delete("/quarantine/{track_id}", h.handleUnflag)
authed.Get("/quarantine/mine", h.handleListMyQuarantine)
authed.Route("/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
@@ -71,17 +82,24 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/requests", h.handleListAdminRequests)
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
admin.Get("/quarantine", h.handleListAdminQuarantine)
admin.Post("/quarantine/{track_id}/resolve", h.handleResolveQuarantine)
admin.Post("/quarantine/{track_id}/delete-file", h.handleDeleteQuarantineFile)
admin.Post("/quarantine/{track_id}/delete-via-lidarr", h.handleDeleteQuarantineViaLidarr)
admin.Get("/quarantine/actions", h.handleListQuarantineActions)
})
})
})
}
type handlers struct {
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
pool *pgxpool.Pool
logger *slog.Logger
events *playevents.Writer
recCfg config.RecommendationConfig
rng func() float64
lidarrCfg *lidarrconfig.Service
lidarrRequests *lidarrrequests.Service
lidarrQuarantine *lidarrquarantine.Service
}
+3 -1
View File
@@ -23,6 +23,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -58,7 +59,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs}
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar}
return h, pool
}
+1 -1
View File
@@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine)
paths := []string{
"/api/artists",
+148
View File
@@ -0,0 +1,148 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
// that operate on a single quarantine row (POST /api/quarantine).
type quarantineView struct {
UserID string `json:"user_id"`
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes *string `json:"notes,omitempty"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
return quarantineView{
UserID: uuidToString(row.UserID),
TrackID: uuidToString(row.TrackID),
Reason: string(row.Reason),
Notes: row.Notes,
CreatedAt: row.CreatedAt,
}
}
// flagBody is the JSON body for POST /api/quarantine. track_id is sent as
// the canonical hyphenated string the SPA already gets from /api/tracks/*.
type flagBody struct {
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes string `json:"notes"`
}
// handleFlag implements POST /api/quarantine. Upserts via Service.Flag.
func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
var body flagBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
trackID, ok := parseUUID(body.TrackID)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
return
}
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrBadReason):
writeErr(w, http.StatusBadRequest, "bad_reason", "invalid reason")
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeErr(w, http.StatusNotFound, "track_not_found", "track does not exist")
default:
h.logger.Error("api: flag", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "flag failed")
}
return
}
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
}
// handleUnflag implements DELETE /api/quarantine/{track_id}.
func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
id, ok := parseUUID(chi.URLParam(r, "track_id"))
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid track id")
return
}
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
writeErr(w, http.StatusNotFound, "quarantine_not_found", "no quarantine for that track")
return
}
h.logger.Error("api: unflag", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "unflag failed")
return
}
w.WriteHeader(http.StatusNoContent)
}
// quarantineMineView is the per-row shape returned by GET /api/quarantine/mine,
// composed from the joined ListQuarantineForUserRow.
type quarantineMineView struct {
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes *string `json:"notes,omitempty"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
TrackTitle string `json:"track_title"`
TrackDurationMs int32 `json:"track_duration_ms"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
AlbumCoverArtPath *string `json:"album_cover_art_path,omitempty"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
}
// handleListMyQuarantine implements GET /api/quarantine/mine. Returns the
// caller's quarantines with track/album/artist detail.
func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required")
return
}
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list mine", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
return
}
out := make([]quarantineMineView, 0, len(rows))
for _, row := range rows {
out = append(out, quarantineMineView{
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
Reason: string(row.LidarrQuarantine.Reason),
Notes: row.LidarrQuarantine.Notes,
CreatedAt: row.LidarrQuarantine.CreatedAt,
TrackTitle: row.TrackTitle,
TrackDurationMs: row.TrackDurationMs,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
AlbumCoverArtPath: row.AlbumCoverArtPath,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
})
}
writeJSON(w, http.StatusOK, out)
}
+305
View File
@@ -0,0 +1,305 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// newQuarantineRouter builds a test router for the user-facing /api/quarantine
// endpoints. Tests inject the user into context manually (RequireUser is
// applied in real Mount but skipped here so unit tests can target the handler
// path directly).
func newQuarantineRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Post("/api/quarantine", h.handleFlag)
r.Delete("/api/quarantine/{track_id}", h.handleUnflag)
r.Get("/api/quarantine/mine", h.handleListMyQuarantine)
return r
}
// doFlag fires POST /api/quarantine with body as the given user.
func doFlag(h *handlers, user dbq.User, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/quarantine", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// doUnflag fires DELETE /api/quarantine/{track_id} as the given user.
func doUnflag(h *handlers, user dbq.User, trackID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodDelete, "/api/quarantine/"+trackID, nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// doListMine fires GET /api/quarantine/mine as the given user.
func doListMine(h *handlers, user dbq.User) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/quarantine/mine", nil)
req = withUser(req, user)
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
return w
}
// seedQuarantineTrack creates a minimal artist+album+track for quarantine
// tests. Uniqueness is forced by appending unique into title fields.
func seedQuarantineTrack(t *testing.T, h *handlers, unique string) dbq.Track {
t.Helper()
artist := seedArtist(t, h.pool, "Q Artist "+unique)
album := seedAlbum(t, h.pool, artist.ID, "Q Album "+unique, 0)
return seedTrack(t, h.pool, album.ID, artist.ID, "Q Track "+unique, 1, 30000)
}
// TestFlag_HappyPath verifies POST /api/quarantine returns 201 and the row
// shows up in ListMine.
func TestFlag_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "happy")
body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip","notes":"crackly"}`, uuidToString(track.ID))
w := doFlag(h, alice, body)
if w.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body = %s", w.Code, w.Body.String())
}
var got quarantineView
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if got.Reason != "bad_rip" {
t.Errorf("reason = %q, want bad_rip", got.Reason)
}
if got.Notes == nil || *got.Notes != "crackly" {
t.Errorf("notes = %v, want 'crackly'", got.Notes)
}
if got.TrackID != uuidToString(track.ID) {
t.Errorf("track_id = %q, want %q", got.TrackID, uuidToString(track.ID))
}
// Verify it shows up in ListMine.
wm := doListMine(h, alice)
if wm.Code != http.StatusOK {
t.Fatalf("ListMine status = %d, body = %s", wm.Code, wm.Body.String())
}
var rows []quarantineMineView
if err := json.Unmarshal(wm.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode mine: %v; body = %s", err, wm.Body.String())
}
if len(rows) != 1 {
t.Fatalf("mine len = %d, want 1", len(rows))
}
if rows[0].TrackID != uuidToString(track.ID) {
t.Errorf("mine track_id = %q, want %q", rows[0].TrackID, uuidToString(track.ID))
}
if rows[0].TrackTitle != track.Title {
t.Errorf("mine track_title = %q, want %q", rows[0].TrackTitle, track.Title)
}
}
// TestFlag_BadReason verifies an unknown reason returns 400 bad_reason.
func TestFlag_BadReason(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "badreason")
body := fmt.Sprintf(`{"track_id":%q,"reason":"garbage"}`, uuidToString(track.ID))
w := doFlag(h, alice, body)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "bad_reason" {
t.Errorf("error.code = %q, want bad_reason", resp.Error.Code)
}
}
// TestFlag_TrackNotFound verifies a nonexistent track id returns 404.
func TestFlag_TrackNotFound(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
// Made-up but well-formed UUID — track won't exist in the DB.
body := `{"track_id":"00000000-0000-0000-0000-000000000099","reason":"bad_rip"}`
w := doFlag(h, alice, body)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "track_not_found" {
t.Errorf("error.code = %q, want track_not_found", resp.Error.Code)
}
}
// TestUnflag_HappyPath verifies DELETE returns 204 after a flag.
func TestUnflag_HappyPath(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "unflag")
body := fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(track.ID))
if w := doFlag(h, alice, body); w.Code != http.StatusCreated {
t.Fatalf("flag failed: %d, body = %s", w.Code, w.Body.String())
}
w := doUnflag(h, alice, uuidToString(track.ID))
if w.Code != http.StatusNoContent {
t.Fatalf("unflag status = %d, want 204; body = %s", w.Code, w.Body.String())
}
// Confirm it's gone.
mine := doListMine(h, alice)
var rows []quarantineMineView
_ = json.Unmarshal(mine.Body.Bytes(), &rows)
if len(rows) != 0 {
t.Errorf("after unflag, mine len = %d, want 0", len(rows))
}
}
// TestUnflag_NotFound verifies DELETE on an un-flagged track returns 404
// quarantine_not_found.
func TestUnflag_NotFound(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
track := seedQuarantineTrack(t, h, "unflagnf")
w := doUnflag(h, alice, uuidToString(track.ID))
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
var resp struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if resp.Error.Code != "quarantine_not_found" {
t.Errorf("error.code = %q, want quarantine_not_found", resp.Error.Code)
}
}
// TestListMine_OK seeds two flags on alice and one on bob, verifies alice
// only sees her own.
func TestListMine_OK(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
alice := seedUser(t, pool, "alice", "pw", false)
bob := seedUser(t, pool, "bob", "pw", false)
t1 := seedQuarantineTrack(t, h, "lm-1")
t2 := seedQuarantineTrack(t, h, "lm-2")
t3 := seedQuarantineTrack(t, h, "lm-3")
for _, body := range []string{
fmt.Sprintf(`{"track_id":%q,"reason":"bad_rip"}`, uuidToString(t1.ID)),
fmt.Sprintf(`{"track_id":%q,"reason":"wrong_tags"}`, uuidToString(t2.ID)),
} {
if w := doFlag(h, alice, body); w.Code != http.StatusCreated {
t.Fatalf("alice flag: %d, body = %s", w.Code, w.Body.String())
}
}
bobBody := fmt.Sprintf(`{"track_id":%q,"reason":"duplicate"}`, uuidToString(t3.ID))
if w := doFlag(h, bob, bobBody); w.Code != http.StatusCreated {
t.Fatalf("bob flag: %d, body = %s", w.Code, w.Body.String())
}
w := doListMine(h, alice)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var rows []quarantineMineView
if err := json.Unmarshal(w.Body.Bytes(), &rows); err != nil {
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
}
if len(rows) != 2 {
t.Fatalf("len = %d, want 2 (alice's only)", len(rows))
}
for _, r := range rows {
if r.TrackID == uuidToString(t3.ID) {
t.Errorf("alice can see bob's flag")
}
}
}
// TestQuarantineEndpointsRequireAuth verifies the /api/quarantine endpoints
// return 401 when no user is in context (covers RequireUser failure mode
// uniformly across the three handlers).
func TestQuarantineEndpointsRequireAuth(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
cases := []struct {
name string
method string
path string
body string
}{
{"flag", http.MethodPost, "/api/quarantine", `{"track_id":"00000000-0000-0000-0000-000000000001","reason":"bad_rip"}`},
{"unflag", http.MethodDelete, "/api/quarantine/00000000-0000-0000-0000-000000000001", ""},
{"mine", http.MethodGet, "/api/quarantine/mine", ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
var reqBody *bytes.Buffer
if tc.body != "" {
reqBody = bytes.NewBufferString(tc.body)
} else {
reqBody = bytes.NewBuffer(nil)
}
req := httptest.NewRequest(tc.method, tc.path, reqBody)
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
// No user injected into context.
req = req.WithContext(context.Background())
w := httptest.NewRecorder()
newQuarantineRouter(h).ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("%s: status = %d, want 401; body = %s", tc.name, w.Code, w.Body.String())
}
})
}
}
+3 -1
View File
@@ -18,6 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
@@ -59,7 +60,8 @@ func (s *Server) Router() http.Handler {
)
lidarrCfg := lidarrconfig.New(s.Pool)
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs)
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, nil)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireUser(s.Pool))
admin.Use(auth.RequireAdmin())