Files
minstrel/internal/api/admin_quarantine_test.go
T
bvandeusenandClaude Opus 5 d7a8e5f300
test-go / test (push) Failing after 55s
test-web / test (push) Successful in 56s
test-go / integration (push) Failing after 4m50s
android / Build + lint + test (push) Successful in 5m52s
release / Build signed APK (releases and dev) (push) Successful in 6m5s
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(library): a track delete that cannot remove its file deletes nothing — #3918
Two delete paths had opposite failure policies. tracks.RemoveTrack
logged a failed os.Remove and deleted the row anyway, which CASCADEs
likes, plays, playlist memberships and tags, while the file survived
for the next scan to re-import as a stranger. library.DeleteTrackFile
stopped correctly but reported it as a bare 500 nobody could read.

One path now: library.DeleteTrackFile removes the file first and, on
anything but ErrNotExist, returns *FileRemoveError with nothing
deleted. Only then does it delete the row and tidy an emptied album
and artist in one transaction, log the sync change and clear orphaned
artist art. RemoveTrack calls it, which also fixes RemoveTrack never
logging a sync change. Quarantine Delete file now tidies emptied
albums and artists too.

Both endpoints answer an unwritable library (EROFS, EACCES, EPERM) with
409 library_not_writable. The message names the directory (removal
writes to the parent), the uid:gid the server runs as, and that
nothing was deleted. Other remove errors are 500 file_delete_failed
with the path.

The reachable surface is quarantine Delete file, which failed
silently: no copy for the code on either client, and Android swallowed
the exception so the row just reappeared. Web and Android now have
copy for both codes and append the server message for exactly those
two. Android's quarantine screen shows it in a snackbar.

DELETE /api/admin/tracks/{id} has had no client since f7278f24, which
kept it on purpose for a safer admin surface, so its history loss was
latent. Fixed rather than removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-11 14:23:01 -04:00

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, h.dataDir)
}
// 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())
}
})
}
}