253 lines
8.6 KiB
Go
253 lines
8.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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"
|
|
"time"
|
|
)
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{})
|
|
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 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{})
|
|
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{})
|
|
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{})
|
|
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{})
|
|
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{})
|
|
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(ctx context.Context) (library.Stats, error) {
|
|
panic("stubScanner.Scan called from server_test")
|
|
}
|