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:
parent
977dafa31d
commit
b5c3fc4d48
2 changed files with 267 additions and 0 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
44
backend/templates/layout_test.go
Normal file
44
backend/templates/layout_test.go
Normal 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)")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue