From b5c3fc4d487b570d6cf1854f4273a65efa06a72a Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 22:32:33 +0200 Subject: [PATCH] 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 --- backend/internal/web/handlers_auth_test.go | 223 +++++++++++++++++++++ backend/templates/layout_test.go | 44 ++++ 2 files changed, 267 insertions(+) create mode 100644 backend/templates/layout_test.go diff --git a/backend/internal/web/handlers_auth_test.go b/backend/internal/web/handlers_auth_test.go index 86f0316..cd9a5ae 100644 --- a/backend/internal/web/handlers_auth_test.go +++ b/backend/internal/web/handlers_auth_test.go @@ -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()) + } +} diff --git a/backend/templates/layout_test.go b/backend/templates/layout_test.go new file mode 100644 index 0000000..2389a02 --- /dev/null +++ b/backend/templates/layout_test.go @@ -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)") + } +}