From e00ef00db853b7b1ac0824e05f6ed99711eee3b0 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 20 Feb 2026 23:44:46 +0000 Subject: [PATCH] feat: add WithSessions server-side session middleware Co-Authored-By: Virgil --- go.mod | 4 + go.sum | 10 +++ options.go | 13 ++++ sessions_test.go | 198 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 sessions_test.go diff --git a/go.mod b/go.mod index 25780ff..2ff1f3a 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gin-contrib/cors v1.7.6 github.com/gin-contrib/gzip v1.2.5 github.com/gin-contrib/secure v1.1.2 + github.com/gin-contrib/sessions v1.0.4 github.com/gin-contrib/slog v1.2.0 github.com/gin-contrib/static v1.1.5 github.com/gin-contrib/timeout v1.1.0 @@ -38,6 +39,9 @@ require ( github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index de6d468..4c9c307 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjck github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U= github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w= +github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk= github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -65,6 +67,14 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/options.go b/options.go index 8ea490f..9dac80e 100644 --- a/options.go +++ b/options.go @@ -11,6 +11,8 @@ import ( "github.com/gin-contrib/cors" gingzip "github.com/gin-contrib/gzip" "github.com/gin-contrib/secure" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" ginslog "github.com/gin-contrib/slog" "github.com/gin-contrib/static" "github.com/gin-contrib/timeout" @@ -206,3 +208,14 @@ func WithCache(ttl time.Duration) Option { e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl)) } } + +// WithSessions adds server-side session management middleware via +// gin-contrib/sessions using a cookie-based store. The name parameter +// sets the session cookie name (e.g. "session") and secret is the key +// used for cookie signing and encryption. +func WithSessions(name string, secret []byte) Option { + return func(e *Engine) { + store := cookie.NewStore(secret) + e.middlewares = append(e.middlewares, sessions.Sessions(name, store)) + } +} diff --git a/sessions_test.go b/sessions_test.go new file mode 100644 index 0000000..9ca38e6 --- /dev/null +++ b/sessions_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// sessionTestGroup provides /sess/set and /sess/get endpoints for session tests. +type sessionTestGroup struct{} + +func (s *sessionTestGroup) Name() string { return "sess" } +func (s *sessionTestGroup) BasePath() string { return "/sess" } +func (s *sessionTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.POST("/set", func(c *gin.Context) { + session := sessions.Default(c) + session.Set("key", "value") + session.Save() + c.JSON(http.StatusOK, api.OK("saved")) + }) + rg.GET("/get", func(c *gin.Context) { + session := sessions.Default(c) + val := session.Get("key") + c.JSON(http.StatusOK, api.OK(val)) + }) +} + +// ── WithSessions ──────────────────────────────────────────────────────── + +func TestWithSessions_Good_SetsSessionCookie(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == "session" { + found = true + break + } + } + if !found { + t.Fatal("expected Set-Cookie header with name 'session'") + } +} + +func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + + // First request: set session value. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("set: expected 200, got %d", w1.Code) + } + + // Extract the session cookie from the response. + var sessionCookie *http.Cookie + for _, c := range w1.Result().Cookies() { + if c.Name == "session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("set: expected session cookie in response") + } + + // Second request: get session value, sending the cookie back. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/sess/get", nil) + req2.AddCookie(sessionCookie) + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("get: expected 200, got %d", w2.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + data, ok := resp.Data.(string) + if !ok || data != "value" { + t.Fatalf("expected Data=%q, got %v", "value", resp.Data) + } +} + +func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/sess/get", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if resp.Data != nil { + t.Fatalf("expected nil Data for empty session, got %v", resp.Data) + } +} + +func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSessions("session", []byte("test-secret-key!")), + api.WithRequestID(), + ) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Session cookie should be present. + found := false + for _, c := range w.Result().Cookies() { + if c.Name == "session" { + found = true + break + } + } + if !found { + t.Fatal("expected session cookie") + } + + // Request ID should also be present. + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Applying WithSessions twice should not panic. + e, err := api.New( + api.WithSessions("session", []byte("secret-one-here!")), + api.WithSessions("session", []byte("secret-two-here!")), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +}