fix(server): drop duplicate /api/admin route mount that shadowed api.Mount endpoints

Bug: r.Route("/api/admin", ...) in server.go for the /scan endpoint
created a second chi subtree at the same prefix as the nested admin
Route inside api.Mount. chi.Walk shows both subtrees registered, but
runtime routing dispatch only matched one — so every /api/admin/*
endpoint registered by api.Mount (Lidarr config, quarantine admin,
requests admin) silently returned 404 to authenticated callers.

Has shipped this way since 06a1fe1; M5a/b/c admin tests didn't catch
it because they call api.Mount on a fresh chi.NewRouter() rather than
going through Server.Router(), so only one /api/admin registration
existed in those tests.

Fix: register /api/admin/scan as a single inline-middleware route
(r.With(...).Post(...)) instead of a Route subtree, eliminating the
duplicate /api/admin mount.

Adds a regression test that uses a real seeded admin session to make
an authenticated GET /api/admin/lidarr/config and asserts != 404.
Verified the test fails without the fix and passes with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:05:15 -04:00
parent a598ea1418
commit 0c5c10d00b
2 changed files with 120 additions and 7 deletions
+8 -7
View File
@@ -74,13 +74,14 @@ func (s *Server) Router() http.Handler {
lidarrReqs := lidarrrequests.NewService(s.Pool, lidarrCfg, lidarrClientFn, nil)
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar)
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireUser(s.Pool))
admin.Use(auth.RequireAdmin())
if s.Scanner != nil {
admin.Post("/scan", s.handleAdminScan)
}
})
// /api/admin/scan is the only admin route owned by the server package
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
// subtree that shadows every admin route registered by api.Mount.
if s.Scanner != nil {
r.With(auth.RequireUser(s.Pool), auth.RequireAdmin()).
Post("/api/admin/scan", s.handleAdminScan)
}
subsonic.Mount(r, s.Pool, s.Logger, s.SubsonicCfg, writer)
}
+112
View File
@@ -1,16 +1,25 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"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) {
@@ -111,3 +120,106 @@ func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
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{})
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")
}