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/playevents" ) // 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{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} 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 = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), 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 = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), 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") } }