// Copyright (c) 2026 Zeni Kim // 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") } }