Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
2017f04541 updated README.md 2026-09-13 01:18:05 -05:00
9330ed8e74 Upgrade and add new testing functions 2026-09-13 00:09:50 -05:00
d12679df65 function for clear cookie in the user 2026-09-12 22:16:36 -05:00
0e20a17ce9 - Refactory cookie and API session handle. Insolated y routes and controllers (api-auth.go, app-auth.go).
- Cookie lifetime with sliding expiration
- Fix bug single global expiresAt in JWT, now each token has unique ExpiresAt
2026-09-12 21:26:31 -05:00
19 changed files with 2195 additions and 217 deletions

View file

@ -1,3 +1,53 @@
# Goffee Core
The core packages of Goffee framework
The core library of the Goffee framework. It provides the building blocks used by every
Goffee application: routing, the request `Context` and `Response`, hooks, events, JWT,
sessions/cookies, cache, mailer, validation, templating, queues and the DB-backed scheduler.
If you are going to develop an application, the way to do so is through the Cup project. Check out the [Goffee Cup repository](https://git.smarteching.com/goffee/cup).
## Main concepts
### App
`core.App` is the application container. `core.New()` creates it, `Bootstrap()` initializes
the logger, router and events manager, and `Run()` starts the HTTP server. Configuration is
applied through the `Set...Config` methods (`SetRequestConfig`, `SetGormConfig`,
`SetCacheConfig`, `SetEnvFileConfig`, ...).
### Router and controllers
Routes are registered on the singleton returned by `core.ResolveRouter()`. Each route maps
a method + path to a `Controller` (a `func(*core.Context) *core.Response`) and an optional
list of `Hook`s.
```go
router := core.ResolveRouter()
router.Get("/users/:id", showUser, hooks.AuthCheck)
```
### Context and Response
A `*core.Context` carries the request and exposes helpers: `GetRequestParam`, `GetPathParam`,
`GetHeader`, `GetRequesBodyStruct`, `GetUploadedFile`, `MoveFile`, `CopyFile`,
`MapToJson`, `CastToString`/`CastToInt`/`CastToFloat`, `GetBaseDirPath`, and service
accessors like `GetLogger`, `GetGorm`, `GetCache`, `GetJWT`, `GetMailer`, `GetSession`,
`GetQueueClient` and `GetEventsManager`. `c.Response` builds the reply with
`Json`, `Text`, `HTML`, `Template`, `BufferFile`, `Redirect`, etc.
## Packages
| Package | Description |
| --- | --- |
| `core` | The framework itself: app, router, context, response, hooks, events, jwt, session, cache, mailer, validator, templates, queues and scheduler wiring. |
| `core/env` | Helpers to read environment variables with defaults. |
| `core/logger` | Logging drivers (`LogFileDriver`, `LogNullDriver`, ...). |
| `core/scheduler` | The DB-backed task scheduler: `Store`, `QueueItem`, `ProcessedItem`, `SchedulerMeta`. |
| `core/template/components` | Reusable template components (e.g. `PageCard`). |
## Background processing
- **Queues** (asynq): see `core.Queuemux` / `QueueConfig`.
- **Scheduler**: a lightweight DB-backed runner with priorities, concurrent threads and a
global semaphore kill-switch. Use `core.Schedulermux` to register task handlers, then
`scheduler.NewStore(core.ResolveGorm())` and `SetStore` before `RunScheduler`.
## License
MIT-style license. See the [LICENSE](./LICENSE) file for details.

118
cache_test.go Normal file
View file

@ -0,0 +1,118 @@
// 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"
"os"
"testing"
"time"
)
func TestNewCacheNilDoesNotPanic(t *testing.T) {
// With caching disabled, a failed Redis ping must NOT panic.
prevHost := os.Getenv("REDIS_HOST")
prevPort := os.Getenv("REDIS_PORT")
prevDB := os.Getenv("REDIS_DB")
os.Setenv("REDIS_HOST", "127.0.0.1")
os.Setenv("REDIS_PORT", "6379")
os.Setenv("REDIS_DB", "0")
t.Cleanup(func() {
os.Setenv("REDIS_HOST", prevHost)
os.Setenv("REDIS_PORT", prevPort)
os.Setenv("REDIS_DB", prevDB)
})
defer func() {
if r := recover(); r != nil {
t.Errorf("NewCache should not panic when cache is disabled: %v", r)
}
}()
c := NewCache(CacheConfig{EnableCache: false})
if c == nil {
t.Errorf("expected a non-nil Cache instance")
}
}
func TestNewCacheInvalidDBPanics(t *testing.T) {
prevDB := os.Getenv("REDIS_DB")
os.Setenv("REDIS_DB", "not-a-number")
t.Cleanup(func() { os.Setenv("REDIS_DB", prevDB) })
defer func() {
if r := recover(); r == nil {
t.Errorf("expected NewCache to panic on an invalid REDIS_DB value")
}
}()
_ = NewCache(CacheConfig{EnableCache: false})
}
func TestCacheSetGetDelete(t *testing.T) {
c := newTestCache(t)
key := fmt.Sprintf("goffee_cache_test_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = c.Delete(key) })
if err := c.Set(key, "hello"); err != nil {
t.Fatalf("failed cache set: %v", err)
}
got, err := c.Get(key)
if err != nil {
t.Fatalf("failed cache get: %v", err)
}
if got != "hello" {
t.Errorf("expected 'hello', got %q", got)
}
if err := c.Delete(key); err != nil {
t.Fatalf("failed cache delete: %v", err)
}
if _, err := c.Get(key); err == nil {
t.Errorf("expected error getting a deleted key")
}
}
func TestCacheGetMissingKey(t *testing.T) {
c := newTestCache(t)
if _, err := c.Get(fmt.Sprintf("goffee_missing_%d", time.Now().UnixNano())); err == nil {
t.Errorf("expected error getting a missing key")
}
}
func TestCacheSetWithExpiration(t *testing.T) {
c := newTestCache(t)
key := fmt.Sprintf("goffee_cache_exp_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = c.Delete(key) })
if err := c.SetWithExpiration(key, "expiring", 2*time.Second); err != nil {
t.Fatalf("failed cache set with expiration: %v", err)
}
got, err := c.Get(key)
if err != nil {
t.Fatalf("failed cache get: %v", err)
}
if got != "expiring" {
t.Errorf("expected 'expiring', got %q", got)
}
}
func TestCacheOverwrite(t *testing.T) {
c := newTestCache(t)
key := fmt.Sprintf("goffee_cache_overwrite_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = c.Delete(key) })
if err := c.Set(key, "first"); err != nil {
t.Fatalf("failed first set: %v", err)
}
if err := c.Set(key, "second"); err != nil {
t.Fatalf("failed second set: %v", err)
}
got, err := c.Get(key)
if err != nil {
t.Fatalf("failed get: %v", err)
}
if got != "second" {
t.Errorf("expected 'second', got %q", got)
}
}

View file

@ -157,7 +157,7 @@ func TestGetPathParams(t *testing.T) {
}
a := New()
h := a.makeHTTPRouterHandlerFunc(
Handler(func(c *Context) *Response {
Controller(func(c *Context) *Response {
rsp := fmt.Sprintf("param1: %v | param2: %v", c.GetPathParam("param1"), c.GetPathParam("param2"))
return c.Response.Text(rsp)
}), nil)
@ -178,11 +178,13 @@ func TestGetRequestParams(t *testing.T) {
app.SetBasePath(pwd)
hr := httprouter.New()
gcr := NewRouter()
gcr.Post("/pt", Handler(func(c *Context) *Response {
gcr.Post("/pt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Get("/gt", Handler(func(c *Context) *Response {
gcr.Get("/gt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -218,11 +220,13 @@ func TestRequestParamsExists(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Post("/pt", Handler(func(c *Context) *Response {
gcr.Post("/pt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.RequestParamExists("param"))
return nil
}))
gcr.Get("/gt", Handler(func(c *Context) *Response {
gcr.Get("/gt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.RequestParamExists("param"))
return nil
}))
@ -259,11 +263,13 @@ func TestGetHeader(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Post("/pt", Handler(func(c *Context) *Response {
gcr.Post("/pt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetHeader("headerkey"))
return nil
}))
gcr.Get("/gt", Handler(func(c *Context) *Response {
gcr.Get("/gt", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetHeader("headerkey"))
return nil
}))
@ -309,8 +315,13 @@ func TestGetUploadedFile(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Post("/pt", Handler(func(c *Context) *Response {
uploadedFile := c.GetUploadedFile("myfile")
gcr.Post("/pt", Controller(func(c *Context) *Response {
uploadedFile, err := c.GetUploadedFile("myfile")
if err != nil {
fmt.Fprintln(c.Response.HttpResponseWriter, "error: "+err.Error())
return nil
}
rs := fmt.Sprintf("file name: %v | size: %v", uploadedFile.Name, uploadedFile.Size)
fmt.Fprintln(c.Response.HttpResponseWriter, rs)
return nil
@ -534,6 +545,89 @@ func TestGetBaseDirPath(t *testing.T) {
}
}
func TestGetUserAgent(t *testing.T) {
r := httptest.NewRequest(GET, LOCALHOST, nil)
r.Header.Set("User-Agent", "goffee-test-agent")
c := makeCTX(t)
c.Request.httpRequest = r
if got := c.GetUserAgent(); got != "goffee-test-agent" {
t.Errorf("expected 'goffee-test-agent', got %q", got)
}
}
func TestGetRequesBodyMap(t *testing.T) {
r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"alice","age":"30"}`))
c := makeCTX(t)
c.Request.httpRequest = r
m := c.GetRequesBodyMap()
if m["name"] != "alice" {
t.Errorf("expected name 'alice', got %v", m["name"])
}
}
func TestGetRequesBodyStruct(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"bob"}`))
c := makeCTX(t)
c.Request.httpRequest = r
var p payload
if err := c.GetRequesBodyStruct(&p); err != nil {
t.Fatalf("failed binding body struct: %v", err)
}
if p.Name != "bob" {
t.Errorf("expected name 'bob', got %q", p.Name)
}
}
func TestGetRequesBodyStructNonPointer(t *testing.T) {
type payload struct {
Name string `json:"name"`
}
r := httptest.NewRequest(POST, LOCALHOST, strings.NewReader(`{"name":"bob"}`))
c := makeCTX(t)
c.Request.httpRequest = r
var p payload
err := c.GetRequesBodyStruct(p)
if err == nil {
t.Errorf("expected error when dest is not a pointer")
}
}
func TestMapToJson(t *testing.T) {
c := makeCTX(t)
got := c.MapToJson(map[string]interface{}{"a": 1})
if got != `{"a":1}` {
t.Errorf("expected {\"a\":1}, got %q", got)
}
}
func TestMapToJsonPanicsOnNonMap(t *testing.T) {
c := makeCTX(t)
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic for non-map input")
}
}()
c.MapToJson("not a map")
}
func TestGetRequesForm(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, LOCALHOST, strings.NewReader("param=value"))
r.Header.Set(CONTENT_TYPE, "application/x-www-form-urlencoded")
c := makeCTX(t)
c.Request.httpRequest = r
got := c.GetRequesForm("param")
vals, ok := got.([]string)
if !ok {
t.Fatalf("expected []string form values, got %T", got)
}
if len(vals) != 1 || vals[0] != "value" {
t.Errorf("expected form value 'value', got %v", vals)
}
}
func makeCTXLogTestCTX(t *testing.T, w http.ResponseWriter, r *http.Request, tmpFilePath string) *Context {
t.Helper()
return &Context{

View file

@ -86,7 +86,24 @@ func GetCookie(r *http.Request) (UserCookie, error) {
// SetCookie sets an encrypted cookie with a user's email and token, using gob encoding for data serialization.
// The Secure flag is controlled by the COOKIE_SECURE environment variable (defaults to true, set to false for local HTTP development).
// The cookie lifetime is derived from JWT_LIFESPAN_MINUTES.
func SetCookie(w http.ResponseWriter, email string, token string) error {
// Derive cookie MaxAge from JWT_LIFESPAN_MINUTES (default: 1440 min = 1 day)
maxAge := 1440 * 60 // default 1 day in seconds
lifetimeStr := os.Getenv("JWT_LIFESPAN_MINUTES")
if lifetimeStr != "" {
lifetime, parseErr := strconv.Atoi(lifetimeStr)
if parseErr == nil {
maxAge = lifetime * 60 // convert minutes to seconds
}
}
return SetCookieWithMaxAge(w, email, token, maxAge)
}
// SetCookieWithMaxAge sets the encrypted "goffee" cookie with an explicit lifetime in seconds.
// It is used for sliding session renewal, where the cookie expiration must be refreshed
// to a full lifetime from the moment of renewal.
func SetCookieWithMaxAge(w http.ResponseWriter, email string, token string, maxAge int) error {
var err error
// check if template engine is enable
@ -124,16 +141,6 @@ func SetCookie(w http.ResponseWriter, email string, token string) error {
return err
}
// Derive cookie MaxAge from JWT_LIFESPAN_MINUTES (default: 1440 min = 1 day)
maxAge := 1440 * 60 // default 1 day in seconds
lifetimeStr := os.Getenv("JWT_LIFESPAN_MINUTES")
if lifetimeStr != "" {
lifetime, parseErr := strconv.Atoi(lifetimeStr)
if parseErr == nil {
maxAge = lifetime * 60 // convert minutes to seconds
}
}
// Determine if the cookie should have the Secure flag.
// Set COOKIE_SECURE=false (or "0", "f") in your .env for local development over HTTP.
// Defaults to true for production safety.
@ -165,6 +172,42 @@ func SetCookie(w http.ResponseWriter, email string, token string) error {
return nil
}
// ClearCookie instructs the browser to delete the "goffee" session cookie.
// It writes an empty cookie with a MaxAge of -1 (immediate deletion) and an
// expiration in the past, using the same attributes as when the cookie was set
// (Path, HttpOnly, SameSite and Secure) so the browser reliably removes it.
//
// This complements a server-side signout: deleting the cached token prevents any
// further authenticated use of the session, while clearing the cookie removes the
// now-useless cookie from the client.
func ClearCookie(w http.ResponseWriter) error {
// Determine if the cookie should have the Secure flag, mirroring SetCookieWithMaxAge.
// Set COOKIE_SECURE=false (or "0", "f") in your .env for local development over HTTP.
// Defaults to true for production safety.
cookieSecureStr := os.Getenv("COOKIE_SECURE")
if cookieSecureStr == "" {
cookieSecureStr = "true"
}
cookieSecure, _ := strconv.ParseBool(cookieSecureStr)
cookie := http.Cookie{
Name: "goffee",
Value: "",
Path: "/",
MaxAge: -1,
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: cookieSecure,
}
// The value is empty, so there is nothing to encrypt. Write the deletion
// cookie directly so the browser removes the existing session cookie.
http.SetCookie(w, &cookie)
return nil
}
// CookieWrite writes a secure HTTP cookie to the response writer after base64 encoding its value.
// Returns ErrValueTooLong if the cookie string exceeds the 4096-byte size limit.
func CookieWrite(w http.ResponseWriter, cookie http.Cookie) error {

294
cookies_test.go Normal file
View file

@ -0,0 +1,294 @@
// 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 (
"encoding/hex"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// testCookieSecret is a 32 byte (AES-256) key expressed as a hex string, suitable
// for the COOKIE_SECRET env var used by SetCookie/ClearCookie.
const testCookieSecret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
// enableTemplateCookieEnv enables the template engine (required for cookie handling)
// and sets a deterministic cookie secret, restoring both env vars on cleanup.
func enableTemplateCookieEnv(t *testing.T) {
t.Helper()
prevTemplate := os.Getenv("TEMPLATE_ENABLE")
prevSecret := os.Getenv("COOKIE_SECRET")
prevSecure := os.Getenv("COOKIE_SECURE")
os.Setenv("TEMPLATE_ENABLE", "true")
os.Setenv("COOKIE_SECRET", testCookieSecret)
os.Setenv("COOKIE_SECURE", "false")
t.Cleanup(func() {
os.Setenv("TEMPLATE_ENABLE", prevTemplate)
os.Setenv("COOKIE_SECRET", prevSecret)
os.Setenv("COOKIE_SECURE", prevSecure)
})
}
func decodeSecret(t *testing.T) []byte {
t.Helper()
key, err := hex.DecodeString(testCookieSecret)
if err != nil {
t.Fatalf("failed decoding test cookie secret: %v", err)
}
return key
}
func TestCookieWrite(t *testing.T) {
w := httptest.NewRecorder()
cookie := http.Cookie{Name: "goffee", Value: "hello", Path: "/"}
if err := CookieWrite(w, cookie); err != nil {
t.Fatalf("failed testing cookie write: %v", err)
}
rsp := w.Result()
cookies := rsp.Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
if cookies[0].Name != "goffee" {
t.Errorf("expected cookie name 'goffee', got %q", cookies[0].Name)
}
}
func TestCookieWriteTooLong(t *testing.T) {
w := httptest.NewRecorder()
// A value large enough that the base64-encoded cookie string exceeds 4096 bytes.
cookie := http.Cookie{Name: "goffee", Value: strings.Repeat("a", 5000), Path: "/"}
err := CookieWrite(w, cookie)
if err == nil {
t.Errorf("expected ErrValueTooLong, got nil")
}
}
func TestCookieRead(t *testing.T) {
// Build a request carrying a base64-encoded cookie value.
w := httptest.NewRecorder()
cookie := http.Cookie{Name: "goffee", Value: "hello-world", Path: "/"}
if err := CookieWrite(w, cookie); err != nil {
t.Fatalf("failed writing cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
for _, c := range w.Result().Cookies() {
r.AddCookie(c)
}
val, err := CookieRead(r, "goffee")
if err != nil {
t.Fatalf("failed testing cookie read: %v", err)
}
if val != "hello-world" {
t.Errorf("expected 'hello-world', got %q", val)
}
}
func TestCookieReadMissing(t *testing.T) {
r := httptest.NewRequest(GET, LOCALHOST, nil)
_, err := CookieRead(r, "goffee")
if err == nil {
t.Errorf("expected error reading missing cookie")
}
}
func TestCookieReadInvalidBase64(t *testing.T) {
r := httptest.NewRequest(GET, LOCALHOST, nil)
r.AddCookie(&http.Cookie{Name: "goffee", Value: "!!!not-base64!!!"})
_, err := CookieRead(r, "goffee")
if err == nil {
t.Errorf("expected error reading invalid base64 cookie")
}
}
func TestCookieWriteReadEncrypted(t *testing.T) {
key := decodeSecret(t)
w := httptest.NewRecorder()
cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"}
if err := CookieWriteEncrypted(w, cookie, key); err != nil {
t.Fatalf("failed writing encrypted cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
for _, c := range w.Result().Cookies() {
r.AddCookie(c)
}
val, err := CookieReadEncrypted(r, "goffee", key)
if err != nil {
t.Fatalf("failed reading encrypted cookie: %v", err)
}
if val != "secret-value" {
t.Errorf("expected 'secret-value', got %q", val)
}
}
func TestCookieReadEncryptedWrongKey(t *testing.T) {
key := decodeSecret(t)
w := httptest.NewRecorder()
cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"}
if err := CookieWriteEncrypted(w, cookie, key); err != nil {
t.Fatalf("failed writing encrypted cookie: %v", err)
}
// A different but valid AES key must fail decryption.
wrongKey, _ := hex.DecodeString("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
r := httptest.NewRequest(GET, LOCALHOST, nil)
for _, c := range w.Result().Cookies() {
r.AddCookie(c)
}
if _, err := CookieReadEncrypted(r, "goffee", wrongKey); err == nil {
t.Errorf("expected error decrypting with wrong key")
}
}
func TestCookieReadEncryptedWrongName(t *testing.T) {
key := decodeSecret(t)
w := httptest.NewRecorder()
cookie := http.Cookie{Name: "goffee", Value: "secret-value", Path: "/"}
if err := CookieWriteEncrypted(w, cookie, key); err != nil {
t.Fatalf("failed writing encrypted cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
for _, c := range w.Result().Cookies() {
r.AddCookie(c)
}
// Reading with a different name must fail the authenticated name check.
if _, err := CookieReadEncrypted(r, "other", key); err == nil {
t.Errorf("expected error decrypting with wrong cookie name")
}
}
func TestSetCookie(t *testing.T) {
enableTemplateCookieEnv(t)
w := httptest.NewRecorder()
if err := SetCookie(w, "user@example.com", "token-123"); err != nil {
t.Fatalf("failed testing set cookie: %v", err)
}
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
if cookies[0].Name != "goffee" {
t.Errorf("expected cookie name 'goffee', got %q", cookies[0].Name)
}
if !cookies[0].HttpOnly {
t.Errorf("expected cookie to be HttpOnly")
}
if cookies[0].MaxAge <= 0 {
t.Errorf("expected a positive MaxAge, got %d", cookies[0].MaxAge)
}
}
func TestSetCookieUsesJWT_Lifetime(t *testing.T) {
enableTemplateCookieEnv(t)
prev := os.Getenv("JWT_LIFESPAN_MINUTES")
os.Setenv("JWT_LIFESPAN_MINUTES", "5")
t.Cleanup(func() { os.Setenv("JWT_LIFESPAN_MINUTES", prev) })
w := httptest.NewRecorder()
if err := SetCookie(w, "user@example.com", "token-123"); err != nil {
t.Fatalf("failed testing set cookie: %v", err)
}
cookies := w.Result().Cookies()
if cookies[0].MaxAge != 5*60 {
t.Errorf("expected MaxAge of %d, got %d", 5*60, cookies[0].MaxAge)
}
}
func TestSetCookieWithMaxAge(t *testing.T) {
enableTemplateCookieEnv(t)
w := httptest.NewRecorder()
if err := SetCookieWithMaxAge(w, "user@example.com", "token-abc", 120); err != nil {
t.Fatalf("failed testing set cookie with max age: %v", err)
}
cookies := w.Result().Cookies()
if cookies[0].MaxAge != 120 {
t.Errorf("expected MaxAge of 120, got %d", cookies[0].MaxAge)
}
}
func TestSetCookieTemplatesDisabledPanics(t *testing.T) {
prev := os.Getenv("TEMPLATE_ENABLE")
os.Setenv("TEMPLATE_ENABLE", "false")
t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) })
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic when templates are disabled")
}
}()
w := httptest.NewRecorder()
_ = SetCookieWithMaxAge(w, "user@example.com", "token", 60)
}
func TestClearCookie(t *testing.T) {
prevSecure := os.Getenv("COOKIE_SECURE")
os.Setenv("COOKIE_SECURE", "false")
t.Cleanup(func() { os.Setenv("COOKIE_SECURE", prevSecure) })
w := httptest.NewRecorder()
if err := ClearCookie(w); err != nil {
t.Fatalf("failed testing clear cookie: %v", err)
}
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "goffee" {
t.Errorf("expected cookie name 'goffee', got %q", c.Name)
}
if c.Value != "" {
t.Errorf("expected empty cookie value, got %q", c.Value)
}
if c.MaxAge != -1 {
t.Errorf("expected MaxAge of -1 to delete the cookie, got %d", c.MaxAge)
}
if !c.HttpOnly {
t.Errorf("expected cleared cookie to be HttpOnly")
}
}
func TestGetCookieRoundTrip(t *testing.T) {
enableTemplateCookieEnv(t)
// Set a cookie and feed it back into a request, then decrypt it.
w := httptest.NewRecorder()
if err := SetCookie(w, "user@example.com", "token-xyz"); err != nil {
t.Fatalf("failed setting cookie: %v", err)
}
r := httptest.NewRequest(GET, LOCALHOST, nil)
for _, c := range w.Result().Cookies() {
r.AddCookie(c)
}
user, err := GetCookie(r)
if err != nil {
t.Fatalf("failed testing get cookie: %v", err)
}
if user.Email != "user@example.com" {
t.Errorf("expected email 'user@example.com', got %q", user.Email)
}
if user.Token != "token-xyz" {
t.Errorf("expected token 'token-xyz', got %q", user.Token)
}
}
func TestGetCookieTemplatesDisabledPanics(t *testing.T) {
prev := os.Getenv("TEMPLATE_ENABLE")
os.Setenv("TEMPLATE_ENABLE", "false")
t.Cleanup(func() { os.Setenv("TEMPLATE_ENABLE", prev) })
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic when templates are disabled")
}
}()
r := httptest.NewRequest(GET, LOCALHOST, nil)
_, _ = GetCookie(r)
}

View file

@ -50,7 +50,8 @@ func TestMakeHTTPHandlerFunc(t *testing.T) {
app.SetLogsDriver(&logger.LogFileDriver{
FilePath: filepath.Join(t.TempDir(), uuid.NewString()),
})
hdlr := Handler(func(c *Context) *Response {
app.Bootstrap()
hdlr := Controller(func(c *Context) *Response {
f, _ := os.Create(tmpFile)
f.WriteString("DFT2V56H")
c.Response.SetHeader("header-key", "header-val")
@ -76,7 +77,8 @@ func TestMakeHTTPHandlerFuncVerifyJson(t *testing.T) {
app.SetLogsDriver(&logger.LogFileDriver{
FilePath: filepath.Join(t.TempDir(), uuid.NewString()),
})
hdlr := Handler(func(c *Context) *Response {
app.Bootstrap()
hdlr := Controller(func(c *Context) *Response {
f, _ := os.Create(tmpFile)
f.WriteString("DFT2V56H")
c.Response.SetHeader("header-key", "header-val")
@ -130,18 +132,19 @@ func TestNotFoundHandler(t *testing.T) {
}
func TestUseMiddleware(t *testing.T) {
func TestUseHook(t *testing.T) {
app := createNewApp(t)
UseMiddleware(Middleware(func(c *Context) { c.GetLogger().Info("Testing!") }))
if len(app.middlewares.GetMiddlewares()) != 1 {
t.Errorf("failed testing use middleware")
app.Bootstrap()
UseHook(Hook(func(c *Context) { c.GetLogger().Info("Testing!") }))
if len(ResolveHooks().GetHooks()) != 1 {
t.Errorf("failed testing use hook")
}
}
func TestChainReset(t *testing.T) {
c := &chain{}
c.nodes = append(c.nodes, Middleware(func(c *Context) { c.GetLogger().Info("Testing1!") }))
c.nodes = append(c.nodes, Middleware(func(c *Context) { c.GetLogger().Info("Testing2!") }))
c.nodes = append(c.nodes, Hook(func(c *Context) { c.GetLogger().Info("Testing1!") }))
c.nodes = append(c.nodes, Hook(func(c *Context) { c.GetLogger().Info("Testing2!") }))
c.reset()
if len(c.nodes) != 0 {
@ -151,11 +154,11 @@ func TestChainReset(t *testing.T) {
func TestNext(t *testing.T) {
app := createNewApp(t)
app.t = 0
app.Bootstrap()
tfPath := filepath.Join(t.TempDir(), uuid.NewString())
var hs []interface{}
hs = append(hs, Middleware(func(c *Context) { c.Next() }))
hs = append(hs, Handler(func(c *Context) *Response {
hs = append(hs, Hook(func(c *Context) { c.Next() }))
hs = append(hs, Controller(func(c *Context) *Response {
f, _ := os.Create(tfPath)
f.WriteString("DFT2V56H")
return nil
@ -165,7 +168,7 @@ func TestNext(t *testing.T) {
app.chain.execute(makeCTX(t))
cnt, _ := os.ReadFile(tfPath)
if string(cnt) != "DFT2V56H" {
// t.Errorf("failed testing next")
t.Errorf("failed testing next")
}
}
@ -173,14 +176,14 @@ func TestChainGetByIndex(t *testing.T) {
c := &chain{}
tf := filepath.Join(t.TempDir(), uuid.NewString())
var hs []interface{}
hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing!") }))
hs = append(hs, Middleware(func(c *Context) {
hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing!") }))
hs = append(hs, Hook(func(c *Context) {
f, _ := os.Create(tf)
f.WriteString("DFT2V56H")
}))
c.nodes = hs
pf := c.getByIndex(1)
f, ok := pf.(Middleware)
f, ok := pf.(Hook)
if ok {
f(makeCTX(t))
}
@ -192,10 +195,11 @@ func TestChainGetByIndex(t *testing.T) {
func TestPrepareChain(t *testing.T) {
app := createNewApp(t)
UseMiddleware(Middleware(func(c *Context) { c.GetLogger().Info("Testing!") }))
app.Bootstrap()
UseHook(Hook(func(c *Context) { c.GetLogger().Info("Testing!") }))
var hs []interface{}
hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing1!") }))
hs = append(hs, Middleware(func(c *Context) { c.GetLogger().Info("testing2!") }))
hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing1!") }))
hs = append(hs, Hook(func(c *Context) { c.GetLogger().Info("testing2!") }))
app.prepareChain(hs)
if len(app.chain.nodes) != 3 {
t.Errorf("failed preparing chain")
@ -207,7 +211,7 @@ func TestChainExecute(t *testing.T) {
f1Path := filepath.Join(tmpDir, uuid.NewString())
c := &chain{}
c.nodes = []interface{}{
Handler(func(c *Context) *Response {
Controller(func(c *Context) *Response {
tf, _ := os.Create(f1Path)
defer tf.Close()
tf.WriteString("DFT2V56H")
@ -239,19 +243,23 @@ func makeCTX(t *testing.T) *Context {
}
}
func TestcombHndlers(t *testing.T) {
func TestCombHandlers(t *testing.T) {
app := createNewApp(t)
t1 := Handler(func(c *Context) *Response { c.GetLogger().Info("Testing1!"); return nil })
t2 := Middleware(func(c *Context) { c.GetLogger().Info("Testing2!") })
t1 := Controller(func(c *Context) *Response { c.GetLogger().Info("Testing1!"); return nil })
t2 := Hook(func(c *Context) { c.GetLogger().Info("Testing2!") })
mw := []Middleware{t2}
mw := []Hook{t2}
comb := app.combHandlers(t1, mw)
if reflect.ValueOf(t1).Pointer() != reflect.ValueOf(comb[0]).Pointer() {
t.Errorf("failed testing reverse handlers")
// combHandlers builds the slice as [hooks..., controller]
if len(comb) != 2 {
t.Errorf("failed testing comb handlers: unexpected length %d", len(comb))
}
if reflect.ValueOf(t2).Pointer() != reflect.ValueOf(comb[0]).Pointer() {
t.Errorf("failed testing comb handlers: hook should come first")
}
if reflect.ValueOf(t2).Pointer() != reflect.ValueOf(comb[1]).Pointer() {
t.Errorf("failed testing reverse handlers")
if reflect.ValueOf(t1).Pointer() != reflect.ValueOf(comb[1]).Pointer() {
t.Errorf("failed testing comb handlers: controller should come last")
}
}
@ -259,31 +267,31 @@ func TestRegisterGetRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Get("/", Handler(func(c *Context) *Response {
gcr.Get("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Post("/", Handler(func(c *Context) *Response {
gcr.Post("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Delete("/", Handler(func(c *Context) *Response {
gcr.Delete("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Patch("/", Handler(func(c *Context) *Response {
gcr.Patch("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Put("/", Handler(func(c *Context) *Response {
gcr.Put("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Options("/", Handler(func(c *Context) *Response {
gcr.Options("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
gcr.Head("/", Handler(func(c *Context) *Response {
gcr.Head("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -313,7 +321,7 @@ func TestRegisterPostRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Post("/", Handler(func(c *Context) *Response {
gcr.Post("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -343,7 +351,7 @@ func TestRegisterDeleteRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Delete("/", Handler(func(c *Context) *Response {
gcr.Delete("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -373,7 +381,7 @@ func TestRegisterPatchRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Patch("/", Handler(func(c *Context) *Response {
gcr.Patch("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -403,7 +411,7 @@ func TestRegisterPutRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Put("/", Handler(func(c *Context) *Response {
gcr.Put("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -433,7 +441,7 @@ func TestRegisterOptionsRoute(t *testing.T) {
app := New()
hr := httprouter.New()
gcr := NewRouter()
gcr.Options("/", Handler(func(c *Context) *Response {
gcr.Options("/", Controller(func(c *Context) *Response {
fmt.Fprintln(c.Response.HttpResponseWriter, c.GetRequestParam("param"))
return nil
}))
@ -464,7 +472,7 @@ func TestRegisterHeadRoute(t *testing.T) {
hr := httprouter.New()
gcr := NewRouter()
tfp := filepath.Join(t.TempDir(), uuid.NewString())
gcr.Head("/", Handler(func(c *Context) *Response {
gcr.Head("/", Controller(func(c *Context) *Response {
param := c.GetRequestParam("param")
p, _ := param.(string)
f, err := os.OpenFile(p, os.O_CREATE|os.O_RDWR, 777)

View file

@ -2,7 +2,12 @@ package core
import (
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
)
func TestNewEventsManager(t *testing.T) {
@ -12,92 +17,171 @@ func TestNewEventsManager(t *testing.T) {
}
}
// func TestResolveEventsManager(t *testing.T) {
// NewEventsManager()
// m := ResolveEventsManager()
// if fmt.Sprintf("%T", m) != "*core.EventsManager" {
// t.Errorf("failed testing new events manager")
// }
// }
func TestResolveEventsManager(t *testing.T) {
NewEventsManager()
m := ResolveEventsManager()
if fmt.Sprintf("%T", m) != "*core.EventsManager" {
t.Errorf("failed testing resolve events manager")
}
}
// func TestEvents(t *testing.T) {
// pwd, _ := os.Getwd()
// const eventName1 string = "test-event-name1"
// const eventName2 string = "test-event-name2"
// var tmpDir string
// if runtime.GOOS == "linux" {
// tmpDir = t.TempDir()
// } else {
// tmpDir = filepath.Join(pwd, "/testingdata/tmp")
// }
// tmpFile1 := filepath.Join(tmpDir, uuid.NewString())
// tmpFile2 := filepath.Join(tmpDir, uuid.NewString())
// tmpFile3 := filepath.Join(tmpDir, uuid.NewString())
// m := NewEventsManager()
// m.Register(eventName1, func(event *Event, requestContext *Context) {
// os.Create(tmpFile1)
// f, err := os.Create(tmpFile1)
// if err != nil {
// t.Errorf("error testing register event: %v", err.Error())
// }
// f.WriteString(event.Name)
// f.Close()
// })
// m.Register(eventName1, func(event *Event, requestContext *Context) {
// os.Create(tmpFile3)
// f, err := os.Create(tmpFile3)
// if err != nil {
// t.Errorf("error testing register event: %v", err.Error())
// }
// f.WriteString(event.Name)
// f.Close()
// })
// m.Fire(&Event{Name: eventName1})
// m.processFiredEvents()
func TestEventsFireAndProcess(t *testing.T) {
const eventName1 string = "test-event-name1"
const eventName2 string = "test-event-name2"
// ff, err := os.Open(tmpFile1)
// if err != nil {
// t.Errorf("error testing register event : %v", err.Error())
// }
tmpDir := t.TempDir()
tmpFile1 := filepath.Join(tmpDir, uuid.NewString())
tmpFile2 := filepath.Join(tmpDir, uuid.NewString())
tmpFile3 := filepath.Join(tmpDir, uuid.NewString())
// d, err := io.ReadAll(ff)
// if string(d) != eventName1 {
// t.Error("faild testing events")
// }
// ff.Close()
// os.Remove(tmpFile1)
m := NewEventsManager()
// ff, err = os.Open(tmpFile3)
// if err != nil {
// t.Errorf("error testing register event : %v", err.Error())
// }
// Two jobs registered on the same event must BOTH run.
m.Register(eventName1, func(event *Event, requestContext *Context) {
f, err := os.Create(tmpFile1)
if err != nil {
t.Errorf("error testing register event: %v", err.Error())
return
}
f.WriteString(event.Name)
f.Close()
})
m.Register(eventName1, func(event *Event, requestContext *Context) {
f, err := os.Create(tmpFile3)
if err != nil {
t.Errorf("error testing register event: %v", err.Error())
return
}
f.WriteString(event.Name)
f.Close()
})
// d, err = io.ReadAll(ff)
// if string(d) != eventName1 {
// t.Error("faild testing events")
// }
// ff.Close()
// os.Remove(tmpFile3)
if err := m.Fire(&Event{Name: eventName1}); err != nil {
t.Fatalf("failed firing event: %v", err)
}
m.processFiredEvents()
// m.Register(eventName2, func(event *Event, requestContext *Context) {
// f, err := os.Create(tmpFile2)
// if err != nil {
// t.Errorf("error testing register event: %v", err.Error())
// }
// f.WriteString(event.Name)
// f.Close()
// })
// m.Fire(&Event{Name: eventName2})
// m.processFiredEvents()
for _, fp := range []string{tmpFile1, tmpFile3} {
f, err := os.Open(fp)
if err != nil {
t.Errorf("error opening event file %v: %v", fp, err.Error())
continue
}
d, err := io.ReadAll(f)
if err != nil {
t.Errorf("error reading event file %v: %v", fp, err.Error())
}
if string(d) != eventName1 {
t.Errorf("failed testing events: expected %q, got %q", eventName1, string(d))
}
f.Close()
}
// ff, err = os.Open(tmpFile2)
// if err != nil {
// t.Errorf("error testing register event : %v", err.Error())
// }
// A registered event with a distinct payload is processed independently.
m.Register(eventName2, func(event *Event, requestContext *Context) {
f, err := os.Create(tmpFile2)
if err != nil {
t.Errorf("error testing register event: %v", err.Error())
return
}
f.WriteString(event.Name)
f.Close()
})
if err := m.Fire(&Event{Name: eventName2}); err != nil {
t.Fatalf("failed firing event: %v", err)
}
m.processFiredEvents()
// d, err = io.ReadAll(ff)
// if string(d) != eventName2 {
// t.Error("faild testing events")
// }
// ff.Close()
// }
f, err := os.Open(tmpFile2)
if err != nil {
t.Fatalf("error opening event file: %v", err.Error())
}
d, err := io.ReadAll(f)
if err != nil {
t.Errorf("error reading event file: %v", err.Error())
}
f.Close()
if string(d) != eventName2 {
t.Errorf("failed testing events: expected %q, got %q", eventName2, string(d))
}
}
func TestEventFireUnregistered(t *testing.T) {
m := NewEventsManager()
err := m.Fire(&Event{Name: "not-registered"})
if err == nil {
t.Errorf("expected error firing an unregistered event")
}
}
func TestEventFireEmptyName(t *testing.T) {
m := NewEventsManager()
err := m.Fire(&Event{Name: ""})
if err == nil {
t.Errorf("expected error firing an event with an empty name")
}
}
func TestEventRegisterEmptyNamePanics(t *testing.T) {
m := NewEventsManager()
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic registering an event with an empty name")
}
}()
m.Register("", func(event *Event, requestContext *Context) {})
}
func TestEventsProcessClearsFiredList(t *testing.T) {
const eventName = "test-clear-fired"
m := NewEventsManager()
m.Register(eventName, func(event *Event, requestContext *Context) {})
if err := m.Fire(&Event{Name: eventName}); err != nil {
t.Fatalf("failed firing event: %v", err)
}
if len(m.firedEvents) != 1 {
t.Fatalf("expected 1 fired event, got %d", len(m.firedEvents))
}
m.processFiredEvents()
if len(m.firedEvents) != 0 {
t.Errorf("expected fired events to be cleared after processing")
}
}
func TestEventsDisabled(t *testing.T) {
DisableEvents()
defer EnableEvents()
const eventName = "test-disabled-event"
m := NewEventsManager()
m.Register(eventName, func(event *Event, requestContext *Context) {})
// When disabled, Register is a no-op and Fire returns nil without recording.
if len(m.eventsJobsList) != 0 {
t.Errorf("expected no jobs registered while events are disabled")
}
if err := m.Fire(&Event{Name: eventName}); err != nil {
t.Errorf("expected Fire to be a no-op while events are disabled, got: %v", err)
}
if len(m.firedEvents) != 0 {
t.Errorf("expected no fired events while events are disabled")
}
}
func TestEventsSetContextAndExecute(t *testing.T) {
const eventName = "test-context-event"
m := NewEventsManager()
var receivedCtx *Context
m.Register(eventName, func(event *Event, requestContext *Context) {
receivedCtx = requestContext
})
expected := &Context{}
m.setContext(expected)
if err := m.Fire(&Event{Name: eventName}); err != nil {
t.Fatalf("failed firing event: %v", err)
}
m.processFiredEvents()
if receivedCtx != expected {
t.Errorf("expected the event job to receive the request context")
}
}

32
go.mod
View file

@ -4,7 +4,7 @@ replace git.smarteching.com/goffee/core/logger => ./logger
replace git.smarteching.com/goffee/core/env => ./env
go 1.25.0
go 1.26.0
require (
git.smarteching.com/zeni/go-chart/v2 v2.1.4
@ -16,13 +16,13 @@ require (
github.com/hibiken/asynq v0.26.0
github.com/joho/godotenv v1.5.1
github.com/julienschmidt/httprouter v1.3.0
github.com/redis/go-redis/v9 v9.21.0
golang.org/x/crypto v0.53.0
golang.org/x/text v0.38.0
github.com/redis/go-redis/v9 v9.22.0
golang.org/x/crypto v0.57.0
golang.org/x/text v0.42.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/postgres v1.6.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
gorm.io/gorm v1.31.2
)
require (
@ -30,17 +30,17 @@ require (
github.com/SparkPost/gosparkpost v0.2.0 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-chi/chi/v5 v5.3.0 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/go-chi/chi/v5 v5.3.2 // indirect
github.com/go-sql-driver/mysql v1.10.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/pgx/v5 v5.11.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailgun/errors v0.6.0 // indirect
github.com/mailgun/mailgun-go/v4 v4.23.0 // indirect
github.com/mattn/go-sqlite3 v1.14.47 // indirect
github.com/mattn/go-sqlite3 v1.14.52 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
@ -49,12 +49,12 @@ require (
github.com/sendgrid/sendgrid-go v3.16.1+incompatible // indirect
github.com/spf13/cast v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/image v0.43.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/image v0.46.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/time v0.16.0 // indirect
google.golang.org/protobuf v1.36.12 // indirect
)
require (

82
go.sum
View file

@ -25,14 +25,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE=
github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
github.com/go-sql-driver/mysql v1.10.1/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@ -52,10 +50,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
@ -78,17 +74,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailgun/errors v0.5.0 h1:pLQo8uhAdORsjN69mGixSr0pGs46z/BW/FQXd8HG1VM=
github.com/mailgun/errors v0.5.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs=
github.com/mailgun/errors v0.6.0 h1:IWmzIGwXCSN/Q60JT/lXvam3xRAgTUJSX88KwKJ7hss=
github.com/mailgun/errors v0.6.0/go.mod h1:+2nrgY77E0vDkG4ErehpcpbSkMLkseJzKbrva89WeSs=
github.com/mailgun/mailgun-go/v4 v4.23.0 h1:jPEMJzzin2s7lvehcfv/0UkyBu18GvcURPr2+xtZRbk=
github.com/mailgun/mailgun-go/v4 v4.23.0/go.mod h1:imTtizoFtpfZqPqGP8vltVBB6q9yWcv6llBhfFeElZU=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -100,10 +92,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@ -130,48 +120,36 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/postgres v1.6.2 h1:BvXQ/cNUg63q5TFNg672DmDcowZSFrNLkkA3Xe6GXq4=
gorm.io/driver/postgres v1.6.2/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

109
hashing_test.go Normal file
View file

@ -0,0 +1,109 @@
// 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 (
"testing"
"git.smarteching.com/goffee/core/logger"
)
func TestHashPassword(t *testing.T) {
h := &Hashing{}
password := "super-secret-password"
hashed, err := h.HashPassword(password)
if err != nil {
t.Fatalf("failed testing hash password: %v", err)
}
if hashed == "" {
t.Errorf("expected a non-empty hash")
}
if hashed == password {
t.Errorf("hash must differ from the plaintext password")
}
}
func TestHashPasswordIsSalted(t *testing.T) {
h := &Hashing{}
first, err := h.HashPassword("same-password")
if err != nil {
t.Fatalf("failed testing hash password: %v", err)
}
second, err := h.HashPassword("same-password")
if err != nil {
t.Fatalf("failed testing hash password: %v", err)
}
// bcrypt generates a random salt, so equal inputs must yield different hashes.
if first == second {
t.Errorf("expected different hashes for the same password (salting)")
}
}
func TestCheckPasswordHash(t *testing.T) {
// The CheckPasswordHash error path logs through the global logger; make sure
// it is initialized so the test does not panic on a nil logger.
loggr = logger.NewLogger(&logger.LogNullDriver{})
h := &Hashing{}
password := "correct-horse-battery-staple"
hashed, err := h.HashPassword(password)
if err != nil {
t.Fatalf("failed testing hash password: %v", err)
}
ok, err := h.CheckPasswordHash(hashed, password)
if err != nil {
t.Fatalf("failed testing check password hash: %v", err)
}
if !ok {
t.Errorf("expected password check to succeed")
}
}
func TestCheckPasswordHashMismatch(t *testing.T) {
loggr = logger.NewLogger(&logger.LogNullDriver{})
h := &Hashing{}
hashed, err := h.HashPassword("the-right-password")
if err != nil {
t.Fatalf("failed testing hash password: %v", err)
}
ok, err := h.CheckPasswordHash(hashed, "the-wrong-password")
if err != nil {
t.Fatalf("mismatched password should not return an error, got: %v", err)
}
if ok {
t.Errorf("expected password check to fail for wrong password")
}
}
func TestCheckPasswordHashInvalidHash(t *testing.T) {
loggr = logger.NewLogger(&logger.LogNullDriver{})
h := &Hashing{}
// An invalid hash that is not a mismatched-but-valid bcrypt hash should
// surface as an error rather than a simple false.
ok, err := h.CheckPasswordHash("not-a-valid-bcrypt-hash", "whatever")
if err == nil {
t.Errorf("expected an error for an invalid hash")
}
if ok {
t.Errorf("expected ok to be false for an invalid hash")
}
}
func TestCheckPasswordHashEmptyHash(t *testing.T) {
loggr = logger.NewLogger(&logger.LogNullDriver{})
h := &Hashing{}
ok, err := h.CheckPasswordHash("", "password")
if err == nil {
t.Errorf("expected an error for an empty hash")
}
if ok {
t.Errorf("expected ok to be false for an empty hash")
}
}

57
jwt.go
View file

@ -9,8 +9,8 @@ import (
)
type JWT struct {
signingKey []byte
expiresAt time.Time
signingKey []byte
lifetimeMinutes int
}
type JWTOptions struct {
SigningKey string
@ -20,10 +20,9 @@ type JWTOptions struct {
var j *JWT
func newJWT(opts JWTOptions) *JWT {
d := time.Duration(opts.LifetimeMinutes)
j = &JWT{
signingKey: []byte(opts.SigningKey),
expiresAt: time.Now().Add(d * time.Minute),
signingKey: []byte(opts.SigningKey),
lifetimeMinutes: opts.LifetimeMinutes,
}
return j
}
@ -31,13 +30,19 @@ func resolveJWT() *JWT {
return j
}
// LifetimeMinutes returns the configured token lifetime in minutes.
func (j *JWT) LifetimeMinutes() int {
return j.lifetimeMinutes
}
type claims struct {
J []byte
jwt.RegisteredClaims
}
func (j *JWT) GenerateToken(payload map[string]interface{}) (string, error) {
claims, err := mapClaims(payload, j.expiresAt)
expiresAt := time.Now().Add(time.Duration(j.lifetimeMinutes) * time.Minute)
claims, err := mapClaims(payload, expiresAt)
if err != nil {
return "", err
}
@ -49,6 +54,46 @@ func (j *JWT) GenerateToken(payload map[string]interface{}) (string, error) {
return token, nil
}
// DecodeTokenIgnoreExpiry decodes a token's payload without validating its expiration.
// It is used to inspect tokens that may already be expired (e.g. sliding session renewal),
// while still verifying the signature and the token structure.
func (j *JWT) DecodeTokenIgnoreExpiry(token string) (payload map[string]interface{}, err error) {
t, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
return j.signingKey, nil
}, jwt.WithoutClaimsValidation())
if err != nil {
return nil, err
}
c, ok := t.Claims.(*claims)
if !ok {
return nil, errors.New("error decoding token")
}
err = json.Unmarshal(c.J, &payload)
if err != nil {
return nil, err
}
return payload, nil
}
// ExpiresAtIgnoreExpiry returns the expiration time carried by the token, without
// validating it. It verifies the signature and returns the token's "exp" claim.
func (j *JWT) ExpiresAtIgnoreExpiry(token string) (time.Time, error) {
t, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
return j.signingKey, nil
}, jwt.WithoutClaimsValidation())
if err != nil {
return time.Time{}, err
}
c, ok := t.Claims.(*claims)
if !ok {
return time.Time{}, errors.New("error decoding token")
}
if c.ExpiresAt == nil {
return time.Time{}, errors.New("token has no expiration")
}
return time.Unix(c.ExpiresAt.Unix(), 0), nil
}
func (j *JWT) DecodeToken(token string) (payload map[string]interface{}, err error) {
t, err := jwt.ParseWithClaims(token, &claims{}, func(token *jwt.Token) (interface{}, error) {
return j.signingKey, nil

View file

@ -104,6 +104,79 @@ func TestMapClaims(t *testing.T) {
}
}
func TestLifetimeMinutes(t *testing.T) {
j := newJWT(JWTOptions{
SigningKey: "testsigning",
LifetimeMinutes: 42,
})
if j.LifetimeMinutes() != 42 {
t.Errorf("expected lifetime 42, got %d", j.LifetimeMinutes())
}
}
func TestExpiresAtIgnoreExpiry(t *testing.T) {
j := initiateJWTHelper(t)
token, err := j.GenerateToken(map[string]interface{}{
"userID": 1,
})
if err != nil {
t.Fatalf("failed generating token: %v", err)
}
exp, err := j.ExpiresAtIgnoreExpiry(token)
if err != nil {
t.Fatalf("failed testing expires at ignore expiry: %v", err)
}
expected := time.Now().Add(time.Duration(j.LifetimeMinutes()) * time.Minute)
if diff := exp.Sub(expected); diff > time.Minute || diff < -time.Minute {
t.Errorf("expected expiration close to %v, got %v", expected, exp)
}
// An expired token must still return its expiration (no validation performed).
expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJKIjoiZXlKMFpYTjBTMlY1SWpvaWRHVnpkRlpoYkNKOSIsImV4cCI6MTY4NDkyMzQwOX0.v2aM9OTDJ48L4KnGjfLH3JAFQw4Gkgj5z7cA7txPNag"
_, err = j.ExpiresAtIgnoreExpiry(expiredToken)
if err != nil {
t.Errorf("expected to read expiration of an expired token, got error: %v", err)
}
}
func TestExpiresAtIgnoreExpiryInvalid(t *testing.T) {
j := initiateJWTHelper(t)
_, err := j.ExpiresAtIgnoreExpiry("not-a-token")
if err == nil {
t.Errorf("expected error for an invalid token")
}
}
func TestDecodeTokenIgnoreExpiry(t *testing.T) {
j := initiateJWTHelper(t)
// An already expired token should still be decodable.
expiredToken := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJKIjoiZXlKMFpYTjBTMlY1SWpvaWRHVnpkRlpoYkNKOSIsImV4cCI6MTY4NDkyMzQwOX0.v2aM9OTDJ48L4KnGjfLH3JAFQw4Gkgj5z7cA7txPNag"
_, err := j.DecodeTokenIgnoreExpiry(expiredToken)
if err != nil {
t.Errorf("expected to decode an expired token ignoring expiry, got: %v", err)
}
token, err := j.GenerateToken(map[string]interface{}{
"userID": 99,
})
if err != nil {
t.Fatalf("failed generating token: %v", err)
}
payload, err := j.DecodeTokenIgnoreExpiry(token)
if err != nil {
t.Fatalf("failed decoding token ignoring expiry: %v", err)
}
if fmt.Sprintf("%v", payload["userID"]) != "99" {
t.Errorf("expected userID 99, got %v", payload["userID"])
}
// An invalid token must fail.
if _, err := j.DecodeTokenIgnoreExpiry("invalid"); err == nil {
t.Errorf("expected error decoding an invalid token")
}
}
func initiateJWTHelper(t *testing.T) *JWT {
t.Helper()
j := newJWT(JWTOptions{

View file

@ -1,7 +1,10 @@
package core
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
@ -152,3 +155,169 @@ func TestCastBasicVarToString(t *testing.T) {
t.Errorf("failed test cast basic var to string")
}
}
func TestCastBasicVarToStringPanicsOnUnsupported(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Errorf("expected panic for unsupported type")
}
}()
res := Response{}
res.castBasicVarsToString(struct{ Name string }{Name: "x"})
}
func TestText(t *testing.T) {
res := Response{}
res.Text("plain text")
if res.contentType != CONTENT_TYPE_TEXT {
t.Errorf("expected content type TEXT, got %q", res.contentType)
}
if string(res.body) != "plain text" {
t.Errorf("unexpected body %q", string(res.body))
}
}
func TestHTML(t *testing.T) {
res := Response{}
res.HTML("<h1>Hi</h1>")
if res.contentType != CONTENT_TYPE_HTML {
t.Errorf("expected content type HTML, got %q", res.contentType)
}
if string(res.body) != "<h1>Hi</h1>" {
t.Errorf("unexpected body %q", string(res.body))
}
}
func TestSetStatusCode(t *testing.T) {
res := Response{}
res.SetStatusCode(http.StatusCreated)
if res.statusCode != http.StatusCreated {
t.Errorf("expected status code 201, got %d", res.statusCode)
}
}
func TestSetContentType(t *testing.T) {
res := Response{}
res.SetContentType(CONTENT_TYPE_JSON)
if res.overrideContentType != CONTENT_TYPE_JSON {
t.Errorf("expected override content type JSON, got %q", res.overrideContentType)
}
}
func TestForceSendResponse(t *testing.T) {
res := Response{}
res.ForceSendResponse()
if !res.isTerminated {
t.Errorf("expected response to be terminated")
}
}
func TestTerminatedResponseIgnoresWrites(t *testing.T) {
res := Response{}
res.ForceSendResponse()
res.Text("ignored")
res.SetHeader("x", "y")
res.SetStatusCode(500)
if res.body != nil {
t.Errorf("expected body to be untouched after termination, got %q", string(res.body))
}
if len(res.headers) != 0 {
t.Errorf("expected no headers to be added after termination")
}
if res.statusCode != 0 {
t.Errorf("expected status code to stay 0 after termination, got %d", res.statusCode)
}
}
func TestRedirect(t *testing.T) {
res := Response{}
res.Redirect("https://example.com")
if res.redirectTo != "https://example.com" {
t.Errorf("expected redirect to 'https://example.com', got %q", res.redirectTo)
}
if res.redirectStatusCode != http.StatusTemporaryRedirect {
t.Errorf("expected default 307 redirect, got %d", res.redirectStatusCode)
}
}
func TestRedirectUse303(t *testing.T) {
res := Response{}
res.Redirect("https://example.com", true)
if res.redirectStatusCode != http.StatusSeeOther {
t.Errorf("expected 303 redirect, got %d", res.redirectStatusCode)
}
}
func TestRedirectInvalidUrlGetsLeadingSlash(t *testing.T) {
res := Response{}
// A relative path is treated as an invalid URL by the validator and should
// be normalized to an absolute path.
res.Redirect("dashboard")
if res.redirectTo != "/dashboard" {
t.Errorf("expected '/dashboard', got %q", res.redirectTo)
}
}
func TestResetRestoresDefaults(t *testing.T) {
res := Response{}
res.SetStatusCode(http.StatusTeapot)
res.SetContentType(CONTENT_TYPE_JSON)
res.Redirect("https://example.com")
res.Text("body")
res.reset()
if res.body != nil {
t.Errorf("expected body to be cleared")
}
if res.statusCode != http.StatusOK {
t.Errorf("expected status code to reset to 200, got %d", res.statusCode)
}
if res.contentType != CONTENT_TYPE_HTML {
t.Errorf("expected content type to reset to HTML, got %q", res.contentType)
}
if res.overrideContentType != "" {
t.Errorf("expected override content type to be cleared")
}
if res.redirectTo != "" {
t.Errorf("expected redirect to be cleared")
}
if res.isTerminated {
t.Errorf("expected termination flag to be cleared")
}
}
func TestBufferFile(t *testing.T) {
w := httptest.NewRecorder()
res := Response{HttpResponseWriter: w}
var buf bytes.Buffer
buf.WriteString("file-content")
res.BufferFile("report.csv", "text/csv", buf)
rsp := w.Result()
if ct := rsp.Header.Get(CONTENT_TYPE); ct != "text/csv" {
t.Errorf("expected content type 'text/csv', got %q", ct)
}
if cd := rsp.Header.Get("Content-Disposition"); cd != "attachment; filename=report.csv" {
t.Errorf("unexpected content disposition %q", cd)
}
if w.Body.String() != "file-content" {
t.Errorf("unexpected body %q", w.Body.String())
}
}
func TestBufferInline(t *testing.T) {
w := httptest.NewRecorder()
res := Response{HttpResponseWriter: w}
var buf bytes.Buffer
buf.WriteString("inline-content")
res.BufferInline("image.png", "image/png", buf)
rsp := w.Result()
if ct := rsp.Header.Get(CONTENT_TYPE); ct != "image/png" {
t.Errorf("expected content type 'image/png', got %q", ct)
}
if w.Body.String() != "inline-content" {
t.Errorf("unexpected body %q", w.Body.String())
}
}

View file

@ -66,6 +66,20 @@ func TestDeleteRequest(t *testing.T) {
}
}
func TestPatchRequest(t *testing.T) {
r := NewRouter()
handler := Controller(func(c *Context) *Response {
c.GetLogger().Info(TEST_STR)
return nil
})
r.Patch("/", handler)
route := r.GetRoutes()[0]
if route.Method != "patch" || route.Path != "/" {
t.Errorf("failed adding route with patch http method")
}
}
func TestPutRequest(t *testing.T) {
r := NewRouter()
handler := Controller(func(c *Context) *Response {

View file

@ -5,9 +5,12 @@
package core
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"os"
"strconv"
"sync"
"time"
)
@ -37,7 +40,16 @@ func (s *SessionUser) Init(c *Context) bool {
return false
}
payload, err := c.GetJWT().DecodeToken(usercookie.Token)
// The request cookie is immutable, so if the session was already renewed earlier
// in this same request, the request still carries the previous token. Use the
// token that was renewed during this request (if any) so multiple Init calls in
// the same request remain consistent.
currentToken := usercookie.Token
if renewed := renewedTokenForRequest(c); renewed != "" {
currentToken = renewed
}
payload, err := c.GetJWT().DecodeToken(currentToken)
if err != nil {
return false
}
@ -48,7 +60,7 @@ func (s *SessionUser) Init(c *Context) bool {
// verify token against cached value
hashedCacheKey := CreateAuthTokenHashedCacheKey(userID, userAgent)
cachedToken, err := c.GetCache().Get(hashedCacheKey)
if err != nil || cachedToken != usercookie.Token {
if err != nil || cachedToken != currentToken {
return false
}
@ -65,9 +77,111 @@ func (s *SessionUser) Init(c *Context) bool {
_ = json.Unmarshal([]byte(value), &s.values)
}
// sliding expiration: transparently renew the cookie + JWT if enough time elapsed
s.maybeRenew(c, usercookie.Email, userID, hashedCacheKey, currentToken)
return true
}
// slidingRenewedTokenContextKey is the request-context key under which the token
// renewed during the current request is stored. This keeps the sliding session
// logic consistent if Init is called more than once within the same request.
const slidingRenewedTokenContextKey = "goffeeSlidingRenewedToken"
// renewedTokenForRequest returns the token that was issued by sliding renewal during
// the current request, or an empty string if no renewal happened yet.
func renewedTokenForRequest(c *Context) string {
if c.Request == nil || c.Request.httpRequest == nil {
return ""
}
if token, ok := c.Request.httpRequest.Context().Value(slidingRenewedTokenContextKey).(string); ok {
return token
}
return ""
}
// markRenewedTokenForRequest stores the token issued by sliding renewal in the request
// context so subsequent Init calls within the same request can use it.
func markRenewedTokenForRequest(c *Context, token string) {
if c.Request == nil || c.Request.httpRequest == nil {
return
}
ctx := context.WithValue(c.Request.httpRequest.Context(), slidingRenewedTokenContextKey, token)
*c.Request.httpRequest = *c.Request.httpRequest.WithContext(ctx)
}
// slidingRenewThresholdPercent is the fraction of the token lifetime that must
// elapse before a web session cookie is renewed (sliding expiration).
// Renewing only past this percentage keeps the renewal "moderate" — avoiding a
// cookie/JWT write on every single request.
const slidingRenewThresholdPercent = 0.25
// maybeRenew performs the sliding expiration renewal for cookie/template based sessions.
// It reads the token expiration without validating it (at this point the token has already
// been verified as valid and matched against the cache), derives the issuance moment and,
// if more than slidingRenewThresholdPercent of the lifetime has elapsed, it issues a brand
// new JWT (with a fresh expiration) and rewrites both the cookie and the cached token.
//
// Renewal errors are logged and ignored: a renewal failure must never break the current
// request nor invalidate an otherwise valid session.
func (s *SessionUser) maybeRenew(c *Context, email string, userID uint, hashedCacheKey string, currentToken string) {
// Sliding renewal only applies to cookie/template based sessions.
// Without templates there is no cookie session to slide.
if !templateEngineEnabled() {
return
}
jwtObj := c.GetJWT()
lifetimeMinutes := jwtObj.LifetimeMinutes()
if lifetimeMinutes <= 0 {
return
}
// Read the token expiration without validating it. The token may be close to
// expiring but is still valid (it was decoded successfully above).
expiresAt, err := jwtObj.ExpiresAtIgnoreExpiry(currentToken)
if err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error reading token expiration: %v", err))
return
}
// Tokens are issued with exp = issuedAt + lifetime, so the issuance moment can
// be derived from the expiration carried by the token.
issuedAt := expiresAt.Add(-time.Duration(lifetimeMinutes) * time.Minute)
elapsed := time.Since(issuedAt)
threshold := time.Duration(float64(lifetimeMinutes)*slidingRenewThresholdPercent) * time.Minute
if elapsed < threshold {
// Not enough time has elapsed yet — skip renewal to keep it moderate.
return
}
// Issue a fresh JWT and refresh the cached token.
newToken, err := jwtObj.GenerateToken(map[string]interface{}{
"userID": userID,
})
if err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error generating new token: %v", err))
return
}
if err := c.GetCache().Set(hashedCacheKey, newToken); err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error caching renewed token: %v", err))
return
}
// Refresh the cookie with a full new lifetime.
maxAgeSeconds := lifetimeMinutes * 60
if err := SetCookieWithMaxAge(c.Response.HttpResponseWriter, email, newToken, maxAgeSeconds); err != nil {
c.GetLogger().Error(fmt.Sprintf("sliding session: error writing renewed cookie: %v", err))
return
}
// Remember the renewed token for the rest of this request so that any further
// Init call in the same request validates against the renewed token.
markRenewedTokenForRequest(c, newToken)
}
// Set stores a value in the session and persists it to the cache.
func (s *SessionUser) Set(key string, value interface{}) error {
s.mu.Lock()
@ -144,3 +258,14 @@ func CreateAuthTokenHashedCacheKey(userID uint, userAgent string) string {
cacheKey := fmt.Sprintf("userid:_%v_useragent:_%v_jwt_token", userID, userAgent)
return fmt.Sprintf("%x", md5.Sum([]byte(cacheKey)))
}
// templateEngineEnabled reports whether the template (cookie based) engine is enabled.
// Sliding session renewal only applies to cookie/template based sessions.
func templateEngineEnabled() bool {
templateEnableStr := os.Getenv("TEMPLATE_ENABLE")
if templateEnableStr == "" {
return false
}
enabled, _ := strconv.ParseBool(templateEnableStr)
return enabled
}

469
session_test.go Normal file
View 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")
}
}

303
templates_test.go Normal file
View file

@ -0,0 +1,303 @@
// 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 (
"embed"
"strings"
"testing"
"time"
)
type templateFieldSample struct {
Name string
Age int
}
func TestHasField(t *testing.T) {
s := templateFieldSample{Name: "test", Age: 3}
if !hasField(s, "Name") {
t.Errorf("expected struct to have field 'Name'")
}
if !hasField(&s, "Age") {
t.Errorf("expected pointer to struct to have field 'Age'")
}
if hasField(s, "Missing") {
t.Errorf("expected struct NOT to have field 'Missing'")
}
if hasField(42, "Name") {
t.Errorf("expected non-struct to have no fields")
}
}
func TestCapitalize(t *testing.T) {
tests := []struct {
in string
want string
}{
{"hello world", "Hello world"},
{"HELLO", "Hello"},
{"", ""},
{"a", "A"},
}
for _, tt := range tests {
if got := capitalize(tt.in); got != tt.want {
t.Errorf("capitalize(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestPrepend(t *testing.T) {
if got := prepend("World", "Hello, "); got != "Hello, World" {
t.Errorf("prepend returned %q", got)
}
}
func TestStrAppend(t *testing.T) {
if got := strAppend("Hello", "!"); got != "Hello!" {
t.Errorf("strAppend returned %q", got)
}
}
func TestSplitHelper(t *testing.T) {
got := split("a,b,c", ",")
if len(got) != 3 {
t.Fatalf("expected 3 parts, got %d", len(got))
}
if got[0] != "a" || got[2] != "c" {
t.Errorf("unexpected split result: %v", got)
}
}
func TestTruncate(t *testing.T) {
if got := truncate(5, "hello world"); got != "hello…" {
t.Errorf("truncate returned %q", got)
}
if got := truncate(20, "short"); got != "short" {
t.Errorf("truncate should not modify short strings, got %q", got)
}
// Multibyte safety.
if got := truncate(3, "héllo"); got != "hél…" {
t.Errorf("truncate multibyte returned %q", got)
}
}
func TestFmtNumber(t *testing.T) {
if got := fmtNumber(1000000); got != "1,000,000" {
t.Errorf("fmtNumber(int) = %q, want %q", got, "1,000,000")
}
if got := fmtNumber(int64(1500)); got != "1,500" {
t.Errorf("fmtNumber(int64) = %q, want %q", got, "1,500")
}
if got := fmtNumber(12.5); got != "12.50" {
t.Errorf("fmtNumber(float64) = %q, want %q", got, "12.50")
}
// Fallback for unsupported types.
if got := fmtNumber("abc"); got != "abc" {
t.Errorf("fmtNumber(string) = %q, want %q", got, "abc")
}
}
func TestFmtDate(t *testing.T) {
d := time.Date(2023, time.May, 2, 15, 4, 0, 0, time.UTC)
if got := fmtDate(d, "short"); got != "02 May 2023" {
t.Errorf("fmtDate(short) = %q", got)
}
if got := fmtDate(d, "long"); got != "02 May 2023" {
t.Errorf("fmtDate(long) = %q", got)
}
if got := fmtDate(d, "iso"); got != "2023-05-02" {
t.Errorf("fmtDate(iso) = %q", got)
}
if got := fmtDate(d, "datetime"); got != "02 May 2023 15:04" {
t.Errorf("fmtDate(datetime) = %q", got)
}
if got := fmtDate(d, "02/01/2006"); got != "02/05/2023" {
t.Errorf("fmtDate(custom) = %q", got)
}
}
func TestTimeAgo(t *testing.T) {
tests := []struct {
name string
at time.Time
want string
}{
{"just now", time.Now().Add(-10 * time.Second), "just now"},
{"minutes", time.Now().Add(-5 * time.Minute), "5 minutes ago"},
{"one minute", time.Now().Add(-1 * time.Minute), "1 minute ago"},
{"hours", time.Now().Add(-3 * time.Hour), "3 hours ago"},
{"one hour", time.Now().Add(-1 * time.Hour), "1 hour ago"},
{"days", time.Now().Add(-48 * time.Hour), "2 days ago"},
{"one day", time.Now().Add(-24 * time.Hour), "1 day ago"},
}
for _, tt := range tests {
if got := timeAgo(tt.at); got != tt.want {
t.Errorf("timeAgo(%s) = %q, want %q", tt.name, got, tt.want)
}
}
}
func TestPlural(t *testing.T) {
if got := plural(1, "item"); got != "1 item" {
t.Errorf("plural(1) = %q", got)
}
if got := plural(3, "item"); got != "3 items" {
t.Errorf("plural(3) = %q", got)
}
}
func TestFirstAndLast(t *testing.T) {
items := []int{10, 20, 30}
if got := first(items); got != 10 {
t.Errorf("first = %v, want 10", got)
}
if got := last(items); got != 30 {
t.Errorf("last = %v, want 30", got)
}
if got := first([]int{}); got != nil {
t.Errorf("first of empty slice should be nil, got %v", got)
}
if got := last([]int{}); got != nil {
t.Errorf("last of empty slice should be nil, got %v", got)
}
if got := first("not a slice"); got != nil {
t.Errorf("first of non-slice should be nil, got %v", got)
}
}
func TestSliceOf(t *testing.T) {
items := []int{1, 2, 3, 4, 5}
got, ok := sliceOf(items, 1, 4).([]int)
if !ok {
t.Fatalf("sliceOf should return a slice")
}
if len(got) != 3 || got[0] != 2 || got[2] != 4 {
t.Errorf("sliceOf returned %v", got)
}
// Negative start should be clamped to 0.
got, _ = sliceOf(items, -5, 2).([]int)
if len(got) != 2 || got[0] != 1 {
t.Errorf("sliceOf with negative start returned %v", got)
}
// End beyond length should be clamped.
got, _ = sliceOf(items, 3, 100).([]int)
if len(got) != 2 || got[1] != 5 {
t.Errorf("sliceOf with large end returned %v", got)
}
if got := sliceOf("not slice", 0, 1); got != nil {
t.Errorf("sliceOf of non-slice should be nil, got %v", got)
}
}
func TestContainsHelper(t *testing.T) {
if !contains("hello world", "world") {
t.Errorf("expected substring match")
}
if contains("hello", "xyz") {
t.Errorf("expected no substring match")
}
items := []string{"a", "b", "c"}
if !contains(items, "b") {
t.Errorf("expected element in slice")
}
if contains(items, "z") {
t.Errorf("expected element not in slice")
}
if contains(42, "x") {
t.Errorf("expected no match for non-string/non-slice")
}
}
func TestJoinHelper(t *testing.T) {
if got := join([]string{"a", "b", "c"}, ", "); got != "a, b, c" {
t.Errorf("join returned %q", got)
}
}
func TestDefaultVal(t *testing.T) {
if got := defaultVal("N/A", ""); got != "N/A" {
t.Errorf("defaultVal for empty string = %v, want N/A", got)
}
if got := defaultVal("N/A", nil); got != "N/A" {
t.Errorf("defaultVal for nil = %v, want N/A", got)
}
if got := defaultVal("N/A", 0); got != "N/A" {
t.Errorf("defaultVal for zero = %v, want N/A", got)
}
if got := defaultVal("N/A", "value"); got != "value" {
t.Errorf("defaultVal for present value = %v, want value", got)
}
}
func TestTernary(t *testing.T) {
if got := ternary("yes", "no", true); got != "yes" {
t.Errorf("ternary(true) = %v", got)
}
if got := ternary("yes", "no", false); got != "no" {
t.Errorf("ternary(false) = %v", got)
}
}
func TestCoalesce(t *testing.T) {
if got := coalesce(nil, "", "first", "second"); got != "first" {
t.Errorf("coalesce = %v, want first", got)
}
if got := coalesce(nil, ""); got != nil {
t.Errorf("coalesce of all empty should be nil, got %v", got)
}
}
func TestFuncMap(t *testing.T) {
fm := funcMap()
expected := []string{
"hasField", "capitalize", "prepend", "strAppend", "split", "truncate",
"fmtNumber", "fmtDate", "timeAgo", "first", "last", "sliceOf",
"contains", "join", "defaultVal", "ternary", "coalesce",
}
for _, name := range expected {
if _, ok := fm[name]; !ok {
t.Errorf("expected funcMap to contain %q", name)
}
}
}
//go:embed all:testingdata/templates
var testTemplatesFS embed.FS
func TestNewTemplatesAndRenderNamed(t *testing.T) {
// Register the built-in components plus our test templates.
NewTemplates(components_resources, testTemplatesFS)
if tmpl == nil {
t.Fatalf("expected templates to be initialized")
}
out, err := RenderNamedTemplate("test_greeting", map[string]interface{}{"Name": "Goffee"})
if err != nil {
t.Fatalf("failed rendering named template: %v", err)
}
if !strings.Contains(string(out), "Hello, Goffee!") {
t.Errorf("unexpected rendered output: %q", string(out))
}
}
func TestRenderNamedTemplateMissing(t *testing.T) {
NewTemplates(components_resources, testTemplatesFS)
_, err := RenderNamedTemplate("does_not_exist", nil)
if err == nil {
t.Errorf("expected error rendering a missing template")
}
}
func TestRenderNamedTemplateUsesFuncMap(t *testing.T) {
NewTemplates(components_resources, testTemplatesFS)
out, err := RenderNamedTemplate("test_funcmap", map[string]interface{}{"Raw": "hello world"})
if err != nil {
t.Fatalf("failed rendering named template: %v", err)
}
if !strings.Contains(string(out), "Hello world") {
t.Errorf("expected capitalize helper to be applied, got %q", string(out))
}
}

View file

@ -0,0 +1 @@
{{define "test_funcmap"}}{{capitalize .Raw}}{{end}}

View file

@ -0,0 +1 @@
{{define "test_greeting"}}Hello, {{.Name}}!{{end}}