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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user