release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Migration 0091 drops lidarr_config and renames the quarantine tables, types, indexes and the history column off the Lidarr name. The Integrations Lidarr card, the Reports "Delete via Lidarr" action, the merge and remove-track unmonitor options, the Lidarr audit actions and error codes go, on the server, web and Android. The README describes acquisition as it is now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
242 lines
7.8 KiB
Go
242 lines
7.8 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"
|
|
)
|
|
|
|
// 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.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
|
|
}
|
|
|
|
// 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.quarantine.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
|
|
}
|
|
|
|
// 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)
|
|
resetRequests(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)
|
|
resetRequests(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.QuarantineActionKindResolved {
|
|
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)
|
|
resetRequests(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")
|
|
}
|
|
}
|
|
|
|
// TestQuarantineAdminEndpointsRequire403ForNonAdmin verifies all five admin
|
|
// endpoints reject non-admin callers with 403.
|
|
func TestQuarantineAdminEndpointsRequire403ForNonAdmin(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
resetRequests(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.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())
|
|
}
|
|
})
|
|
}
|
|
}
|