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>
217 lines
7.5 KiB
Go
217 lines
7.5 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// newAdminTracksRouter builds a test chi router with the admin-tracks
|
|
// endpoint. RequireAdmin middleware is applied; tests inject the user into
|
|
// context manually (RequireUser is bypassed). Mirrors the pattern from
|
|
// admin_quarantine_test.go.
|
|
func newAdminTracksRouter(h *handlers) chi.Router {
|
|
r := chi.NewRouter()
|
|
r.Route("/api/admin", func(admin chi.Router) {
|
|
admin.Use(auth.RequireAdmin())
|
|
admin.Delete("/tracks/{id}", h.handleRemoveTrack)
|
|
})
|
|
return r
|
|
}
|
|
|
|
// doAdminTracksReq fires an HTTP request against the admin-tracks router
|
|
// with the given user in context.
|
|
func doAdminTracksReq(t *testing.T, h *handlers, method, path string, user dbq.User) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
|
|
req = withUser(req, user)
|
|
w := httptest.NewRecorder()
|
|
newAdminTracksRouter(h).ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// doAdminTracksReqNoUser fires a request directly at the handler with NO
|
|
// user injected into context. Used to exercise the defensive 401 branch
|
|
// inside the handler — real traffic hits RequireUser upstream, but the
|
|
// handler defends against routing misconfigurations and we test that path
|
|
// here. We bypass the admin router entirely since RequireAdmin would
|
|
// short-circuit with 500 internal_error on missing-user-in-context (which
|
|
// is its programmer-error contract); the handler's own 401 path is what
|
|
// we want to assert.
|
|
func doAdminTracksReqNoUser(t *testing.T, h *handlers, method, path, idParam string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, bytes.NewBuffer(nil))
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", idParam)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
w := httptest.NewRecorder()
|
|
h.handleRemoveTrack(w, req)
|
|
return w
|
|
}
|
|
|
|
// seedTrackForRemoveTest creates a fresh artist+album+track on disk and
|
|
// returns the track row. The mbids are set on the track and album when
|
|
// non-empty.
|
|
func seedTrackForRemoveTest(t *testing.T, h *handlers, unique, trackMbid, albumMbid string) (dbq.Track, string) {
|
|
t.Helper()
|
|
artist := seedArtist(t, h.pool, "RemoveTest 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)
|
|
}
|
|
q := dbq.New(h.pool)
|
|
var albumMbidPtr *string
|
|
if albumMbid != "" {
|
|
albumMbidPtr = &albumMbid
|
|
}
|
|
album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
|
Title: "RemoveTest Album " + unique,
|
|
SortTitle: "RemoveTest Album " + unique,
|
|
ArtistID: artist.ID,
|
|
Mbid: albumMbidPtr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertAlbum: %v", err)
|
|
}
|
|
one := int32(1)
|
|
var trackMbidPtr *string
|
|
if trackMbid != "" {
|
|
trackMbidPtr = &trackMbid
|
|
}
|
|
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
|
Title: "RemoveTest Track " + unique,
|
|
AlbumID: album.ID,
|
|
ArtistID: artist.ID,
|
|
TrackNumber: &one,
|
|
DurationMs: 30000,
|
|
FilePath: path,
|
|
FileSize: 5,
|
|
FileFormat: "mp3",
|
|
Mbid: trackMbidPtr,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpsertTrack: %v", err)
|
|
}
|
|
return tr, path
|
|
}
|
|
|
|
// TestHandleRemoveTrack_NonAdmin403 verifies the RequireAdmin middleware
|
|
// rejects non-admin callers. Asserts the existing middleware shape
|
|
// ({"error":"not_authorized"}) — spec §5 says `unauthorized`, but the
|
|
// shared middleware emits not_authorized today, and this handler doesn't
|
|
// own that envelope.
|
|
func TestHandleRemoveTrack_NonAdmin403(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
nonAdmin := seedUser(t, pool, "regular", "pw", false)
|
|
fakeID := "00000000-0000-0000-0000-000000000001"
|
|
|
|
w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+fakeID, nonAdmin)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want 403; 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"] != "not_authorized" {
|
|
t.Errorf("error = %q, want not_authorized", resp["error"])
|
|
}
|
|
}
|
|
|
|
// TestHandleRemoveTrack_NoSession401 exercises the defensive no-user-in-
|
|
// context branch in the handler itself. Real traffic hits RequireUser
|
|
// upstream (which emits 401 with text "unauthenticated"), but the handler
|
|
// also defends against routing misconfigurations and we test that
|
|
// directly.
|
|
func TestHandleRemoveTrack_NoSession401(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
|
|
tr, _ := seedTrackForRemoveTest(t, h, "no-session", "", "")
|
|
w := doAdminTracksReqNoUser(t, h, http.MethodDelete,
|
|
"/api/admin/tracks/"+uuidToString(tr.ID), uuidToString(tr.ID))
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var env errorBody
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
}
|
|
if env.Error.Code != "unauthenticated" {
|
|
t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleRemoveTrack_UnknownID404 verifies ErrNotFound from the service
|
|
// maps to 404 not_found in the spec envelope.
|
|
func TestHandleRemoveTrack_UnknownID404(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
admin := seedUser(t, pool, "admin", "pw", true)
|
|
|
|
// Well-formed UUID with no matching row in the DB.
|
|
missing := "00000000-0000-0000-0000-0000000000aa"
|
|
w := doAdminTracksReq(t, h, http.MethodDelete, "/api/admin/tracks/"+missing, admin)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
|
|
}
|
|
var env errorBody
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
}
|
|
if env.Error.Code != "not_found" {
|
|
t.Errorf("error.code = %q, want not_found", env.Error.Code)
|
|
}
|
|
}
|
|
|
|
// TestHandleRemoveTrack_HappyPath verifies the success envelope: 200 with
|
|
// deleted_track_id and the optional cascade fields when they fired.
|
|
func TestHandleRemoveTrack_HappyPath(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
truncateLibrary(t, pool)
|
|
admin := seedUser(t, pool, "admin", "pw", true)
|
|
|
|
// Track with no mbid; sole track on its album/artist so the cascade
|
|
// fills both deleted_album_id and deleted_artist_id.
|
|
tr, path := seedTrackForRemoveTest(t, h, "noumon-happy", "", "")
|
|
w := doAdminTracksReq(t, h, http.MethodDelete,
|
|
"/api/admin/tracks/"+uuidToString(tr.ID), admin)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var got removeTrackResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v; body = %s", err, w.Body.String())
|
|
}
|
|
if got.DeletedTrackID != uuidToString(tr.ID) {
|
|
t.Errorf("deleted_track_id = %q, want %q", got.DeletedTrackID, uuidToString(tr.ID))
|
|
}
|
|
if got.DeletedAlbumID == nil {
|
|
t.Error("deleted_album_id missing; expected sole-track cascade")
|
|
}
|
|
if got.DeletedArtistID == nil {
|
|
t.Error("deleted_artist_id missing; expected sole-track cascade")
|
|
}
|
|
|
|
// File is gone, DB row is gone.
|
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
t.Errorf("file still exists: %v", err)
|
|
}
|
|
if _, err := dbq.New(pool).GetTrackByID(context.Background(), tr.ID); err == nil {
|
|
t.Error("track row still exists")
|
|
}
|
|
}
|