diff --git a/internal/api/admin_quarantine.go b/internal/api/admin_quarantine.go new file mode 100644 index 00000000..a6c2e06d --- /dev/null +++ b/internal/api/admin_quarantine.go @@ -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) +} diff --git a/internal/api/admin_quarantine_test.go b/internal/api/admin_quarantine_test.go new file mode 100644 index 00000000..d45e536e --- /dev/null +++ b/internal/api/admin_quarantine_test.go @@ -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()) + } + }) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index e654fa2f..0ef8d086 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b2644ea8..b38a8f72 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -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 } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 63f01f2b..80e6e7ac 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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", diff --git a/internal/api/quarantine.go b/internal/api/quarantine.go new file mode 100644 index 00000000..d3a61188 --- /dev/null +++ b/internal/api/quarantine.go @@ -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) +} diff --git a/internal/api/quarantine_test.go b/internal/api/quarantine_test.go new file mode 100644 index 00000000..83a425e6 --- /dev/null +++ b/internal/api/quarantine_test.go @@ -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()) + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 77c87add..f491cbe7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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())