test-go / test (push) Successful in 1m54s
test-web / test (push) Successful in 1m34s
test-go / integration (push) Successful in 4m56s
android / Build + lint + test (push) Successful in 5m41s
release / Build signed APK (releases and dev) (push) Successful in 5m52s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
Every password-shaped check was mounted bare, so guessing was limited only by bcrypt cost. A shared in-memory AttemptLimiter now sits in front of them: - login: 10 failures per account and 50 per address per 15 min, checked before the user lookup and bcrypt; 429 with Retry-After. A success clears the account's count but not the address's. - unknown usernames run a dummy bcrypt compare, so timing no longer says which accounts exist. - register: 10 per address per hour; forgot-password: 5 per address and 3 per email per hour (applied whether or not the email matches); reset: 20 failed tokens per address per 15 min. - Subsonic /rest: same limits as login, counting only wrong credentials, since clients authenticate on every request. Web login, register, reset and forgot-password screens say how long to wait; web and Android carry copy for the rate_limited code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
304 lines
9.9 KiB
Go
304 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/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)
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}
|