Files
minstrel/internal/api/auth_test.go
T
bvandeusen 0d0a8f46b1
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00

274 lines
8.8 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"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/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
)
// testHandlers spins up a handlers instance against MINSTREL_TEST_DATABASE_URL.
// Skips in -short mode or when the env var is missing, matching the pattern
// used elsewhere (scanner_test.go, etc.).
func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
t.Helper()
if testing.Short() {
t.Skip("skipping api integration in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := db.Migrate(dsn, logger); 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)
dbtest.ResetDB(t, pool)
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
recSettings, err := recsettings.New(context.Background(), pool, logger)
if err != nil {
t.Fatalf("recsettings: %v", err)
}
lidarrCfg := lidarrconfig.New(pool)
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
// tracks.Service has no Lidarr unmonitorer in tests by default; the
// admin-tracks tests below override h.tracks via installTracksLidarrStub
// when they need a stubbed Lidarr.
dataDir := t.TempDir()
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
playlistsSvc := playlists.NewService(pool, logger, dataDir)
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
return h, pool
}
// seedUser creates a test user. The supplied username is automatically
// prefixed with dbtest.TestUserPrefix so dbtest.ResetDB cleans it up
// without touching the operator's admin row. Callers that need to send
// the username in HTTP bodies or compare against API responses must
// include the same prefix in their literals.
func seedUser(t *testing.T, pool *pgxpool.Pool, username, password string, isAdmin bool) dbq.User {
t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("bcrypt: %v", err)
}
prefixed := dbtest.TestUserPrefix + username
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: prefixed,
PasswordHash: string(hash),
ApiToken: "test-api-token-" + prefixed,
IsAdmin: isAdmin,
})
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
return u
}
func TestHandleLogin_SuccessSetsCookieAndReturnsToken(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"test-alice","password":"hunter2"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
User struct {
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
} `json:"user"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v\nbody=%s", err, w.Body.String())
}
if resp.Token == "" {
t.Error("token empty")
}
if resp.User.Username != "test-alice" || resp.User.IsAdmin {
t.Errorf("user = %+v, want alice/non-admin", resp.User)
}
var cookieFound bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName {
cookieFound = true
if !c.HttpOnly {
t.Error("session cookie missing HttpOnly")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("SameSite = %v, want Strict", c.SameSite)
}
if c.Value != resp.Token {
t.Error("cookie value does not match response token")
}
}
}
if !cookieFound {
t.Error("session cookie not set")
}
}
func TestHandleLogin_WrongPasswordReturns401(t *testing.T) {
h, pool := testHandlers(t)
seedUser(t, pool, "alice", "hunter2", false)
body := strings.NewReader(`{"username":"test-alice","password":"wrong"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_UnknownUserReturns401(t *testing.T) {
h, _ := testHandlers(t)
body := strings.NewReader(`{"username":"ghost","password":"whatever"}`)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleLogin_MalformedBodyReturns400(t *testing.T) {
h, _ := testHandlers(t)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
bytes.NewReader([]byte("not-json")))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleLogin(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func userCtxKeyForTest() any { return auth.UserCtxKeyForTest() }
func TestHandleLogout_DeletesSessionAndClearsCookie(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
// Manually create a session to log out of.
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: token})
// handleLogout runs behind RequireUser in real routing; simulate that by
// putting the user into context here.
req = withUser(req, user)
w := httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
// Cookie should be cleared.
var cleared bool
for _, c := range w.Result().Cookies() {
if c.Name == auth.SessionCookieName && c.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("session cookie not cleared")
}
// Session row should be gone.
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
if err == nil {
t.Error("session row still present after logout")
}
}
func TestHandleLogout_BearerHeaderWithTrailingWhitespaceDeletesSession(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "alice", "hunter2", false)
token, err := auth.MintSessionToken()
if err != nil {
t.Fatalf("mint: %v", err)
}
if _, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
UserID: user.ID,
TokenHash: auth.HashSessionToken(token),
}); err != nil {
t.Fatalf("insert: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", nil)
// Trailing whitespace — RequireUser trims this, so logout must too.
req.Header.Set("Authorization", "Bearer "+token+" ")
req = withUser(req, user)
w := httptest.NewRecorder()
h.handleLogout(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", w.Code)
}
_, err = dbq.New(pool).GetSessionByTokenHash(context.Background(), auth.HashSessionToken(token))
if err == nil {
t.Error("session row still present after bearer logout with trailing whitespace")
}
}