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()) } }) } }