diff --git a/README.md b/README.md index 73e7ccc..ec031bd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..0a81ae6 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,118 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "fmt" + "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) + } +} diff --git a/context_test.go b/context_test.go index 9cc4287..e293a08 100644 --- a/context_test.go +++ b/context_test.go @@ -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{ diff --git a/cookies.go b/cookies.go index 9830099..b5b0018 100644 --- a/cookies.go +++ b/cookies.go @@ -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 { diff --git a/cookies_test.go b/cookies_test.go new file mode 100644 index 0000000..a97084b --- /dev/null +++ b/cookies_test.go @@ -0,0 +1,294 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "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) +} diff --git a/core_test.go b/core_test.go index fc146af..08dffdc 100644 --- a/core_test.go +++ b/core_test.go @@ -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) diff --git a/events-manager_test.go b/events-manager_test.go index 4630b21..1380039 100644 --- a/events-manager_test.go +++ b/events-manager_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index 6f47488..fe70367 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 6155305..7cd51cb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/hashing_test.go b/hashing_test.go new file mode 100644 index 0000000..53da62f --- /dev/null +++ b/hashing_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Zeni Kim +// Use of this source code is governed by MIT-style +// license that can be found in the LICENSE file. + +package core + +import ( + "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") + } +} diff --git a/jwt.go b/jwt.go index 7768d09..7f1b5cb 100644 --- a/jwt.go +++ b/jwt.go @@ -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 diff --git a/jwt_test.go b/jwt_test.go index c7bf7a8..3d5459b 100644 --- a/jwt_test.go +++ b/jwt_test.go @@ -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{ diff --git a/response_test.go b/response_test.go index dd99405..3b7bfa2 100644 --- a/response_test.go +++ b/response_test.go @@ -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("

Hi

") + if res.contentType != CONTENT_TYPE_HTML { + t.Errorf("expected content type HTML, got %q", res.contentType) + } + if string(res.body) != "

Hi

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