package session_test import ( "context" "log/slog" "net/http" "net/http/httptest" "os" "testing" "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 // testSession creates a Session with a real cookie store for // testing. func testSession(t *testing.T) *session.Session { 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, } log := slog.New(slog.NewTextHandler( os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}, )) return session.NewForTest(store, cfg, log, key) } // --- 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], ) } func TestGetUserID(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) // Before setting user userID, ok := s.GetUserID(sess) assert.False( t, ok, "should return false when no user ID is set", ) assert.Empty(t, userID) // After setting user s.SetUser(sess, "user-xyz", "bob") userID, ok = s.GetUserID(sess) assert.True(t, ok) assert.Equal(t, "user-xyz", userID) } func TestGetUsername(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) // Before setting user username, ok := s.GetUsername(sess) assert.False( t, ok, "should return false when no username is set", ) assert.Empty(t, username) // After setting user s.SetUser(sess, "user-xyz", "bob") username, ok = s.GetUsername(sess) assert.True(t, ok) assert.Equal(t, "bob", username) } // --- 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) } // --- 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", ) }