Files
minstrel/internal/server/server_test.go
T
bvandeusen 62222d8438 fix(server,web/m7-cover-sources): forward-fix CI round 2
Three issues from the previous push:

1. internal/api/admin_covers_test.go used t.Context() (Go 1.24+) but
   go.mod declares go 1.23.0. Replace with context.Background() in
   the testHandlersWithCovers helper; add "context" import.

2. internal/server/server_test.go's New() call missed the
   *coverart.SettingsService argument added in T11. The new param
   sits between the cover-art-backfill cap (int) and the *library.Scanner
   pointer in the function signature; the test was using 12 args
   instead of 13. Insert nil at the right position in all six call sites.

3. integrations.test.ts had 11 failing tests after T13 added the
   cover-art-providers section. Both panels render "Save changes",
   "Test connection", and "API key" controls, so the existing
   Lidarr tests' role/text queries matched multiple elements. Add
   helper functions (lidarrSection, coverProvidersSection,
   providerCard) and scope every relevant query with within() so
   each panel's tests interact only with their own controls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:55:35 -04:00

253 lines
8.9 KiB
Go

package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
)
func TestHealthz(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["status"] != "ok" {
t.Errorf("body = %v, want status=ok", body)
}
}
func TestHealthz_IncludesMinClientVersion(t *testing.T) {
t.Parallel()
s := &Server{}
r := chi.NewRouter()
r.Get("/healthz", s.handleHealthz)
ts := httptest.NewServer(r)
defer ts.Close()
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status: got %q want \"ok\"", body["status"])
}
if body["min_client_version"] == "" {
t.Error("min_client_version is empty; clients can't enforce pairing")
}
}
func TestRouter_ServesSPAAtRoot(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/")
if err != nil {
t.Fatalf("GET /: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
}
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/artists/00000000-0000-0000-0000-000000000001")
if err != nil {
t.Fatalf("GET deep link: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html*", ct)
}
}
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/does-not-exist")
if err != nil {
t.Fatalf("GET /api/does-not-exist: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q; /api/* must never return text/html", ct)
}
}
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
resp, err := http.Get(ts.URL + "/rest/does-not-exist")
if err != nil {
t.Fatalf("GET /rest/does-not-exist: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "text/html") {
t.Errorf("Content-Type = %q; /rest/* must never return text/html", ct)
}
}
// TestRouter_AdminSubtreeNotShadowed is a regression test for a real bug we
// shipped in 06a1fe1 and didn't catch until M6a: r.Route("/api/admin", ...)
// in server.go was creating a second chi subtree at the same prefix as the
// nested admin Route inside api.Mount. chi.Walk shows both subtrees as
// registered, but runtime routing dispatch only matches one — every admin
// endpoint registered by api.Mount (Lidarr config, quarantine, etc.)
// silently 404'd for authenticated callers in production.
//
// chi.Walk enumeration would NOT have caught this (both branches are in
// the tree). The only reliable check is to make a real authenticated
// request and confirm it reaches a handler — i.e. doesn't fall through
// to the JSON NotFound (404) that signals shadowing.
//
// The existing tests in this file pass nil for pool, which skips api.Mount
// entirely (gated behind `if s.Pool != nil`), so the conflict didn't manifest.
//
// Gated on MINSTREL_TEST_DATABASE_URL like other integration-leaning tests.
func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
// Seed a test admin user + session so the request actually reaches the
// inner route lookup (auth-middleware short-circuits don't catch shadowing).
ctx := context.Background()
q := dbq.New(pool)
_, _ = pool.Exec(ctx, "DELETE FROM sessions WHERE user_agent = 'shadowing-test'")
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE username = 'test-shadowing-admin'")
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: "test-shadowing-admin",
PasswordHash: "x",
ApiToken: "test-shadowing-token",
IsAdmin: true,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID) })
token := "shadow-test-" + time.Now().Format("20060102150405")
tokenHash := auth.HashSessionToken(token)
_, err = pool.Exec(ctx,
"INSERT INTO sessions (user_id, token_hash, user_agent) VALUES ($1, $2, 'shadowing-test')",
user.ID, tokenHash[:],
)
if err != nil {
t.Fatalf("insert session: %v", err)
}
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{})
ts := httptest.NewServer(s.Router())
defer ts.Close()
// Each path is registered by a different package: lidarr/config from
// api.Mount and admin/scan from server.Router. If chi runtime dispatch
// only routes one /api/admin subtree, one of these returns 404 — that
// IS the bug we shipped in 06a1fe1.
cases := []struct {
method string
path string
owner string
}{
{http.MethodGet, "/api/admin/lidarr/config", "api.Mount"},
// /api/admin/scan would actually trigger a real library scan via
// stubScanner; we don't include it. The lidarr/config path is the
// canonical regression check.
}
for _, tc := range cases {
req, err := http.NewRequest(tc.method, ts.URL+tc.path, nil)
if err != nil {
t.Fatalf("build %s %s: %v", tc.method, tc.path, err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
_ = resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
t.Errorf("%s %s (owner=%s) returned 404 with valid admin auth — route is shadowed",
tc.method, tc.path, tc.owner)
}
}
}
// stubScanner is a no-op ScanTrigger used only to make Server.Router()
// register /api/admin/scan. Its Scan method must never be called by the
// route-presence assertions in this file.
type stubScanner struct{}
func (stubScanner) Scan(_ context.Context) (library.Stats, error) {
panic("stubScanner.Scan called from server_test")
}