fix: convert auth flows from SSE to standard HTTP to fix session cookies
Datastar's NewSSE() flushes HTTP headers before SCS's session middleware can attach the Set-Cookie header, so the session cookie never reaches the browser after login/register/logout. Convert login, register, and logout to standard HTML forms with HTTP redirects, which lets SCS write cookies normally. Also fix return_url capture on the login page (was never being stored in the session). Add handler tests covering login, register, and logout flows.
This commit is contained in:
351
features/auth/handlers_test.go
Normal file
351
features/auth/handlers_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ryanhamamura/games/auth"
|
||||
"github.com/ryanhamamura/games/db/repository"
|
||||
featauth "github.com/ryanhamamura/games/features/auth"
|
||||
"github.com/ryanhamamura/games/features/lobby"
|
||||
appsessions "github.com/ryanhamamura/games/sessions"
|
||||
"github.com/ryanhamamura/games/testutil"
|
||||
)
|
||||
|
||||
// sessionCookieName is the default SCS cookie name used in tests.
|
||||
const sessionCookieName = "session"
|
||||
|
||||
type testSetup struct {
|
||||
db *sql.DB
|
||||
queries *repository.Queries
|
||||
sm *scs.SessionManager
|
||||
}
|
||||
|
||||
func (s *testSetup) ctx() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
func newTestSetup(t *testing.T) *testSetup {
|
||||
t.Helper()
|
||||
db, queries := testutil.NewTestDB(t)
|
||||
sm := testutil.NewTestSessionManager(t, db)
|
||||
return &testSetup{db: db, queries: queries, sm: sm}
|
||||
}
|
||||
|
||||
// createTestUser inserts a user into the test database and returns the user ID.
|
||||
func createTestUser(t *testing.T, setup *testSetup, username, password string) string {
|
||||
t.Helper()
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("hashing password: %v", err)
|
||||
}
|
||||
id := uuid.New().String()
|
||||
_, err = setup.queries.CreateUser(setup.ctx(), repository.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username,
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("creating test user: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// postForm sends a POST request with form-encoded body through the session middleware,
|
||||
// forwarding any cookies from a previous response.
|
||||
func postForm(handler http.Handler, path string, values url.Values, cookies []*http.Cookie) *httptest.ResponseRecorder {
|
||||
body := strings.NewReader(values.Encode())
|
||||
req := httptest.NewRequest(http.MethodPost, path, body)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// getPage sends a GET request through the session middleware, forwarding cookies.
|
||||
func getPage(handler http.Handler, path string, cookies []*http.Cookie) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// extractSessionValue makes a GET request with the given cookies to a test endpoint
|
||||
// that reads a session value, verifying the session was persisted correctly.
|
||||
func extractSessionValue(t *testing.T, setup *testSetup, cookies []*http.Cookie, key string) string {
|
||||
t.Helper()
|
||||
var value string
|
||||
handler := setup.sm.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
value = setup.sm.GetString(r.Context(), key)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/check-session", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("session check returned %d", rec.Code)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func TestHandleLogin_Success(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
createTestUser(t, setup, "alice", "password123")
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/login", url.Values{
|
||||
"username": {"alice"},
|
||||
"password": {"password123"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("expected redirect to /, got %q", loc)
|
||||
}
|
||||
|
||||
// Verify the response sets a session cookie
|
||||
cookies := rec.Result().Cookies()
|
||||
if !hasCookie(cookies, sessionCookieName) {
|
||||
t.Fatal("response did not set a session cookie")
|
||||
}
|
||||
|
||||
// Verify session contains user data by reading it back
|
||||
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
|
||||
if userID == "" {
|
||||
t.Error("session does not contain user_id after login")
|
||||
}
|
||||
nickname := extractSessionValue(t, setup, cookies, appsessions.KeyNickname)
|
||||
if nickname != "alice" {
|
||||
t.Errorf("expected nickname %q, got %q", "alice", nickname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_InvalidPassword(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
createTestUser(t, setup, "alice", "password123")
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/login", url.Values{
|
||||
"username": {"alice"},
|
||||
"password": {"wrongpassword"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/login?error=") {
|
||||
t.Errorf("expected redirect to /login?error=..., got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_UnknownUser(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/login", url.Values{
|
||||
"username": {"nonexistent"},
|
||||
"password": {"password123"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/login?error=") {
|
||||
t.Errorf("expected redirect to /login?error=..., got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogin_ReturnURL(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
createTestUser(t, setup, "alice", "password123")
|
||||
|
||||
// First, visit the login page with a return_url to store it in the session
|
||||
loginPageHandler := setup.sm.LoadAndSave(featauth.HandleLoginPage(setup.sm))
|
||||
pageRec := getPage(loginPageHandler, "/login?return_url=/games/abc", nil)
|
||||
cookies := pageRec.Result().Cookies()
|
||||
|
||||
// Now log in with those cookies so the handler can read return_url from session
|
||||
loginHandler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
|
||||
rec := postForm(loginHandler, "/auth/login", url.Values{
|
||||
"username": {"alice"},
|
||||
"password": {"password123"},
|
||||
}, cookies)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/games/abc" {
|
||||
t.Errorf("expected redirect to /games/abc, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_Success(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/register", url.Values{
|
||||
"username": {"newuser"},
|
||||
"password": {"password123"},
|
||||
"confirm": {"password123"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("expected redirect to /, got %q", loc)
|
||||
}
|
||||
|
||||
cookies := rec.Result().Cookies()
|
||||
if !hasCookie(cookies, sessionCookieName) {
|
||||
t.Fatal("response did not set a session cookie")
|
||||
}
|
||||
|
||||
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
|
||||
if userID == "" {
|
||||
t.Error("session does not contain user_id after registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_PasswordMismatch(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/register", url.Values{
|
||||
"username": {"newuser"},
|
||||
"password": {"password123"},
|
||||
"confirm": {"differentpassword"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.Contains(loc, "Passwords+do+not+match") {
|
||||
t.Errorf("expected error about password mismatch, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_InvalidUsername(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/register", url.Values{
|
||||
"username": {"ab"}, // too short
|
||||
"password": {"password123"},
|
||||
"confirm": {"password123"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/register?error=") {
|
||||
t.Errorf("expected redirect to /register?error=..., got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_ShortPassword(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/register", url.Values{
|
||||
"username": {"validuser"},
|
||||
"password": {"short"},
|
||||
"confirm": {"short"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(loc, "/register?error=") {
|
||||
t.Errorf("expected redirect to /register?error=..., got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_DuplicateUsername(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
createTestUser(t, setup, "taken", "password123")
|
||||
|
||||
handler := setup.sm.LoadAndSave(featauth.HandleRegister(setup.queries, setup.sm))
|
||||
rec := postForm(handler, "/auth/register", url.Values{
|
||||
"username": {"taken"},
|
||||
"password": {"password123"},
|
||||
"confirm": {"password123"},
|
||||
}, nil)
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, rec.Code)
|
||||
}
|
||||
loc := rec.Header().Get("Location")
|
||||
if !strings.Contains(loc, "Username+already+taken") {
|
||||
t.Errorf("expected error about duplicate username, got %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLogout(t *testing.T) {
|
||||
setup := newTestSetup(t)
|
||||
createTestUser(t, setup, "alice", "password123")
|
||||
|
||||
// Log in first to establish a session
|
||||
loginHandler := setup.sm.LoadAndSave(featauth.HandleLogin(setup.queries, setup.sm))
|
||||
loginRec := postForm(loginHandler, "/auth/login", url.Values{
|
||||
"username": {"alice"},
|
||||
"password": {"password123"},
|
||||
}, nil)
|
||||
cookies := loginRec.Result().Cookies()
|
||||
|
||||
// Verify we're logged in
|
||||
userID := extractSessionValue(t, setup, cookies, appsessions.KeyUserID)
|
||||
if userID == "" {
|
||||
t.Fatal("expected to be logged in before testing logout")
|
||||
}
|
||||
|
||||
// Now log out
|
||||
logoutHandler := setup.sm.LoadAndSave(lobby.HandleLogout(setup.sm))
|
||||
logoutRec := postForm(logoutHandler, "/logout", nil, cookies)
|
||||
|
||||
if logoutRec.Code != http.StatusSeeOther {
|
||||
t.Errorf("expected status %d, got %d", http.StatusSeeOther, logoutRec.Code)
|
||||
}
|
||||
if loc := logoutRec.Header().Get("Location"); loc != "/" {
|
||||
t.Errorf("expected redirect to /, got %q", loc)
|
||||
}
|
||||
|
||||
// Verify session is cleared — use the cookies from the logout response
|
||||
logoutCookies := logoutRec.Result().Cookies()
|
||||
userID = extractSessionValue(t, setup, logoutCookies, appsessions.KeyUserID)
|
||||
if userID != "" {
|
||||
t.Errorf("expected empty user_id after logout, got %q", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func hasCookie(cookies []*http.Cookie, name string) bool {
|
||||
for _, c := range cookies {
|
||||
if c.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user