Upgrade and add new testing functions
This commit is contained in:
parent
d12679df65
commit
9330ed8e74
13 changed files with 1867 additions and 130 deletions
469
session_test.go
Normal file
469
session_test.go
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
// 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue