package session_test import ( "context" "log/slog" "net/http" "net/http/httptest" "os" "testing" "time" "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/session" ) const testKeySize = 32 // testIdleTimeout is the idle window used by the expiry tests. const testIdleTimeout = time.Hour // testAbsoluteMaxAge restates the documented absolute session cap // independently of the implementation constant. const testAbsoluteMaxAge = 7 * 24 * time.Hour // fakeClock is a manually advanced clock, so expiry can be tested // without sleeping. type fakeClock struct { t time.Time } func (c *fakeClock) Now() time.Time { return c.t } func (c *fakeClock) Advance(d time.Duration) { c.t = c.t.Add(d) } // testSession creates a Session with a real cookie store and the // real clock. func testSession(t *testing.T) *session.Session { t.Helper() s, _ := testSessionWithClock(t, testIdleTimeout, nil) return s } // testSessionWithClock creates a Session with a real cookie store, // the given idle timeout, and a manually advanced clock. Passing a // nil clock uses the real one. func testSessionWithClock( t *testing.T, idleTimeout time.Duration, clock *fakeClock, ) (*session.Session, *fakeClock) { t.Helper() key := make([]byte, testKeySize) for i := range key { key[i] = byte(i + 42) } store := sessions.NewCookieStore(key) store.Options = &sessions.Options{ Path: "/", MaxAge: 86400 * 7, HttpOnly: true, Secure: false, SameSite: http.SameSiteLaxMode, } cfg := &config.Config{ Environment: config.EnvironmentDev, SessionIdleTimeout: idleTimeout, } log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) var now func() time.Time if clock != nil { now = clock.Now } return session.NewForTest(store, cfg, log, key, now), clock } // newFakeClock returns a clock started at a fixed instant. func newFakeClock() *fakeClock { return &fakeClock{ t: time.Date( 2026, time.January, 2, 3, 4, 5, 0, time.UTC, ), } } // authenticatedSession returns a fresh session that has just been // logged in, along with its manager and clock. func authenticatedSession( t *testing.T, idleTimeout time.Duration, ) (*session.Session, *sessions.Session, *fakeClock) { t.Helper() s, clock := testSessionWithClock( t, idleTimeout, newFakeClock(), ) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") require.True(t, s.IsAuthenticated(sess)) return s, sess, clock } // --- Get and Save Tests --- func TestGet_NewSession(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) require.NotNil(t, sess) assert.True( t, sess.IsNew, "session should be new when no cookie is present", ) } func TestGet_ExistingSession(t *testing.T) { t.Parallel() s := testSession(t) // Create and save a session req1 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) w1 := httptest.NewRecorder() sess1, err := s.Get(req1) require.NoError(t, err) sess1.Values["test_key"] = "test_value" require.NoError(t, s.Save(req1, w1, sess1)) // Extract cookies cookies := w1.Result().Cookies() require.NotEmpty(t, cookies) // Make a new request with the session cookie req2 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) for _, c := range cookies { req2.AddCookie(c) } sess2, err := s.Get(req2) require.NoError(t, err) assert.False( t, sess2.IsNew, "session should not be new when cookie is present", ) assert.Equal(t, "test_value", sess2.Values["test_key"]) } func TestSave_SetsCookie(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) w := httptest.NewRecorder() sess, err := s.Get(req) require.NoError(t, err) sess.Values["key"] = "value" err = s.Save(req, w, sess) require.NoError(t, err) cookies := w.Result().Cookies() require.NotEmpty(t, cookies, "Save should set a cookie") // Verify the cookie has the expected name var found bool for _, c := range cookies { if c.Name == session.SessionName { found = true assert.True( t, c.HttpOnly, "session cookie should be HTTP-only", ) break } } assert.True( t, found, "should find a cookie named %s", session.SessionName, ) } // --- SetUser and User Retrieval Tests --- func TestSetUser_SetsAllFields(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-abc-123", "alice") assert.Equal( t, "user-abc-123", sess.Values[session.UserIDKey], ) assert.Equal( t, "alice", sess.Values[session.UsernameKey], ) assert.Equal( t, true, sess.Values[session.AuthenticatedKey], ) } // testSessionGetter exercises a session string getter before and // after SetUser: it must report false with an empty value on a // fresh session, then true with the expected value once // SetUser(sess, "user-xyz", "bob") has run. func testSessionGetter( t *testing.T, get func( *session.Session, *sessions.Session, ) (string, bool), expected string, ) { t.Helper() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) // Before setting user val, ok := get(s, sess) assert.False( t, ok, "should return false before SetUser", ) assert.Empty(t, val) // After setting user s.SetUser(sess, "user-xyz", "bob") val, ok = get(s, sess) assert.True(t, ok) assert.Equal(t, expected, val) } func TestGetUserID(t *testing.T) { t.Parallel() testSessionGetter( t, func( s *session.Session, sess *sessions.Session, ) (string, bool) { return s.GetUserID(sess) }, "user-xyz", ) } func TestGetUsername(t *testing.T) { t.Parallel() testSessionGetter( t, func( s *session.Session, sess *sessions.Session, ) (string, bool) { return s.GetUsername(sess) }, "bob", ) } // --- IsAuthenticated Tests --- func TestIsAuthenticated_NoSession(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) assert.False( t, s.IsAuthenticated(sess), "new session should not be authenticated", ) } func TestIsAuthenticated_AfterSetUser(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") assert.True(t, s.IsAuthenticated(sess)) } func TestIsAuthenticated_AfterClearUser(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") require.True(t, s.IsAuthenticated(sess)) s.ClearUser(sess) assert.False( t, s.IsAuthenticated(sess), "should not be authenticated after ClearUser", ) } func TestIsAuthenticated_WrongType(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) // Set authenticated to a non-bool value sess.Values[session.AuthenticatedKey] = "yes" assert.False( t, s.IsAuthenticated(sess), "should return false for non-bool authenticated value", ) } // --- ClearUser Tests --- func TestClearUser_RemovesAllKeys(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") s.ClearUser(sess) _, hasUserID := sess.Values[session.UserIDKey] assert.False(t, hasUserID, "UserIDKey should be removed") _, hasUsername := sess.Values[session.UsernameKey] assert.False(t, hasUsername, "UsernameKey should be removed") _, hasAuth := sess.Values[session.AuthenticatedKey] assert.False( t, hasAuth, "AuthenticatedKey should be removed", ) } // --- Destroy Tests --- func TestDestroy_InvalidatesSession(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") s.Destroy(sess) // After Destroy: MaxAge should be -1 (delete cookie) and // user data cleared assert.Equal( t, -1, sess.Options.MaxAge, "Destroy should set MaxAge to -1", ) assert.False( t, s.IsAuthenticated(sess), "should not be authenticated after Destroy", ) _, hasUserID := sess.Values[session.UserIDKey] assert.False(t, hasUserID, "Destroy should clear user ID") } // --- Session Persistence Round-Trip --- func TestSessionPersistence_RoundTrip(t *testing.T) { t.Parallel() s := testSession(t) // Step 1: Create session, set user, save req1 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) w1 := httptest.NewRecorder() sess1, err := s.Get(req1) require.NoError(t, err) s.SetUser(sess1, "user-round-trip", "charlie") require.NoError(t, s.Save(req1, w1, sess1)) cookies := w1.Result().Cookies() require.NotEmpty(t, cookies) // Step 2: New request with cookies -- session data should // persist req2 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/profile", nil, ) for _, c := range cookies { req2.AddCookie(c) } sess2, err := s.Get(req2) require.NoError(t, err) assert.True( t, s.IsAuthenticated(sess2), "session should be authenticated after round-trip", ) userID, ok := s.GetUserID(sess2) assert.True(t, ok) assert.Equal(t, "user-round-trip", userID) username, ok := s.GetUsername(sess2) assert.True(t, ok) assert.Equal(t, "charlie", username) } // --- Constants Tests --- func TestSessionConstants(t *testing.T) { t.Parallel() assert.Equal(t, "webhooker_session", session.SessionName) assert.Equal(t, "user_id", session.UserIDKey) assert.Equal(t, "username", session.UsernameKey) assert.Equal(t, "authenticated", session.AuthenticatedKey) assert.Equal(t, "created_at", session.CreatedAtKey) assert.Equal(t, "last_seen", session.LastSeenKey) } // --- Expiry Tests --- func TestSetUser_StartsBothClocks(t *testing.T) { t.Parallel() _, sess, clock := authenticatedSession(t, testIdleTimeout) assert.Equal( t, clock.Now().Unix(), sess.Values[session.CreatedAtKey], "SetUser should anchor the absolute clock", ) assert.Equal( t, clock.Now().Unix(), sess.Values[session.LastSeenKey], "SetUser should anchor the idle clock", ) } func TestIsAuthenticated_WithinIdleWindow(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) clock.Advance(testIdleTimeout - time.Second) assert.True( t, s.IsAuthenticated(sess), "session should still be valid just inside the idle window", ) } func TestIsAuthenticated_IdleExpired(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) clock.Advance(testIdleTimeout) assert.False( t, s.IsAuthenticated(sess), "session should expire once the idle window lapses", ) } // TestTouch_DoesNotExtendAbsoluteCap is the regression test for the // refresh-the-wrong-clock bug: a session that is used continuously // must survive well past the idle window and still die at the // absolute cap. func TestTouch_DoesNotExtendAbsoluteCap(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) createdAt := sess.Values[session.CreatedAtKey] // Stay active: a request every half idle window, right up to // the absolute cap. step := testIdleTimeout / 2 steps := int(testAbsoluteMaxAge/step) - 1 for i := range steps { clock.Advance(step) s.Touch(sess) require.True( t, s.IsAuthenticated(sess), "active session should survive the idle window "+ "(step %d of %d)", i+1, steps, ) } // One more step of activity takes the session to exactly the // absolute cap, measured from login. Nothing that happened in // the loop may have moved that deadline. clock.Advance(step) s.Touch(sess) assert.False( t, s.IsAuthenticated(sess), "activity must not extend the absolute cap", ) assert.Equal( t, createdAt, sess.Values[session.CreatedAtKey], "Touch must never rewrite the absolute-clock anchor", ) } func TestTouch_RefreshesIdleDeadline(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) // Halfway through the window, activity happens. clock.Advance(testIdleTimeout / 2) assert.True( t, s.Touch(sess), "Touch should refresh once past the lazy-refresh threshold", ) // Past the original deadline, but inside the refreshed one. clock.Advance(testIdleTimeout - time.Second) assert.True( t, s.IsAuthenticated(sess), "refreshed session should outlive the original deadline", ) // And it still expires an idle window after that activity. clock.Advance(time.Second) assert.False( t, s.IsAuthenticated(sess), "refreshed session should expire one window after activity", ) } func TestTouch_LazyBelowRefreshThreshold(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) before := sess.Values[session.LastSeenKey] // A request arriving almost immediately is not worth a cookie // rewrite. clock.Advance(time.Second) assert.False( t, s.Touch(sess), "Touch should not rewrite the session below the threshold", ) assert.Equal( t, before, sess.Values[session.LastSeenKey], "last-seen should be unchanged below the threshold", ) } func TestTouch_UnauthenticatedSessionIsNotRefreshed(t *testing.T) { t.Parallel() s, clock := testSessionWithClock( t, testIdleTimeout, newFakeClock(), ) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) clock.Advance(testIdleTimeout / 2) assert.False( t, s.Touch(sess), "an unauthenticated session must not be refreshed", ) _, hasLastSeen := sess.Values[session.LastSeenKey] assert.False( t, hasLastSeen, "Touch must not stamp an unauthenticated session", ) } func TestTouch_IdleExpiredSessionIsNotRevived(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, testIdleTimeout) clock.Advance(testIdleTimeout) require.False(t, s.IsAuthenticated(sess)) assert.False( t, s.Touch(sess), "an already expired session must not be refreshed", ) assert.False( t, s.IsAuthenticated(sess), "Touch must not revive an expired session", ) } func TestIsAuthenticated_MissingTimestamps(t *testing.T) { t.Parallel() s, _ := testSessionWithClock( t, testIdleTimeout, newFakeClock(), ) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) // A session from before idle expiry existed: authenticated, // but with no timestamps. Fail closed. sess.Values[session.AuthenticatedKey] = true assert.False( t, s.IsAuthenticated(sess), "a session with no timestamps should be rejected", ) } func TestIsAuthenticated_MissingLastSeen(t *testing.T) { t.Parallel() s, sess, _ := authenticatedSession(t, testIdleTimeout) delete(sess.Values, session.LastSeenKey) assert.False( t, s.IsAuthenticated(sess), "a session with no idle anchor should be rejected", ) } func TestIdleTimeoutDisabled_AbsoluteCapStillApplies(t *testing.T) { t.Parallel() s, sess, clock := authenticatedSession(t, 0) // Idle expiry is off, so an untouched session survives an // arbitrary idle stretch. clock.Advance(testAbsoluteMaxAge - time.Second) assert.True( t, s.IsAuthenticated(sess), "idle expiry should be disabled by a non-positive timeout", ) assert.False( t, s.Touch(sess), "Touch should be a no-op when idle expiry is disabled", ) // The absolute cap still ends it. clock.Advance(time.Second) assert.False( t, s.IsAuthenticated(sess), "the absolute cap must still apply with idle expiry off", ) } func TestClearUser_RemovesTimestamps(t *testing.T) { t.Parallel() s, sess, _ := authenticatedSession(t, testIdleTimeout) s.ClearUser(sess) _, hasCreatedAt := sess.Values[session.CreatedAtKey] assert.False(t, hasCreatedAt, "CreatedAtKey should be removed") _, hasLastSeen := sess.Values[session.LastSeenKey] assert.False(t, hasLastSeen, "LastSeenKey should be removed") } // --- Edge Cases --- func TestSetUser_OverwritesPreviousUser(t *testing.T) { t.Parallel() s := testSession(t) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) sess, err := s.Get(req) require.NoError(t, err) s.SetUser(sess, "user-1", "alice") assert.True(t, s.IsAuthenticated(sess)) // Overwrite with a different user s.SetUser(sess, "user-2", "bob") userID, ok := s.GetUserID(sess) assert.True(t, ok) assert.Equal(t, "user-2", userID) username, ok := s.GetUsername(sess) assert.True(t, ok) assert.Equal(t, "bob", username) } func TestDestroy_ThenSave_DeletesCookie(t *testing.T) { t.Parallel() s := testSession(t) // Create a session req1 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) w1 := httptest.NewRecorder() sess, err := s.Get(req1) require.NoError(t, err) s.SetUser(sess, "user-123", "alice") require.NoError(t, s.Save(req1, w1, sess)) cookies := w1.Result().Cookies() require.NotEmpty(t, cookies) // Destroy and save req2 := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/logout", nil, ) for _, c := range cookies { req2.AddCookie(c) } w2 := httptest.NewRecorder() sess2, err := s.Get(req2) require.NoError(t, err) s.Destroy(sess2) require.NoError(t, s.Save(req2, w2, sess2)) // The cookie should have MaxAge = -1 (browser should delete) responseCookies := w2.Result().Cookies() var sessionCookie *http.Cookie for _, c := range responseCookies { if c.Name == session.SessionName { sessionCookie = c break } } require.NotNil( t, sessionCookie, "should have a session cookie in response", ) assert.Negative( t, sessionCookie.MaxAge, "destroyed session cookie should have negative MaxAge", ) }