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