package session_test import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/gorilla/sessions" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/webhooker/internal/session" ) // The tests below exercise the securecookie codecs underneath the // store and nothing else: Session.Get only decodes, so no server-side // expiry check takes part in the result. They exist because // NewCookieStore gives its codecs a 30-day max age that assigning // store.Options does not override, which would let the codec accept a // cookie weeks past the cap the cookie attribute advertises. // issuedCookie returns a session cookie the store itself wrote. func issuedCookie(t *testing.T, s *session.Session) string { t.Helper() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) w := httptest.NewRecorder() sess, err := s.Get(req) require.NoError(t, err) sess.Values["probe"] = "value" require.NoError(t, s.Save(req, w, sess)) cookies := w.Result().Cookies() require.Len(t, cookies, 1) return cookies[0].Value } // restamp rewrites the timestamp inside an encoded session cookie and // re-signs it, yielding the cookie the store would have written at // that instant. securecookie stamps the encoding time itself and // exposes no seam to move it, so its wire format is reproduced here: // the base64url payload is "date|value|mac", where mac is HMAC-SHA256 // of "name|date|value" under the store's key. func restamp( t *testing.T, encoded string, at time.Time, ) string { t.Helper() raw, err := base64.URLEncoding.DecodeString(encoded) require.NoError(t, err) parts := strings.SplitN(string(raw), "|", 3) require.Len(t, parts, 3) stamped := fmt.Sprintf("%d|%s", at.Unix(), parts[1]) mac := hmac.New(sha256.New, testKey()) _, err = mac.Write([]byte(session.SessionName + "|" + stamped)) require.NoError(t, err) payload := append([]byte(stamped+"|"), mac.Sum(nil)...) return base64.URLEncoding.EncodeToString(payload) } // decodeCookie feeds value back through the store's decode path. func decodeCookie( t *testing.T, s *session.Session, value string, ) (*sessions.Session, error) { t.Helper() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{ Name: session.SessionName, Value: value, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) sess, err := s.Get(req) require.NotNil(t, sess) return sess, err } func TestCodec_AcceptsCookieInsideAbsoluteCap(t *testing.T) { t.Parallel() s := testSession(t) sess, err := decodeCookie(t, s, restamp( t, issuedCookie(t, s), time.Now().Add(-(testAbsoluteMaxAge-time.Hour)), )) require.NoError(t, err) assert.False( t, sess.IsNew, "a cookie inside the cap must still decode", ) assert.Equal( t, "value", sess.Values["probe"], "decoding must yield the values that were saved", ) } func TestCodec_RejectsCookiePastAbsoluteCap(t *testing.T) { t.Parallel() s := testSession(t) sess, err := decodeCookie(t, s, restamp( t, issuedCookie(t, s), time.Now().Add(-(testAbsoluteMaxAge+time.Hour)), )) require.Error( t, err, "the codec must refuse a cookie older than the cap", ) assert.Contains( t, err.Error(), "expired timestamp", "rejection must come from the codec's age check", ) assert.True( t, sess.IsNew, "a cookie past the cap must not populate a session", ) assert.Nil( t, sess.Values["probe"], "a cookie past the cap must not yield its values", ) }