430 lines
15 KiB
Go
430 lines
15 KiB
Go
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 = withUser(req, 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())
|
|
}
|
|
})
|
|
}
|
|
}
|