1145 lines
26 KiB
Go
1145 lines
26 KiB
Go
package session_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"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)
|
|
}
|
|
|
|
// testKey returns the fixed session key the tests sign with. The
|
|
// codec tests re-sign cookies with it, so it must be the same key the
|
|
// store was built from.
|
|
func testKey() []byte {
|
|
key := make([]byte, testKeySize)
|
|
|
|
for i := range key {
|
|
key[i] = byte(i + 42)
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
// 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 := testKey()
|
|
store := session.NewStore(key)
|
|
|
|
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_RefreshThresholdIsOneTenthOfIdleWindow(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// testRefreshDivisor restates the documented bound independently
|
|
// of the implementation constant: the idle timestamp is rewritten
|
|
// once it is a tenth of the idle window old, which is what makes
|
|
// "expires up to 10% early, never late" true. Both assertions are
|
|
// needed to pin it -- a larger divisor fails the first, a smaller
|
|
// one fails the second.
|
|
const testRefreshDivisor = 10
|
|
|
|
threshold := testIdleTimeout / testRefreshDivisor
|
|
|
|
s, sess, clock := authenticatedSession(t, testIdleTimeout)
|
|
|
|
clock.Advance(threshold - time.Second)
|
|
assert.False(
|
|
t, s.Touch(sess),
|
|
"Touch must not rewrite the session below a tenth of the window",
|
|
)
|
|
|
|
clock.Advance(time.Second)
|
|
assert.True(
|
|
t, s.Touch(sess),
|
|
"Touch must rewrite the session at a tenth of the window",
|
|
)
|
|
}
|
|
|
|
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",
|
|
)
|
|
}
|
|
|
|
// --- Secure Attribute / Transport Tests ---
|
|
|
|
// transportCase describes one client-facing transport and the Secure
|
|
// attribute the session cookie must carry for it.
|
|
type transportCase struct {
|
|
name string
|
|
tls bool
|
|
header string
|
|
want bool
|
|
why string
|
|
}
|
|
|
|
// transportCases enumerates the transports the session cookie has to
|
|
// get right. Every https spelling here is one a real proxy emits.
|
|
func transportCases() []transportCase {
|
|
return []transportCase{
|
|
{
|
|
name: "direct TLS",
|
|
tls: true,
|
|
want: true,
|
|
why: "this process terminated TLS itself",
|
|
},
|
|
{
|
|
name: "proxy reports https",
|
|
header: "https",
|
|
want: true,
|
|
why: "the ordinary reverse-proxy deployment",
|
|
},
|
|
{
|
|
name: "proxy reports HTTPS",
|
|
header: "HTTPS",
|
|
want: true,
|
|
why: "the header value is a case-insensitive token",
|
|
},
|
|
{
|
|
name: "appended chain https, http",
|
|
header: "https, http",
|
|
want: true,
|
|
why: "the leftmost hop is the browser's connection",
|
|
},
|
|
{
|
|
name: "appended chain https,https",
|
|
header: "https,https",
|
|
want: true,
|
|
why: "two TLS hops, no space after the comma",
|
|
},
|
|
{
|
|
name: "trailing space",
|
|
header: "https ",
|
|
want: true,
|
|
why: "whitespace is not part of the token",
|
|
},
|
|
{
|
|
name: "proxy reports http",
|
|
header: "http",
|
|
want: false,
|
|
why: "the negative control: Secure over plaintext is " +
|
|
"dropped by the browser without a word",
|
|
},
|
|
{
|
|
name: "plaintext, no proxy",
|
|
want: false,
|
|
why: "a plain local run must stay loggable-in",
|
|
},
|
|
}
|
|
}
|
|
|
|
// transportRequest builds a request carrying the case's transport.
|
|
func (tc transportCase) request(t *testing.T) *http.Request {
|
|
t.Helper()
|
|
|
|
r := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
|
|
if tc.tls {
|
|
r.TLS = &tls.ConnectionState{}
|
|
}
|
|
|
|
if tc.header != "" {
|
|
r.Header.Set("X-Forwarded-Proto", tc.header)
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// sessionCookieFrom returns the session cookie from a response, or
|
|
// fails the test if there is none.
|
|
func sessionCookieFrom(
|
|
t *testing.T,
|
|
w *httptest.ResponseRecorder,
|
|
) *http.Cookie {
|
|
t.Helper()
|
|
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == session.SessionName {
|
|
return c
|
|
}
|
|
}
|
|
|
|
require.FailNow(t, "no session cookie in response")
|
|
|
|
return nil
|
|
}
|
|
|
|
// TestSave_SecureFollowsRequestTransport is the regression test for
|
|
// the defect this replaces: Secure was fixed at startup from the
|
|
// configured environment, and "dev" is the environment when
|
|
// WEBHOOKER_ENVIRONMENT is unset. A deployment behind a TLS proxy in
|
|
// that DEFAULT posture shipped the authentication cookie with no
|
|
// Secure attribute and said nothing about it.
|
|
//
|
|
// testSession builds its config with EnvironmentDev precisely so that
|
|
// the https cases below fail against the old startup-fixed behaviour.
|
|
func TestSave_SecureFollowsRequestTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range transportCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
r := tc.request(t)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(sess, "user-1", "alice")
|
|
require.NoError(t, s.Save(r, w, sess))
|
|
|
|
assert.Equal(
|
|
t, tc.want, sessionCookieFrom(t, w).Secure,
|
|
"session cookie Secure for %q: %s",
|
|
tc.name, tc.why,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSave_SecureTracksTransportBothWays pins that the flag is not
|
|
// latched. One store serves every request, so a Secure cookie set for
|
|
// a proxied request must not leak into a later plaintext response --
|
|
// the browser would silently discard that one, and a local run would
|
|
// become impossible to log into.
|
|
func TestSave_SecureTracksTransportBothWays(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
secureReq := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
secureReq.Header.Set("X-Forwarded-Proto", "https")
|
|
|
|
secureW := httptest.NewRecorder()
|
|
|
|
secureSess, err := s.Get(secureReq)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Save(secureReq, secureW, secureSess))
|
|
require.True(
|
|
t, sessionCookieFrom(t, secureW).Secure,
|
|
"proxied request should produce a Secure cookie",
|
|
)
|
|
|
|
plainReq := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
plainW := httptest.NewRecorder()
|
|
|
|
plainSess, err := s.Get(plainReq)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Save(plainReq, plainW, plainSess))
|
|
|
|
assert.False(
|
|
t, sessionCookieFrom(t, plainW).Secure,
|
|
"a later plaintext request must not inherit Secure from "+
|
|
"the earlier proxied one",
|
|
)
|
|
}
|
|
|
|
// TestDestroy_DeletionCookieFollowsTransport covers the trap in the
|
|
// deletion path. The store's template Secure is true, so a logout over
|
|
// plaintext that failed to track the transport would emit a Secure
|
|
// deletion cookie -- which the browser drops, leaving the session the
|
|
// user just tried to end still sitting in the jar.
|
|
func TestDestroy_DeletionCookieFollowsTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
|
|
r := httptest.NewRequestWithContext(
|
|
context.Background(), http.MethodGet,
|
|
"http://example.com/", nil,
|
|
)
|
|
w := httptest.NewRecorder()
|
|
|
|
sess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
s.Destroy(sess)
|
|
require.NoError(t, s.Save(r, w, sess))
|
|
|
|
cookie := sessionCookieFrom(t, w)
|
|
|
|
require.Negative(
|
|
t, cookie.MaxAge,
|
|
"Destroy then Save should emit a deletion cookie",
|
|
)
|
|
assert.False(
|
|
t, cookie.Secure,
|
|
"a deletion cookie sent over plaintext must not be Secure, "+
|
|
"or the browser discards it and the session survives",
|
|
)
|
|
}
|
|
|
|
// TestRegenerate_BothCookiesFollowTransport covers the login path.
|
|
// Regenerate writes two cookies -- a deletion for the pre-login
|
|
// session and the new authenticated one -- and both have to match the
|
|
// transport or one of them is silently dropped.
|
|
func TestRegenerate_BothCookiesFollowTransport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range transportCases() {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := testSession(t)
|
|
r := tc.request(t)
|
|
w := httptest.NewRecorder()
|
|
|
|
oldSess, err := s.Get(r)
|
|
require.NoError(t, err)
|
|
|
|
newSess, err := s.Regenerate(r, w, oldSess)
|
|
require.NoError(t, err)
|
|
|
|
s.SetUser(newSess, "user-1", "alice")
|
|
require.NoError(t, s.Save(r, w, newSess))
|
|
|
|
cookies := w.Result().Cookies()
|
|
require.Len(
|
|
t, cookies, 2,
|
|
"Regenerate then Save writes a deletion cookie "+
|
|
"and a replacement",
|
|
)
|
|
|
|
for _, c := range cookies {
|
|
assert.Equal(
|
|
t, tc.want, c.Secure,
|
|
"cookie %d Secure for %q: %s",
|
|
c.MaxAge, tc.name, tc.why,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|