feat(server/m7-cover-sources): admin cover-sources HTTP handlers
Three endpoints for the admin Settings UI:
- GET /api/admin/cover-sources — lists registered providers with
current settings + capability badges + sources_version
- PATCH /api/admin/cover-sources/{provider_id} — updates enabled
and/or api_key; returns version_bumped: true only when the
enabled set changed
- POST /api/admin/cover-sources/{provider_id}/test — invokes
TestableProvider.TestConnection; returns ok:bool + duration_ms +
error string. Returns 200 in both success and failure cases (the
operation completed; the test result is data, not an error).
api_key is never echoed in GET — replaced with api_key_set: bool
to follow the standard credential-display convention. PATCH
api_key: "" clears; omitting the field leaves it unchanged. PATCH
on an unknown provider_id returns 404.
All under existing RequireAdmin middleware. Routes registered
alongside the library/coverage endpoint in the /api/admin/ tree.
handlers struct gains a coverSettings *coverart.SettingsService
field; server.New + cmd/minstrel/main.go plumb the existing
coverSettings instance through.
coverart.ResetRegistryForTests() exported wrapper added so api
package tests can reset the registry without importing internals.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
)
|
||||
|
||||
// apiTestAlbumProvider is a minimal AlbumCoverProvider for use in api
|
||||
// package tests. Does not implement TestableProvider.
|
||||
type apiTestAlbumProvider struct {
|
||||
id string
|
||||
display string
|
||||
}
|
||||
|
||||
func (p *apiTestAlbumProvider) ID() string { return p.id }
|
||||
func (p *apiTestAlbumProvider) DisplayName() string { return p.display }
|
||||
func (p *apiTestAlbumProvider) RequiresAPIKey() bool { return false }
|
||||
func (p *apiTestAlbumProvider) DefaultEnabled() bool { return true }
|
||||
func (p *apiTestAlbumProvider) Configure(_ coverart.ProviderSettings) error { return nil }
|
||||
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ string) ([]byte, error) {
|
||||
return []byte("img"), nil
|
||||
}
|
||||
|
||||
// apiTestTestableProvider is an AlbumCoverProvider that also implements
|
||||
// TestableProvider (so the test connection button exercises the success path).
|
||||
type apiTestTestableProvider struct {
|
||||
apiTestAlbumProvider
|
||||
}
|
||||
|
||||
func (p *apiTestTestableProvider) TestConnection(_ context.Context) error { return nil }
|
||||
|
||||
func newAdminCoverSourcesRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// testHandlersWithCoverSettings extends testHandlers by installing a
|
||||
// SettingsService backed by the test DB. Caller is responsible for
|
||||
// registering whichever fake providers the test needs BEFORE calling
|
||||
// this helper, via coverart.Register. Use coverart.ResetRegistryForTests
|
||||
// with t.Cleanup to restore registry state after the test.
|
||||
func testHandlersWithCoverSettings(t *testing.T) (*handlers, *coverart.SettingsService) {
|
||||
t.Helper()
|
||||
h, pool := testHandlers(t)
|
||||
s, err := coverart.NewSettingsService(context.Background(), pool, h.logger)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSettingsService: %v", err)
|
||||
}
|
||||
h.coverSettings = s
|
||||
return h, s
|
||||
}
|
||||
|
||||
func TestAdminListCoverSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
coverart.Register(&apiTestAlbumProvider{id: "test-album-prov", display: "Test Album Provider"})
|
||||
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
admin := seedUser(t, h.pool, "cslist", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/cover-sources", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp coverProvidersListResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Providers) != 1 {
|
||||
t.Fatalf("providers len = %d, want 1", len(resp.Providers))
|
||||
}
|
||||
p := resp.Providers[0]
|
||||
if p.ID != "test-album-prov" {
|
||||
t.Errorf("ID = %q, want test-album-prov", p.ID)
|
||||
}
|
||||
if p.DisplayName != "Test Album Provider" {
|
||||
t.Errorf("DisplayName = %q, want Test Album Provider", p.DisplayName)
|
||||
}
|
||||
if p.APIKeySet {
|
||||
t.Error("api_key_set = true, want false (no key set)")
|
||||
}
|
||||
// apiTestAlbumProvider implements AlbumCoverProvider but not TestableProvider.
|
||||
if p.Testable {
|
||||
t.Error("testable = true, want false")
|
||||
}
|
||||
found := false
|
||||
for _, s := range p.Supports {
|
||||
if s == "album_cover" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("supports = %v, want to include album_cover", p.Supports)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateCoverSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
coverart.Register(&apiTestAlbumProvider{id: "flip-prov", display: "Flip Provider"})
|
||||
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
admin := seedUser(t, h.pool, "csflip", "pw", true)
|
||||
|
||||
disabled := false
|
||||
body, _ := json.Marshal(updateCoverSourceReq{Enabled: &disabled})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/flip-prov", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp updateCoverSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.VersionBumped {
|
||||
t.Error("version_bumped = false, want true after flipping enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateCoverSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
coverart.Register(&apiTestAlbumProvider{id: "key-prov", display: "Key Provider"})
|
||||
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
admin := seedUser(t, h.pool, "cskey", "pw", true)
|
||||
|
||||
newKey := "newkey"
|
||||
body, _ := json.Marshal(updateCoverSourceReq{APIKey: &newKey})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/key-prov", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp updateCoverSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.VersionBumped {
|
||||
t.Error("version_bumped = true, want false for key-only change")
|
||||
}
|
||||
if !resp.APIKeySet {
|
||||
t.Error("api_key_set = false, want true after setting key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUpdateCoverSource_UnknownProvider404(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
// No providers registered — anything we patch will be not-found.
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
admin := seedUser(t, h.pool, "cs404", "pw", true)
|
||||
|
||||
body, _ := json.Marshal(updateCoverSourceReq{})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/missing", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTestCoverSource_NonAdminReturns403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
coverart.Register(&apiTestTestableProvider{apiTestAlbumProvider{id: "testable-prov", display: "Testable"}})
|
||||
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
nonAdmin := seedUser(t, h.pool, "csnoadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/testable-prov/test", nil)
|
||||
req = withUser(req, nonAdmin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminTestCoverSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
coverart.ResetRegistryForTests()
|
||||
t.Cleanup(coverart.ResetRegistryForTests)
|
||||
|
||||
// apiTestAlbumProvider does NOT implement TestableProvider.
|
||||
coverart.Register(&apiTestAlbumProvider{id: "nontestable-prov", display: "Non-Testable"})
|
||||
|
||||
h, _ := testHandlersWithCoverSettings(t)
|
||||
admin := seedUser(t, h.pool, "csntest", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/nontestable-prov/test", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp testCoverSourceResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.OK {
|
||||
t.Error("ok = true, want false for non-testable provider")
|
||||
}
|
||||
if resp.Error == "" {
|
||||
t.Error("error string empty, want message mentioning test connection")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user