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/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
)
|
)
|
||||||
@@ -21,9 +22,15 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// 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()))
|
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) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Post("/auth/login", h.handleLogin)
|
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.Get("/requests/{id}", h.handleGetRequest)
|
||||||
authed.Delete("/requests/{id}", h.handleCancelRequest)
|
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) {
|
authed.Route("/admin", func(admin chi.Router) {
|
||||||
admin.Use(auth.RequireAdmin())
|
admin.Use(auth.RequireAdmin())
|
||||||
admin.Get("/lidarr/config", h.handleGetLidarrConfig)
|
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.Get("/requests", h.handleListAdminRequests)
|
||||||
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
||||||
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
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 {
|
type handlers struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.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/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
)
|
)
|
||||||
@@ -58,7 +59,8 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
}
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
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
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/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/config"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
|
"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/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
@@ -59,7 +60,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
)
|
)
|
||||||
lidarrCfg := lidarrconfig.New(s.Pool)
|
lidarrCfg := lidarrconfig.New(s.Pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, nil, nil)
|
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) {
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
admin.Use(auth.RequireUser(s.Pool))
|
admin.Use(auth.RequireUser(s.Pool))
|
||||||
admin.Use(auth.RequireAdmin())
|
admin.Use(auth.RequireAdmin())
|
||||||
|
|||||||
Reference in New Issue
Block a user