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.
352 lines
11 KiB
Go
352 lines
11 KiB
Go
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
|
|
}
|