Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
303 lines
9.9 KiB
Go
303 lines
9.9 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/catalogue"
|
|
"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/mailer"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
requestSvc := requests.NewService(pool)
|
|
cat := catalogue.New(pool, nil, logger)
|
|
quarantineSvc := quarantine.NewService(pool, "")
|
|
dataDir := t.TempDir()
|
|
tracksSvc := tracks.NewService(pool, logger, 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 }, requests: requestSvc, catalogue: cat, wanted: wanted.New(pool, cat, logger), quarantine: quarantineSvc, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}, notifier: notifications.New(pool, nil, nil)}
|
|
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 developer'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),
|
|
ApiTokenHash: "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)
|
|
}
|
|
}
|
|
|
|
// A guesser who reaches the account limit is refused with 429 before any
|
|
// password check, so even the right password is turned away until the window
|
|
// passes, and the response says when to come back.
|
|
func TestHandleLogin_ThrottledAfterRepeatedFailures(t *testing.T) {
|
|
h, pool := testHandlers(t)
|
|
h.loginGuard = auth.NewLoginGuard()
|
|
seedUser(t, pool, "alice", "hunter2", false)
|
|
|
|
login := func(password string) *httptest.ResponseRecorder {
|
|
body := strings.NewReader(`{"username":"test-alice","password":"` + password + `"}`)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", body)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
h.handleLogin(w, req)
|
|
return w
|
|
}
|
|
for i := 0; i < 10; i++ {
|
|
if w := login("wrong"); w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
|
}
|
|
}
|
|
w := login("hunter2")
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status = %d, want 429 once the account limit is reached", w.Code)
|
|
}
|
|
if w.Header().Get("Retry-After") == "" {
|
|
t.Error("429 without Retry-After")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|