469 lines
15 KiB
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")
|
|
}
|
|
}
|