core/session_test.go

469 lines
15 KiB
Go

// Copyright (c) 2026 Zeni Kim <zenik@smarteching.com>
// Use of this source code is governed by MIT-style
// license that can be found in the LICENSE file.
package core
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"git.smarteching.com/goffee/core/logger"
"github.com/golang-jwt/jwt/v5"
)
// ─────────────────────────────────────────────
// Pure helpers (no external dependencies)
// ─────────────────────────────────────────────
func TestCreateAuthTokenHashedCacheKey(t *testing.T) {
k1 := CreateAuthTokenHashedCacheKey(1, "Mozilla/5.0")
k2 := CreateAuthTokenHashedCacheKey(1, "Mozilla/5.0")
if k1 != k2 {
t.Errorf("expected deterministic cache keys, got %q and %q", k1, k2)
}
// MD5 hex representation is 32 characters long.
if len(k1) != 32 {
t.Errorf("expected a 32 char md5 hex key, got %d chars (%q)", len(k1), k1)
}
k3 := CreateAuthTokenHashedCacheKey(2, "Mozilla/5.0")
if k1 == k3 {
t.Errorf("expected different keys for different user IDs")
}
k4 := CreateAuthTokenHashedCacheKey(1, "curl/8.0")
if k1 == k4 {
t.Errorf("expected different keys for different user agents")
}
}
func TestTemplateEngineEnabled(t *testing.T) {
prev := os.Getenv("TEMPLATE_ENABLE")
t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) })
os.Setenv("TEMPLATE_ENABLE", "")
if templateEngineEnabled() {
t.Errorf("expected template engine to be disabled when unset")
}
os.Setenv("TEMPLATE_ENABLE", "false")
if templateEngineEnabled() {
t.Errorf("expected template engine to be disabled for 'false'")
}
os.Setenv("TEMPLATE_ENABLE", "true")
if !templateEngineEnabled() {
t.Errorf("expected template engine to be enabled for 'true'")
}
}
func TestSlidingRenewThresholdPercent(t *testing.T) {
if slidingRenewThresholdPercent != 0.25 {
t.Errorf("expected threshold 0.25, got %v", slidingRenewThresholdPercent)
}
}
func TestRenewedTokenForRequest(t *testing.T) {
// A context without a request should not panic and return an empty string.
empty := &Context{}
if got := renewedTokenForRequest(empty); got != "" {
t.Errorf("expected empty token for nil request, got %q", got)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := &Context{Request: &Request{httpRequest: r}}
if got := renewedTokenForRequest(c); got != "" {
t.Errorf("expected empty token before marking, got %q", got)
}
markRenewedTokenForRequest(c, "new-token")
if got := renewedTokenForRequest(c); got != "new-token" {
t.Errorf("expected 'new-token', got %q", got)
}
}
func TestMarkRenewedTokenForRequestNilSafety(t *testing.T) {
// Must not panic on a context without request/writer.
markRenewedTokenForRequest(&Context{}, "x")
markRenewedTokenForRequest(&Context{Request: &Request{}}, "x")
}
// ─────────────────────────────────────────────
// SessionUser value store (in-memory only)
// ─────────────────────────────────────────────
func TestSessionUserGetSetIsAuthenticated(t *testing.T) {
// Construct a session without a cache, exercising the value map directly.
s := &SessionUser{values: make(map[string]interface{})}
if s.IsAuthenticated() {
t.Errorf("expected a fresh session to be unauthenticated")
}
if _, ok := s.Get("missing"); ok {
t.Errorf("expected 'missing' key to be absent")
}
}
func TestSessionUserGetUserID(t *testing.T) {
s := &SessionUser{userID: 7}
if s.GetUserID() != 7 {
t.Errorf("expected user ID 7, got %d", s.GetUserID())
}
}
// ─────────────────────────────────────────────
// Redis-backed integration tests (skipped when Redis is unavailable)
// ─────────────────────────────────────────────
// newTestCache returns a Cache connected to a local Redis, or skips the test if
// Redis is not reachable. Tests that require a live cache call this helper.
func newTestCache(t *testing.T) *Cache {
t.Helper()
if os.Getenv("REDIS_HOST") == "" {
os.Setenv("REDIS_HOST", "127.0.0.1")
}
if os.Getenv("REDIS_PORT") == "" {
os.Setenv("REDIS_PORT", "6379")
}
os.Setenv("REDIS_DB", "0")
c := NewCache(CacheConfig{EnableCache: false})
// Probe the connection; skip if Redis is not available.
probeKey := fmt.Sprintf("goffee_test_probe_%d", time.Now().UnixNano())
if err := c.Set(probeKey, "1"); err != nil {
t.Skipf("redis is not available, skipping integration test: %v", err)
}
_ = c.Delete(probeKey)
return c
}
func newSessionContext(r *http.Request, cch *Cache) *Context {
w := httptest.NewRecorder()
return &Context{
Request: &Request{
httpRequest: r,
},
Response: &Response{
headers: []header{},
HttpResponseWriter: w,
},
GetLogger: loggerResolverForTest,
GetCache: func() *Cache { return cch },
}
}
func loggerResolverForTest() *logger.Logger {
return logger.NewLogger(&logger.LogNullDriver{})
}
func TestSessionUserSetGetDelete(t *testing.T) {
cch := newTestCache(t)
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := newSessionContext(r, cch)
s := &SessionUser{
context: c,
values: make(map[string]interface{}),
hashedSessionKey: CreateAuthTokenHashedCacheKey(1, "sess_test-agent"),
}
if err := s.Set("username", "alice"); err != nil {
t.Fatalf("failed setting session value: %v", err)
}
if v, ok := s.Get("username"); !ok || v != "alice" {
t.Errorf("expected username 'alice', got %v (ok=%v)", v, ok)
}
// Value should be persisted to the cache.
cached, err := cch.Get(s.hashedSessionKey)
if err != nil {
t.Fatalf("expected value in cache: %v", err)
}
if cached == "" {
t.Errorf("expected cached session to be non-empty")
}
// Delete returns the removed value and persists the change.
deleted := s.Delete("username")
if deleted != "alice" {
t.Errorf("expected deleted value 'alice', got %v", deleted)
}
if _, ok := s.Get("username"); ok {
t.Errorf("expected username to be deleted")
}
t.Cleanup(func() { _ = cch.Delete(s.hashedSessionKey) })
}
func TestSessionUserFlush(t *testing.T) {
cch := newTestCache(t)
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := newSessionContext(r, cch)
key := CreateAuthTokenHashedCacheKey(2, "sess_flush-agent")
if err := cch.Set(key, `{"a":"b"}`); err != nil {
t.Fatalf("failed seeding cache: %v", err)
}
s := &SessionUser{
context: c,
values: map[string]interface{}{"a": "b"},
hashedSessionKey: key,
authenticated: true,
}
if err := s.Flush(); err != nil {
t.Fatalf("failed flushing session: %v", err)
}
if s.IsAuthenticated() {
t.Errorf("expected session to be unauthenticated after flush")
}
if len(s.values) != 0 {
t.Errorf("expected session values to be cleared")
}
if _, ok := s.Get("a"); ok {
t.Errorf("expected value 'a' to be removed after flush")
}
if _, err := cch.Get(key); err == nil {
t.Errorf("expected cache key to be deleted after flush")
}
}
func TestSessionUserSaveEmptyDeletesKey(t *testing.T) {
cch := newTestCache(t)
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := newSessionContext(r, cch)
key := CreateAuthTokenHashedCacheKey(3, "sess_save-agent")
if err := cch.Set(key, `{"a":"b"}`); err != nil {
t.Fatalf("failed seeding cache: %v", err)
}
s := &SessionUser{
context: c,
values: map[string]interface{}{},
hashedSessionKey: key,
}
if err := s.Save(); err != nil {
t.Fatalf("failed saving empty session: %v", err)
}
if _, err := cch.Get(key); err == nil {
t.Errorf("expected cache key to be deleted when saving an empty session")
}
}
func TestSessionUserInitNoCookie(t *testing.T) {
enableTemplateCookieEnv(t)
// With no cookie present Init must fail gracefully.
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := &Context{
Request: &Request{httpRequest: r},
Response: &Response{
HttpResponseWriter: httptest.NewRecorder(),
},
GetLogger: loggerResolverForTest,
}
s := &SessionUser{}
if s.Init(c) {
t.Errorf("expected Init to return false when no cookie is present")
}
if s.IsAuthenticated() {
t.Errorf("expected session to remain unauthenticated")
}
}
// TestSessionUserInitValidToken exercises the full happy path against Redis:
// JWT generation, cookie round-trip, cache verification and session loading.
func TestSessionUserInitValidToken(t *testing.T) {
cch := newTestCache(t)
enableTemplateCookieEnv(t)
jwtSecret := "test-session-secret"
jwtObj := newJWT(JWTOptions{SigningKey: jwtSecret, LifetimeMinutes: 60})
token, err := jwtObj.GenerateToken(map[string]interface{}{"userID": 123})
if err != nil {
t.Fatalf("failed generating token: %v", err)
}
userAgent := "goffee-test-agent"
hashedCacheKey := CreateAuthTokenHashedCacheKey(123, userAgent)
if err := cch.Set(hashedCacheKey, token); err != nil {
t.Fatalf("failed seeding cached token: %v", err)
}
t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) })
// Build the request with a valid encrypted goffee cookie.
w := httptest.NewRecorder()
if err := SetCookie(w, "user@example.com", token); err != nil {
t.Fatalf("failed setting cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
r.Header.Set("User-Agent", userAgent)
for _, ck := range w.Result().Cookies() {
r.AddCookie(ck)
}
c := newSessionContext(r, cch)
c.GetJWT = func() *JWT { return jwtObj }
s := &SessionUser{}
if !s.Init(c) {
t.Fatalf("expected Init to succeed with a valid token")
}
if !s.IsAuthenticated() {
t.Errorf("expected session to be authenticated")
}
if s.GetUserID() != 123 {
t.Errorf("expected user ID 123, got %d", s.GetUserID())
}
}
// TestSessionUserInitWrongToken ensures a mismatched cached token fails Init.
func TestSessionUserInitWrongToken(t *testing.T) {
cch := newTestCache(t)
enableTemplateCookieEnv(t)
jwtSecret := "test-session-secret"
jwtObj := newJWT(JWTOptions{SigningKey: jwtSecret, LifetimeMinutes: 60})
token, err := jwtObj.GenerateToken(map[string]interface{}{"userID": 5})
if err != nil {
t.Fatalf("failed generating token: %v", err)
}
userAgent := "goffee-test-agent-2"
hashedCacheKey := CreateAuthTokenHashedCacheKey(5, userAgent)
// Cache a different token so the verification fails.
if err := cch.Set(hashedCacheKey, "a-different-token"); err != nil {
t.Fatalf("failed seeding cached token: %v", err)
}
t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) })
w := httptest.NewRecorder()
if err := SetCookie(w, "user@example.com", token); err != nil {
t.Fatalf("failed setting cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
r.Header.Set("User-Agent", userAgent)
for _, ck := range w.Result().Cookies() {
r.AddCookie(ck)
}
c := newSessionContext(r, cch)
c.GetJWT = func() *JWT { return jwtObj }
s := &SessionUser{}
if s.Init(c) {
t.Errorf("expected Init to fail when the cached token does not match")
}
}
func TestSessionUserMaybeRenewSkipsWhenTemplatesDisabled(t *testing.T) {
prev := os.Getenv("TEMPLATE_ENABLE")
os.Setenv("TEMPLATE_ENABLE", "false")
t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) })
jwtObj := newJWT(JWTOptions{SigningKey: "k", LifetimeMinutes: 60})
token, _ := jwtObj.GenerateToken(map[string]interface{}{"userID": 1})
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := &Context{
Request: &Request{httpRequest: r},
Response: &Response{HttpResponseWriter: httptest.NewRecorder()},
GetLogger: loggerResolverForTest,
GetJWT: func() *JWT { return jwtObj },
GetCache: func() *Cache { return &Cache{} },
}
// Should simply return without renewing or panicking.
s := &SessionUser{}
s.maybeRenew(c, "user@example.com", 1, "key", token)
if renewedTokenForRequest(c) != "" {
t.Errorf("expected no renewal when templates are disabled")
}
}
// buildTokenWithCustomLifetime signs a token whose exp is now + expiresIn, while
// the JWT object advertises lifetimeMinutes. This lets us simulate a token that
// appears to have been issued long ago (and is therefore due for renewal) while
// still being cryptographically valid.
func buildTokenWithCustomLifetime(t *testing.T, jwtObj *JWT, expiresIn time.Duration, payload map[string]interface{}) string {
t.Helper()
claims, err := mapClaims(payload, time.Now().Add(expiresIn))
if err != nil {
t.Fatalf("failed building claims: %v", err)
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(jwtObj.signingKey)
if err != nil {
t.Fatalf("failed signing token: %v", err)
}
return signed
}
func TestSessionUserMaybeRenewRenews(t *testing.T) {
cch := newTestCache(t)
enableTemplateCookieEnv(t)
// Lifetime 60 min, token expiring in 1 min → derived issuance ~59 min ago,
// which is well past the 25% (15 min) threshold → renewal must happen.
jwtObj := newJWT(JWTOptions{SigningKey: "renew-secret", LifetimeMinutes: 60})
token := buildTokenWithCustomLifetime(t, jwtObj, time.Minute, map[string]interface{}{"userID": 77})
userAgent := "renew-agent"
hashedCacheKey := CreateAuthTokenHashedCacheKey(77, userAgent)
if err := cch.Set(hashedCacheKey, token); err != nil {
t.Fatalf("failed seeding token: %v", err)
}
t.Cleanup(func() { _ = cch.Delete(hashedCacheKey) })
r := httptest.NewRequest(GET, LOCALHOST, nil)
r.Header.Set("User-Agent", userAgent)
c := newSessionContext(r, cch)
c.GetJWT = func() *JWT { return jwtObj }
s := &SessionUser{}
s.maybeRenew(c, "user@example.com", 77, hashedCacheKey, token)
renewed := renewedTokenForRequest(c)
if renewed == "" {
t.Fatalf("expected a renewed token to be issued")
}
if renewed == token {
t.Errorf("expected a brand new token different from the previous one")
}
// The cache must now hold the renewed token.
cached, err := cch.Get(hashedCacheKey)
if err != nil {
t.Fatalf("failed reading renewed token from cache: %v", err)
}
if cached != renewed {
t.Errorf("expected cache to store the renewed token")
}
// A renewed cookie must be written to the response.
if len(c.Response.HttpResponseWriter.(*httptest.ResponseRecorder).Result().Cookies()) == 0 {
t.Errorf("expected a renewed cookie to be set")
}
}
func TestSessionUserMaybeRenewSkipsWhenTooEarly(t *testing.T) {
enableTemplateCookieEnv(t)
// Lifetime 1000 minutes, token just issued → elapsed ~0 < threshold → no renewal.
jwtObj := newJWT(JWTOptions{SigningKey: "k", LifetimeMinutes: 1000})
token, _ := jwtObj.GenerateToken(map[string]interface{}{"userID": 1})
r := httptest.NewRequest(GET, LOCALHOST, nil)
c := &Context{
Request: &Request{httpRequest: r},
Response: &Response{HttpResponseWriter: httptest.NewRecorder()},
GetLogger: loggerResolverForTest,
GetJWT: func() *JWT { return jwtObj },
GetCache: func() *Cache { return &Cache{} },
}
s := &SessionUser{}
s.maybeRenew(c, "user@example.com", 1, "key", token)
if renewedTokenForRequest(c) != "" {
t.Errorf("expected no renewal before the threshold elapses")
}
}