All checks were successful
check / check (push) Successful in 3m6s
Sessions had only a 7-day absolute lifetime, and that cap was enforced only by the cookie's MaxAge -- i.e. only by the browser. An abandoned session stayed usable for the full week. Sessions are now bounded by two independent, server-enforced clocks, and end at whichever expires first: - absolute: created_at + 7 days, stamped once by SetUser and never rewritten, so no amount of activity can extend it - idle: last_seen + SESSION_IDLE_TIMEOUT (default 24h), pushed forward by the new Session.Touch Both deadlines are checked in Session.expired, which IsAuthenticated now consults, so every existing authentication decision honours them without each call site having to remember. Activity means a request that passes RequireAuth, which is the only place Touch is called; an unauthenticated request carrying the cookie cannot keep a session alive. Touch re-checks authentication itself so that guarantee does not depend on the call site. To avoid re-issuing the session cookie on every authenticated request, Touch rewrites last_seen only once it is older than a tenth of the idle window. The session therefore expires up to 10% early relative to the user's true last request, never late. An authenticated session carrying no timestamps (a cookie minted before this change) is treated as expired, so the failure mode of the upgrade is one forced re-login rather than an unbounded session. Tests use an injected clock rather than sleeps and cover idle expiry, refresh on activity, an actively used session still dying at the absolute cap, refusal to refresh unauthenticated or expired sessions, disabled idle expiry, and startup aborting on an unparseable SESSION_IDLE_TIMEOUT.
854 lines
18 KiB
Go
854 lines
18 KiB
Go
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",
|
|
)
|
|
}
|