test(02-06): add failing tests for logout, protected routes, and layout auth

- TestLogout_Success: POST /logout with valid cookie -> 303, cookie cleared, session deleted
- TestLogout_UnauthRedirectsToLogin: POST /logout without cookie -> 303 from RequireAuth
- TestLogout_HXRedirect: HTMX logout -> 200 + HX-Redirect: /login
- TestLogout_AfterLogoutSubsequentRequestUnauth: stale cookie blocked after logout
- TestProtected_HomeUnauthRedirects: GET / without session -> 303 /login
- TestProtected_HomeUnauthHXRedirect: HTMX GET / without session -> 200 + HX-Redirect
- TestProtected_HomeAuthRendersUserEmail: authed GET / -> 200 with user email
- TestLayout_LogoutFormVisibleWhenAuthed: Layout with user shows logout form
- TestLayout_LogoutFormHiddenWhenUnauthed: Layout with nil user hides logout form
This commit is contained in:
Arthur Belleville 2026-05-14 22:32:33 +02:00
parent 977dafa31d
commit b5c3fc4d48
No known key found for this signature in database
2 changed files with 267 additions and 0 deletions

View file

@ -753,3 +753,226 @@ func TestLogin_RateLimit_AppliesBeforeUserLookup(t *testing.T) {
}
}
}
// ---- Logout Tests ----
func TestLogout_Success(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
user := preInsertUser(t, ctx, q, "logout@example.com", "correct-horse-12chars")
cookieValue, _, err := store.Create(ctx, user.ID)
if err != nil {
t.Fatalf("store.Create: %v", err)
}
sessionID := hashCookieValue(t, cookieValue)
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d; want 303", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q; want /login", loc)
}
// Session cookie must be cleared (Max-Age=0 or Expires in the past).
var found *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
found = c
break
}
}
if found == nil {
t.Fatal("expected Set-Cookie header to clear the session cookie; none found")
}
if found.MaxAge > 0 {
t.Errorf("session cookie Max-Age = %d; want <= 0 (expired/cleared)", found.MaxAge)
}
// Session row must be hard-deleted from DB (D-06).
var count int
row := pool.QueryRow(ctx, "SELECT COUNT(*) FROM sessions WHERE id = $1", sessionID)
if err := row.Scan(&count); err != nil {
t.Fatalf("session count query: %v", err)
}
if count != 0 {
t.Errorf("session row still exists after logout; want 0 (D-06 hard delete)")
}
}
func TestLogout_UnauthRedirectsToLogin(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
// POST /logout with NO cookie — RequireAuth must block it.
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
// Must redirect to /login (from RequireAuth), NOT a 500.
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d; want 303 (RequireAuth redirect)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q; want /login", loc)
}
}
func TestLogout_HXRedirect(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
user := preInsertUser(t, ctx, q, "logouthtmx@example.com", "correct-horse-12chars")
cookieValue, _, err := store.Create(ctx, user.ID)
if err != nil {
t.Fatalf("store.Create: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue})
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HTMX status = %d; want 200", rec.Code)
}
if hxRedir := rec.Header().Get("HX-Redirect"); hxRedir != "/login" {
t.Errorf("HX-Redirect = %q; want /login", hxRedir)
}
}
func TestLogout_AfterLogoutSubsequentRequestUnauth(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
user := preInsertUser(t, ctx, q, "stale@example.com", "correct-horse-12chars")
cookieValue, _, err := store.Create(ctx, user.ID)
if err != nil {
t.Fatalf("store.Create: %v", err)
}
// Logout first.
logoutReq := httptest.NewRequest(http.MethodPost, "/logout", nil)
logoutReq.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue})
logoutRec := httptest.NewRecorder()
router.ServeHTTP(logoutRec, logoutReq)
if logoutRec.Code != http.StatusSeeOther {
t.Fatalf("logout status = %d; want 303", logoutRec.Code)
}
// Simulate attacker still holding the old cookie — GET / must redirect to /login.
followReq := httptest.NewRequest(http.MethodGet, "/", nil)
followReq.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue})
followRec := httptest.NewRecorder()
router.ServeHTTP(followRec, followReq)
if followRec.Code != http.StatusSeeOther {
t.Fatalf("post-logout GET / status = %d; want 303 (session row deleted)", followRec.Code)
}
if loc := followRec.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q; want /login", loc)
}
}
// ---- Protected Route Tests ----
func TestProtected_HomeUnauthRedirects(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d; want 303 (unauth GET / redirects to /login)", rec.Code)
}
if loc := rec.Header().Get("Location"); loc != "/login" {
t.Errorf("Location = %q; want /login", loc)
}
}
func TestProtected_HomeUnauthHXRedirect(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("HTMX status = %d; want 200", rec.Code)
}
if hxRedir := rec.Header().Get("HX-Redirect"); hxRedir != "/login" {
t.Errorf("HX-Redirect = %q; want /login", hxRedir)
}
}
func TestProtected_HomeAuthRendersUserEmail(t *testing.T) {
pool, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
q := sqlc.New(pool)
store := auth.NewStore(q)
router := newTestRouter(q, store)
user := preInsertUser(t, ctx, q, "alice@example.com", "correct-horse-12chars")
cookieValue, _, err := store.Create(ctx, user.ID)
if err != nil {
t.Fatalf("store.Create: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: cookieValue})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d; want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "alice@example.com") {
t.Errorf("body must contain user email 'alice@example.com'; got: %s", rec.Body.String())
}
}

View file

@ -0,0 +1,44 @@
package templates
import (
"bytes"
"context"
"strings"
"testing"
"backend/internal/auth"
)
// TestLayout_LogoutFormVisibleWhenAuthed verifies that the logout form is
// rendered in the header when Layout receives a non-nil user (D-22).
func TestLayout_LogoutFormVisibleWhenAuthed(t *testing.T) {
var buf bytes.Buffer
user := &auth.User{Email: "a@b.c"}
err := Layout("Test", user).Render(context.Background(), &buf)
if err != nil {
t.Fatalf("Layout.Render: %v", err)
}
body := buf.String()
if !strings.Contains(body, `action="/logout"`) {
t.Errorf("Layout body missing action=\"/logout\"; want logout form when authed\nbody: %s", body)
}
if !strings.Contains(body, `method="POST"`) {
t.Errorf("Layout body missing method=\"POST\"; logout must be a POST form (D-22)")
}
}
// TestLayout_LogoutFormHiddenWhenUnauthed verifies that no logout form is
// rendered when Layout receives a nil user (unauthenticated request).
func TestLayout_LogoutFormHiddenWhenUnauthed(t *testing.T) {
var buf bytes.Buffer
err := Layout("Test", nil).Render(context.Background(), &buf)
if err != nil {
t.Fatalf("Layout.Render: %v", err)
}
body := buf.String()
if strings.Contains(body, `action="/logout"`) {
t.Errorf("Layout body must NOT contain action=\"/logout\" when user is nil (unauthenticated)")
}
}