Files
fiber/middleware/cache/cache_test.go
zeekayandHanzo Dev f60e254acb module: rename to github.com/zap-proto/fiber/v3
Fork of gofiber/fiber v3 based on the v3.2.0 tag. Rewrites all self-imports
github.com/gofiber/fiber/v3 -> github.com/zap-proto/fiber/v3. Upstream
gofiber/schema and gofiber/utils/v2 dependencies are left untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 12:49:35 -07:00

5485 lines
162 KiB
Go

// Special thanks to @codemicro for moving this to fiber core
// Original middleware: github.com/codemicro/fiber-cache
package cache
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/zap-proto/fiber/v3/internal/storage/memory"
"github.com/zap-proto/fiber/v3/middleware/etag"
"github.com/gofiber/utils/v2"
"github.com/stretchr/testify/require"
"github.com/valyala/fasthttp"
)
type failingCacheStorage struct {
data map[string][]byte
errs map[string]error
mu sync.RWMutex
}
type mutatingStorage struct {
data map[string][]byte
mutate func(key string, value []byte) []byte
}
func newFailingCacheStorage() *failingCacheStorage {
return &failingCacheStorage{
data: make(map[string][]byte),
errs: make(map[string]error),
}
}
func newMutatingStorage(mutate func(key string, value []byte) []byte) *mutatingStorage {
return &mutatingStorage{
data: make(map[string][]byte),
mutate: mutate,
}
}
func (s *mutatingStorage) GetWithContext(_ context.Context, key string) ([]byte, error) {
return s.Get(key)
}
func (s *mutatingStorage) Get(key string) ([]byte, error) {
if value, ok := s.data[key]; ok {
return value, nil
}
return nil, nil
}
func (s *mutatingStorage) SetWithContext(_ context.Context, key string, val []byte, _ time.Duration) error {
return s.Set(key, val, 0)
}
func (s *mutatingStorage) Set(key string, val []byte, _ time.Duration) error {
if key == "" || len(val) == 0 {
return nil
}
if s.mutate != nil {
val = s.mutate(key, val)
}
s.data[key] = val
return nil
}
func (s *mutatingStorage) DeleteWithContext(_ context.Context, key string) error {
return s.Delete(key)
}
func (s *mutatingStorage) Delete(key string) error {
delete(s.data, key)
return nil
}
func (s *mutatingStorage) ResetWithContext(_ context.Context) error {
return s.Reset()
}
func (s *mutatingStorage) Reset() error {
s.data = make(map[string][]byte)
return nil
}
func (s *mutatingStorage) Close() error {
s.data = nil
return nil
}
func (s *failingCacheStorage) GetWithContext(_ context.Context, key string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err, ok := s.errs["get|"+key]; ok && err != nil {
return nil, err
}
if val, ok := s.data[key]; ok {
return append([]byte(nil), val...), nil
}
return nil, nil
}
func (s *failingCacheStorage) Get(key string) ([]byte, error) {
return s.GetWithContext(context.Background(), key)
}
func (s *failingCacheStorage) SetWithContext(_ context.Context, key string, val []byte, _ time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
if err, ok := s.errs["set|"+key]; ok && err != nil {
return err
}
s.data[key] = append([]byte(nil), val...)
return nil
}
func (s *failingCacheStorage) Set(key string, val []byte, exp time.Duration) error {
return s.SetWithContext(context.Background(), key, val, exp)
}
func (s *failingCacheStorage) DeleteWithContext(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err, ok := s.errs["del|"+key]; ok && err != nil {
return err
}
delete(s.data, key)
return nil
}
func (s *failingCacheStorage) Delete(key string) error {
return s.DeleteWithContext(context.Background(), key)
}
func (s *failingCacheStorage) ResetWithContext(context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
s.data = make(map[string][]byte)
s.errs = make(map[string]error)
return nil
}
func (s *failingCacheStorage) Reset() error {
return s.ResetWithContext(context.Background())
}
func (*failingCacheStorage) Close() error { return nil }
type contextRecord struct {
key string
value string
canceled bool
}
type contextRecorderStorage struct {
*failingCacheStorage
deletes []contextRecord
gets []contextRecord
sets []contextRecord
}
func newContextRecorderStorage() *contextRecorderStorage {
return &contextRecorderStorage{failingCacheStorage: newFailingCacheStorage()}
}
func contextRecordFrom(ctx context.Context, key string) contextRecord {
record := contextRecord{
key: key,
canceled: errors.Is(ctx.Err(), context.Canceled),
}
if value, ok := ctx.Value(markerKey).(string); ok {
record.value = value
}
return record
}
func (s *contextRecorderStorage) GetWithContext(ctx context.Context, key string) ([]byte, error) {
s.gets = append(s.gets, contextRecordFrom(ctx, key))
return s.failingCacheStorage.GetWithContext(ctx, key)
}
func (s *contextRecorderStorage) SetWithContext(ctx context.Context, key string, val []byte, exp time.Duration) error {
s.sets = append(s.sets, contextRecordFrom(ctx, key))
return s.failingCacheStorage.SetWithContext(ctx, key, val, exp)
}
func (s *contextRecorderStorage) DeleteWithContext(ctx context.Context, key string) error {
s.deletes = append(s.deletes, contextRecordFrom(ctx, key))
return s.failingCacheStorage.DeleteWithContext(ctx, key)
}
func (s *contextRecorderStorage) recordedGets() []contextRecord {
out := make([]contextRecord, len(s.gets))
copy(out, s.gets)
return out
}
func (s *contextRecorderStorage) recordedSets() []contextRecord {
out := make([]contextRecord, len(s.sets))
copy(out, s.sets)
return out
}
func (s *contextRecorderStorage) recordedDeletes() []contextRecord {
out := make([]contextRecord, len(s.deletes))
copy(out, s.deletes)
return out
}
func TestCacheStorageGetError(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
storage.errs["get|GET|/|q=|h=accept:|accept-encoding:|accept-language:"] = errors.New("boom")
var captured error
app := fiber.New(fiber.Config{
ErrorHandler: func(c fiber.Ctx, err error) error {
captured = err
return c.Status(fiber.StatusInternalServerError).SendString("storage failure")
},
})
app.Use(New(Config{Storage: storage, Expiration: time.Second}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
require.Error(t, captured)
require.ErrorContains(t, captured, "cache: failed to get key")
}
func TestCacheStorageSetError(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
storage.errs["set|GET|/|q=|h=accept:|accept-encoding:|accept-language:_body"] = errors.New("boom")
var captured error
app := fiber.New(fiber.Config{
ErrorHandler: func(c fiber.Ctx, err error) error {
captured = err
return c.Status(fiber.StatusInternalServerError).SendString("storage failure")
},
})
app.Use(New(Config{Storage: storage, Expiration: time.Second}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
require.Error(t, captured)
require.ErrorContains(t, captured, "cache: failed to store raw key")
}
func TestCacheStorageDeleteError(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
storage.errs["del|GET|/|q=|h=accept:|accept-encoding:|accept-language:"] = errors.New("boom")
// Use an obviously expired timestamp without relying on time-based conversions
expired := &item{exp: 1}
raw, err := expired.MarshalMsg(nil)
require.NoError(t, err)
storage.data["GET|/|q=|h=accept:|accept-encoding:|accept-language:"] = raw
var captured error
app := fiber.New(fiber.Config{
ErrorHandler: func(c fiber.Ctx, err error) error {
captured = err
return c.Status(fiber.StatusInternalServerError).SendString("storage failure")
},
})
app.Use(New(Config{Storage: storage, Expiration: time.Second}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
require.Error(t, captured)
require.ErrorContains(t, captured, "cache: failed to delete expired key")
}
type contextKey string
const markerKey contextKey = "marker"
func contextWithMarker(label string) context.Context {
return context.WithValue(context.Background(), markerKey, label)
}
func canceledContextWithMarker(label string) context.Context {
ctx, cancel := context.WithCancel(contextWithMarker(label))
cancel()
return ctx
}
func TestCacheEvictionPropagatesRequestContextToDelete(t *testing.T) {
t.Parallel()
storage := newContextRecorderStorage()
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
path := c.Path()
if path == "/first" {
c.SetContext(contextWithMarker("first"))
}
if path == "/second" {
c.SetContext(canceledContextWithMarker("evict"))
}
return c.Next()
})
app.Use(New(Config{Storage: storage, Expiration: time.Minute, MaxBytes: 5}))
app.Get("/first", func(c fiber.Ctx) error {
return c.SendString("aaa")
})
app.Get("/second", func(c fiber.Ctx) error {
return c.SendString("bbbb")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/first", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/second", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
records := storage.recordedDeletes()
require.Len(t, records, 2)
var keys []string
for _, rec := range records {
keys = append(keys, rec.key)
require.Equal(t, "evict", rec.value)
require.True(t, rec.canceled)
}
require.ElementsMatch(t, []string{
"GET|/first|q=|h=accept:|accept-encoding:|accept-language:",
"GET|/first|q=|h=accept:|accept-encoding:|accept-language:_body",
}, keys)
}
func TestCacheCleanupPropagatesRequestContextToDelete(t *testing.T) {
t.Parallel()
storage := newContextRecorderStorage()
storage.errs["set|GET|/|q=|h=accept:|accept-encoding:|accept-language:"] = errors.New("boom")
var captured error
app := fiber.New(fiber.Config{
ErrorHandler: func(c fiber.Ctx, err error) error {
captured = err
return c.Status(fiber.StatusInternalServerError).SendString("storage failure")
},
})
app.Use(func(c fiber.Ctx) error {
c.SetContext(canceledContextWithMarker("cleanup"))
return c.Next()
})
app.Use(New(Config{Storage: storage, Expiration: time.Minute}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("payload")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusInternalServerError, resp.StatusCode)
require.Error(t, captured)
require.ErrorContains(t, captured, "cache: failed to store key")
records := storage.recordedDeletes()
require.Len(t, records, 1)
require.Equal(t, "GET|/|q=|h=accept:|accept-encoding:|accept-language:_body", records[0].key)
require.Equal(t, "cleanup", records[0].value)
require.True(t, records[0].canceled)
}
func TestCacheStorageOperationsObserveRequestContext(t *testing.T) {
t.Parallel()
storage := newContextRecorderStorage()
app := fiber.New()
app.Use(func(c fiber.Ctx) error {
ctxLabel := string(c.Request().Header.Peek("X-Context"))
if ctxLabel == "" {
return c.Next()
}
canceled := string(c.Request().Header.Peek("X-Cancel")) == "true"
if canceled {
c.SetContext(canceledContextWithMarker(ctxLabel))
} else {
c.SetContext(contextWithMarker(ctxLabel))
}
return c.Next()
})
app.Use(New(Config{Storage: storage, Expiration: time.Minute}))
app.Get("/cache", func(c fiber.Ctx) error {
return c.SendString("payload")
})
firstReq := httptest.NewRequest(fiber.MethodGet, "/cache", http.NoBody)
firstReq.Header.Set("X-Context", "store")
firstReq.Header.Set("X-Cancel", "true")
resp, err := app.Test(firstReq)
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
secondReq := httptest.NewRequest(fiber.MethodGet, "/cache", http.NoBody)
secondReq.Header.Set("X-Context", "fetch")
secondReq.Header.Set("X-Cancel", "true")
resp, err = app.Test(secondReq)
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
setRecords := storage.recordedSets()
require.Len(t, setRecords, 2)
for _, rec := range setRecords {
require.Contains(t, []string{
"GET|/cache|q=|h=accept:|accept-encoding:|accept-language:",
"GET|/cache|q=|h=accept:|accept-encoding:|accept-language:_body",
}, rec.key)
require.Equal(t, "store", rec.value)
require.True(t, rec.canceled)
}
getRecords := storage.recordedGets()
require.NotEmpty(t, getRecords)
var fetchEntry, fetchBody bool
for _, rec := range getRecords {
if rec.value != "fetch" {
continue
}
if rec.key == "GET|/cache|q=|h=accept:|accept-encoding:|accept-language:" {
require.True(t, rec.canceled)
fetchEntry = true
}
if rec.key == "GET|/cache|q=|h=accept:|accept-encoding:|accept-language:_body" {
require.True(t, rec.canceled)
fetchBody = true
}
}
require.True(t, fetchEntry, "expected cached entry retrieval to observe request context")
require.True(t, fetchBody, "expected cached body retrieval to observe request context")
}
func Test_Cache_CacheControl(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Hello, World!")
})
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
// max-age can be 10 or 11 depending on sub-second timing between store and read
cc := resp.Header.Get(fiber.HeaderCacheControl)
require.True(t, cc == "public, max-age=10" || cc == "public, max-age=11",
"unexpected Cache-Control: %s", cc)
}
func Test_Cache_CacheControl_Disabled(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 10 * time.Second,
DisableCacheControl: true,
}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Hello, World!")
})
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Empty(t, resp.Header.Get(fiber.HeaderCacheControl))
}
func Test_Cache_Expired(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 2 * time.Second}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
// Sleep until the cache is expired
time.Sleep(3 * time.Second)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
if bytes.Equal(body, bodyCached) {
t.Errorf("Cache should have expired: %s, %s", body, bodyCached)
}
// Next response should be also cached
respCachedNextRound, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCachedNextRound, err := io.ReadAll(respCachedNextRound.Body)
require.NoError(t, err)
if !bytes.Equal(bodyCachedNextRound, bodyCached) {
t.Errorf("Cache should not have expired: %s, %s", bodyCached, bodyCachedNextRound)
}
}
func Test_Cache(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{DisableQueryKeys: true}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
cachedReq := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
cachedResp, err := app.Test(cachedReq)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
cachedBody, err := io.ReadAll(cachedResp.Body)
require.NoError(t, err)
require.Equal(t, cachedBody, body)
}
// go test -run Test_Cache_WithNoCacheRequestDirective
func Test_Cache_WithNoCacheRequestDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{DisableQueryKeys: true}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString(fiber.Query(c, "id", "1"))
})
// Request id = 1
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, []byte("1"), body)
// Response cached, entry id = 1
// Request id = 2 without Cache-Control: no-cache
cachedReq := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
cachedResp, err := app.Test(cachedReq)
require.NoError(t, err)
cachedBody, err := io.ReadAll(cachedResp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedResp.Header.Get("X-Cache"))
require.Equal(t, []byte("1"), cachedBody)
// Response not cached, returns cached response, entry id = 1
// Request id = 2 with Cache-Control: no-cache
noCacheReq := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
noCacheReq.Header.Set(fiber.HeaderCacheControl, noCache)
noCacheResp, err := app.Test(noCacheReq)
require.NoError(t, err)
noCacheBody, err := io.ReadAll(noCacheResp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheResp.Header.Get("X-Cache"))
require.Equal(t, []byte("2"), noCacheBody)
// Response cached, returns updated response, entry = 2
/* Check Test_Cache_WithETagAndNoCacheRequestDirective */
// Request id = 2 with Cache-Control: no-cache again
noCacheReq1 := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
noCacheReq1.Header.Set(fiber.HeaderCacheControl, noCache)
noCacheResp1, err := app.Test(noCacheReq1)
require.NoError(t, err)
noCacheBody1, err := io.ReadAll(noCacheResp1.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheResp1.Header.Get("X-Cache"))
require.Equal(t, []byte("2"), noCacheBody1)
// Response cached, returns updated response, entry = 2
// Request id = 3 with Cache-Control: NO-CACHE
noCacheReqUpper := httptest.NewRequest(fiber.MethodGet, "/?id=3", http.NoBody)
noCacheReqUpper.Header.Set(fiber.HeaderCacheControl, "NO-CACHE")
noCacheRespUpper, err := app.Test(noCacheReqUpper)
require.NoError(t, err)
noCacheBodyUpper, err := io.ReadAll(noCacheRespUpper.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheRespUpper.Header.Get("X-Cache"))
require.Equal(t, []byte("3"), noCacheBodyUpper)
// Response cached, returns updated response, entry = 3
// Request id = 4 with Cache-Control: my-no-cache
invalidReq := httptest.NewRequest(fiber.MethodGet, "/?id=4", http.NoBody)
invalidReq.Header.Set(fiber.HeaderCacheControl, "my-no-cache")
invalidResp, err := app.Test(invalidReq)
require.NoError(t, err)
invalidBody, err := io.ReadAll(invalidResp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, invalidResp.Header.Get("X-Cache"))
require.Equal(t, []byte("3"), invalidBody)
// Response served from cache, existing entry = 3
// Request id = 4 again without Cache-Control: no-cache
cachedInvalidReq := httptest.NewRequest(fiber.MethodGet, "/?id=4", http.NoBody)
cachedInvalidResp, err := app.Test(cachedInvalidReq)
require.NoError(t, err)
cachedInvalidBody, err := io.ReadAll(cachedInvalidResp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedInvalidResp.Header.Get("X-Cache"))
require.Equal(t, []byte("3"), cachedInvalidBody)
// Response cached, returns cached response, entry id = 3
// Request id = 1 without Cache-Control: no-cache
cachedReq1 := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
cachedResp1, err := app.Test(cachedReq1)
require.NoError(t, err)
cachedBody1, err := io.ReadAll(cachedResp1.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedResp1.Header.Get("X-Cache"))
require.Equal(t, []byte("3"), cachedBody1)
// Response not cached, returns cached response, entry id = 3
}
// go test -run Test_Cache_WithETagAndNoCacheRequestDirective
func Test_Cache_WithETagAndNoCacheRequestDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(
etag.New(),
New(Config{DisableQueryKeys: true}),
)
app.Get("/", func(c fiber.Ctx) error {
return c.SendString(fiber.Query(c, "id", "1"))
})
// Request id = 1
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusOK, resp.StatusCode)
// Response cached, entry id = 1
// If response status 200
etagToken := resp.Header.Get("Etag")
// Request id = 2 with ETag but without Cache-Control: no-cache
cachedReq := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
cachedReq.Header.Set(fiber.HeaderIfNoneMatch, etagToken)
cachedResp, err := app.Test(cachedReq)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedResp.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusNotModified, cachedResp.StatusCode)
// Response not cached, returns cached response, entry id = 1, status not modified
// Request id = 2 with ETag and Cache-Control: no-cache
noCacheReq := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
noCacheReq.Header.Set(fiber.HeaderCacheControl, noCache)
noCacheReq.Header.Set(fiber.HeaderIfNoneMatch, etagToken)
noCacheResp, err := app.Test(noCacheReq)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheResp.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusOK, noCacheResp.StatusCode)
// Response cached, returns updated response, entry id = 2
// If response status 200
etagToken = noCacheResp.Header.Get("Etag")
// Request id = 3 with ETag and Cache-Control: NO-CACHE
noCacheReqUpper := httptest.NewRequest(fiber.MethodGet, "/?id=3", http.NoBody)
noCacheReqUpper.Header.Set(fiber.HeaderCacheControl, "NO-CACHE")
noCacheReqUpper.Header.Set(fiber.HeaderIfNoneMatch, etagToken)
noCacheRespUpper, err := app.Test(noCacheReqUpper)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheRespUpper.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusOK, noCacheRespUpper.StatusCode)
// Response cached, returns updated response, entry id = 3
// Request id = 2 with ETag and Cache-Control: no-cache again
noCacheReq1 := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
noCacheReq1.Header.Set(fiber.HeaderCacheControl, noCache)
noCacheReq1.Header.Set(fiber.HeaderIfNoneMatch, etagToken)
noCacheResp1, err := app.Test(noCacheReq1)
require.NoError(t, err)
require.Equal(t, cacheMiss, noCacheResp1.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusNotModified, noCacheResp1.StatusCode)
// Response cached, returns updated response, entry id = 2, status not modified
// Request id = 1 without ETag and Cache-Control: no-cache
cachedReq1 := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
cachedResp1, err := app.Test(cachedReq1)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedResp1.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusOK, cachedResp1.StatusCode)
// Response not cached, returns cached response, entry id = 2
}
// go test -run Test_Cache_WithNoStoreRequestDirective
func Test_Cache_WithNoStoreRequestDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{DisableQueryKeys: true}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString(fiber.Query(c, "id", "1"))
})
// Request id = 2
noStoreReq := httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody)
noStoreReq.Header.Set(fiber.HeaderCacheControl, noStore)
noStoreResp, err := app.Test(noStoreReq)
require.NoError(t, err)
noStoreBody, err := io.ReadAll(noStoreResp.Body)
require.NoError(t, err)
require.Equal(t, []byte("2"), noStoreBody)
// Response not cached, returns updated response
// Request id = 3 with Cache-Control: NO-STORE
noStoreReqUpper := httptest.NewRequest(fiber.MethodGet, "/?id=3", http.NoBody)
noStoreReqUpper.Header.Set(fiber.HeaderCacheControl, "NO-STORE")
noStoreRespUpper, err := app.Test(noStoreReqUpper)
require.NoError(t, err)
noStoreBodyUpper, err := io.ReadAll(noStoreRespUpper.Body)
require.NoError(t, err)
require.Equal(t, []byte("3"), noStoreBodyUpper)
// Response not cached, returns updated response
// Request id = 4 with Cache-Control: my-no-store
invalidReq := httptest.NewRequest(fiber.MethodGet, "/?id=4", http.NoBody)
invalidReq.Header.Set(fiber.HeaderCacheControl, "my-no-store")
invalidResp, err := app.Test(invalidReq)
require.NoError(t, err)
invalidBody, err := io.ReadAll(invalidResp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, invalidResp.Header.Get("X-Cache"))
require.Equal(t, []byte("4"), invalidBody)
// Response cached, returns updated response, entry = 4
// Request id = 4 again without Cache-Control
cachedInvalidReq := httptest.NewRequest(fiber.MethodGet, "/?id=4", http.NoBody)
cachedInvalidResp, err := app.Test(cachedInvalidReq)
require.NoError(t, err)
cachedInvalidBody, err := io.ReadAll(cachedInvalidResp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedInvalidResp.Header.Get("X-Cache"))
require.Equal(t, []byte("4"), cachedInvalidBody)
// Response cached previously, served from cache
}
func Test_Cache_WithSeveralRequests(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 10 * time.Second,
}))
app.Get("/:id", func(c fiber.Ctx) error {
return c.SendString(c.Params("id"))
})
for range 10 {
for i := range 10 {
func(id int) {
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, fmt.Sprintf("/%d", id), http.NoBody))
require.NoError(t, err)
defer func(body io.ReadCloser) {
closeErr := body.Close()
require.NoError(t, closeErr)
}(rsp.Body)
idFromServ, err := io.ReadAll(rsp.Body)
require.NoError(t, err)
a, err := strconv.Atoi(string(idFromServ))
require.NoError(t, err)
// Sometimes, the id is not equal to a
require.Equal(t, id, a)
}(i)
}
}
}
func Test_Cache_Invalid_Expiration(t *testing.T) {
t.Parallel()
app := fiber.New()
cache := New(Config{Expiration: 0 * time.Second})
app.Use(cache)
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
cachedReq := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
cachedResp, err := app.Test(cachedReq)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
cachedBody, err := io.ReadAll(cachedResp.Body)
require.NoError(t, err)
require.Equal(t, cachedBody, body)
}
func Test_Cache_Get(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
app.Post("/", func(c fiber.Ctx) error {
return c.SendString(fiber.Query[string](c, "cache"))
})
app.Get("/get", func(c fiber.Ctx) error {
return c.SendString(fiber.Query[string](c, "cache"))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=123", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "123", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=12345", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "12345", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/get?cache=123", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "123", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/get?cache=12345", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "12345", string(body))
}
func Test_Cache_Post(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count atomic.Int32
app.Post("/", func(c fiber.Ctx) error {
current := count.Add(1)
return c.SendString(fmt.Sprintf("%d:%s", current, fiber.Query[string](c, "cache")))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=123", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "1:123", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=123", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "2:123", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=12345", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "3:12345", string(body))
}
func Test_Cache_CustomMethods(t *testing.T) {
t.Parallel()
t.Run("POST cached when in Methods", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Methods: []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodPost},
}))
var count atomic.Int32
app.Post("/", func(c fiber.Ctx) error {
current := count.Add(1)
return c.SendString(strconv.Itoa(int(current)))
})
// First POST — cache miss
resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, "1", string(body))
// Second POST — cache hit
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, "1", string(body))
})
t.Run("unconfigured method bypasses cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Methods: []string{fiber.MethodGet},
}))
var count atomic.Int32
app.Put("/", func(c fiber.Ctx) error {
current := count.Add(1)
return c.SendString(strconv.Itoa(int(current)))
})
// PUT not in Methods — always bypasses cache
resp, err := app.Test(httptest.NewRequest(fiber.MethodPut, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, int32(1), count.Load())
resp, err = app.Test(httptest.NewRequest(fiber.MethodPut, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, int32(2), count.Load(), "handler must be called on every bypass")
})
t.Run("empty Methods slice disables caching", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Methods: []string{},
}))
var count atomic.Int32
app.Get("/", func(c fiber.Ctx) error {
current := count.Add(1)
return c.SendString(strconv.Itoa(int(current)))
})
// Even GET bypasses cache when Methods is explicitly empty
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "2", string(body))
require.Equal(t, int32(2), count.Load(), "handler must be called each time with empty Methods")
})
t.Run("lowercase method names are normalized", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Methods: []string{"get", "post"},
}))
var count atomic.Int32
app.Get("/", func(c fiber.Ctx) error {
current := count.Add(1)
return c.SendString(strconv.Itoa(int(current)))
})
// "get" should be normalized to "GET" and match
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, "1", string(body))
})
}
func Test_Cache_DefaultKeyDimensions(t *testing.T) {
t.Parallel()
t.Run("distinct query values do not collide", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Request().URI().QueryString()))
})
req := httptest.NewRequest(fiber.MethodGet, "/?id=1", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:id=1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/?id=2", http.NoBody))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2:id=2", string(body))
})
t.Run("representation headers partition cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Get(fiber.HeaderAcceptLanguage)))
})
reqEN := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqEN.Header.Set(fiber.HeaderAcceptLanguage, "en-US")
resp, err := app.Test(reqEN)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:en-US", string(body))
reqFR := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqFR.Header.Set(fiber.HeaderAcceptLanguage, "fr-FR")
resp, err = app.Test(reqFR)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2:fr-FR", string(body))
})
t.Run("keyed cookies preserve case-sensitive names", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{KeyCookies: []string{"SessionID"}}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Cookies("SessionID")))
})
reqA := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqA.AddCookie(&http.Cookie{Name: "SessionID", Value: "alpha"})
resp, err := app.Test(reqA)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, "1:alpha", string(body))
reqB := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqB.AddCookie(&http.Cookie{Name: "SessionID", Value: "beta"})
resp, err = app.Test(reqB)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, "2:beta", string(body))
reqBRepeat := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqBRepeat.AddCookie(&http.Cookie{Name: "SessionID", Value: "beta"})
resp, err = app.Test(reqBRepeat)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, "2:beta", string(body))
})
t.Run("long paths are bounded in storage keys", func(t *testing.T) {
t.Parallel()
storage := newMutatingStorage(nil)
oversizedPath := "/" + strings.Repeat("a", maxKeyDimensionSegmentLength+1)
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
Storage: storage,
}))
app.Get(oversizedPath, func(c fiber.Ctx) error {
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, oversizedPath, http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
hash := sha256.Sum256([]byte(oversizedPath))
expectedBoundedPath := "sha256:" + hex.EncodeToString(hash[:])
require.Len(t, expectedBoundedPath, len("sha256:")+sha256.Size*2)
expectedPrefix := fiber.MethodGet + "|" + expectedBoundedPath
foundBoundedKey := false
for key := range storage.data {
require.NotContains(t, key, oversizedPath)
if strings.HasPrefix(key, expectedPrefix) {
foundBoundedKey = true
}
}
require.True(t, foundBoundedKey)
})
t.Run("empty keyed headers disable default header partitioning", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{KeyHeaders: []string{}}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Get(fiber.HeaderAcceptLanguage)))
})
reqEN := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqEN.Header.Set(fiber.HeaderAcceptLanguage, "en-US")
resp, err := app.Test(reqEN)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:en-US", string(body))
reqFR := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqFR.Header.Set(fiber.HeaderAcceptLanguage, "fr-FR")
resp, err = app.Test(reqFR)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, "1:en-US", string(body))
})
t.Run("non-keyed headers do not fragment cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Get("X-Request-ID")))
})
reqA := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqA.Header.Set("X-Request-ID", "a")
resp, err := app.Test(reqA)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:a", string(body))
reqB := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqB.Header.Set("X-Request-ID", "b")
resp, err = app.Test(reqB)
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:a", string(body))
})
t.Run("body-dependent requests are bypassed by default", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
count := 0
app.Post("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("%d:%s", count, c.Body()))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/", strings.NewReader("a")))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1:a", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/", strings.NewReader("b")))
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2:b", string(body))
})
}
func Test_Cache_NothingToCache(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: -(time.Second * 1)}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
time.Sleep(500 * time.Millisecond)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
if bytes.Equal(body, bodyCached) {
t.Errorf("Cache should have expired: %s, %s", body, bodyCached)
}
}
func Test_Cache_CustomNext(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Next: func(c fiber.Ctx) bool {
return c.Response().StatusCode() != fiber.StatusOK
},
}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
errorCount := 0
app.Get("/error", func(c fiber.Ctx) error {
errorCount++
return c.Status(fiber.StatusInternalServerError).SendString(strconv.Itoa(errorCount))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
require.True(t, bytes.Equal(body, bodyCached))
require.NotEmpty(t, respCached.Header.Get(fiber.HeaderCacheControl))
_, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/error", http.NoBody))
require.NoError(t, err)
errRespCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/error", http.NoBody))
require.NoError(t, err)
require.Empty(t, errRespCached.Header.Get(fiber.HeaderCacheControl))
}
func Test_CustomKey(t *testing.T) {
t.Parallel()
app := fiber.New()
var called bool
app.Use(New(Config{KeyGenerator: func(c fiber.Ctx) string {
called = true
return utils.CopyString(c.Path())
}}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("hi")
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
_, err := app.Test(req)
require.NoError(t, err)
require.True(t, called)
}
func Test_CustomExpiration(t *testing.T) {
t.Parallel()
app := fiber.New()
var called bool
var newCacheTime int
app.Use(New(Config{ExpirationGenerator: func(c fiber.Ctx, _ *Config) time.Duration {
called = true
var err error
newCacheTime, err = strconv.Atoi(c.GetRespHeader("Cache-Time", "600"))
require.NoError(t, err)
return time.Second * time.Duration(newCacheTime)
}}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
c.Response().Header.Add("Cache-Time", "1")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.True(t, called)
require.Equal(t, 1, newCacheTime)
// Wait until the cache expires (timestamp tick can delay expiry detection slightly).
expireDeadline := time.Now().Add(3 * time.Second)
var cachedResp *http.Response
for {
cachedResp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
if cachedResp.Header.Get("X-Cache") != cacheHit {
break
}
require.True(t, time.Now().Before(expireDeadline), "response remained cached beyond expected expiration")
time.Sleep(50 * time.Millisecond)
}
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
cachedBody, err := io.ReadAll(cachedResp.Body)
require.NoError(t, err)
if bytes.Equal(body, cachedBody) {
t.Errorf("Cache should have expired: %s, %s", body, cachedBody)
}
// Next response should be cached
cachedRespNextRound, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
cachedBodyNextRound, err := io.ReadAll(cachedRespNextRound.Body)
require.NoError(t, err)
if !bytes.Equal(cachedBodyNextRound, cachedBody) {
t.Errorf("Cache should not have expired: %s, %s", cachedBodyNextRound, cachedBody)
}
}
func Test_AdditionalE2EResponseHeaders(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
StoreResponseHeaders: true,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Response().Header.Add("X-Foobar", "foobar")
return c.SendString("hi")
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, "foobar", resp.Header.Get("X-Foobar"))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, "foobar", resp.Header.Get("X-Foobar"))
}
func Test_CacheHeader(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 10 * time.Second,
Next: func(c fiber.Ctx) bool {
return c.Response().StatusCode() != fiber.StatusOK
},
}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Hello, World!")
})
app.Post("/", func(c fiber.Ctx) error {
return c.SendString(fiber.Query[string](c, "cache"))
})
count := 0
app.Get("/error", func(c fiber.Ctx) error {
count++
c.Response().Header.Add("Cache-Time", "1")
return c.Status(fiber.StatusInternalServerError).SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/?cache=12345", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
errRespCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/error", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, errRespCached.Header.Get("X-Cache"))
}
func Test_Cache_WithHead(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
count := 0
handler := func(c fiber.Ctx) error {
count++
c.Response().Header.Add("Cache-Time", "1")
return c.SendString(strconv.Itoa(count))
}
app.RouteChain("/").Get(handler).Head(handler)
req := httptest.NewRequest(fiber.MethodHead, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
cachedReq := httptest.NewRequest(fiber.MethodHead, "/", http.NoBody)
cachedResp, err := app.Test(cachedReq)
require.NoError(t, err)
require.Equal(t, cacheHit, cachedResp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
cachedBody, err := io.ReadAll(cachedResp.Body)
require.NoError(t, err)
require.Equal(t, cachedBody, body)
}
func Test_Cache_WithHeadThenGet(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
handler := func(c fiber.Ctx) error {
return c.SendString(fiber.Query[string](c, "cache"))
}
app.RouteChain("/").Get(handler).Head(handler)
headResp, err := app.Test(httptest.NewRequest(fiber.MethodHead, "/?cache=123", http.NoBody))
require.NoError(t, err)
headBody, err := io.ReadAll(headResp.Body)
require.NoError(t, err)
require.Empty(t, string(headBody))
require.Equal(t, cacheMiss, headResp.Header.Get("X-Cache"))
headResp, err = app.Test(httptest.NewRequest(fiber.MethodHead, "/?cache=123", http.NoBody))
require.NoError(t, err)
headBody, err = io.ReadAll(headResp.Body)
require.NoError(t, err)
require.Empty(t, string(headBody))
require.Equal(t, cacheHit, headResp.Header.Get("X-Cache"))
getResp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/?cache=123", http.NoBody))
require.NoError(t, err)
getBody, err := io.ReadAll(getResp.Body)
require.NoError(t, err)
require.Equal(t, "123", string(getBody))
require.Equal(t, cacheMiss, getResp.Header.Get("X-Cache"))
getResp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/?cache=123", http.NoBody))
require.NoError(t, err)
getBody, err = io.ReadAll(getResp.Body)
require.NoError(t, err)
require.Equal(t, "123", string(getBody))
require.Equal(t, cacheHit, getResp.Header.Get("X-Cache"))
}
func Test_CustomCacheHeader(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
CacheHeader: "Cache-Status",
}))
app.Get("/", func(c fiber.Ctx) error {
return c.SendString("Hello, World!")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("Cache-Status"))
}
func Test_CacheInvalidation(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
CacheInvalidator: func(c fiber.Ctx) bool {
return fiber.Query[bool](c, "invalidate")
},
}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
require.True(t, bytes.Equal(body, bodyCached))
require.NotEmpty(t, respCached.Header.Get(fiber.HeaderCacheControl))
respInvalidate, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/?invalidate=true", http.NoBody))
require.NoError(t, err)
bodyInvalidate, err := io.ReadAll(respInvalidate.Body)
require.NoError(t, err)
require.NotEqual(t, body, bodyInvalidate)
}
func Test_CacheInvalidation_noCacheEntry(t *testing.T) {
t.Parallel()
t.Run("Cache Invalidator should not be called if no cache entry exist ", func(t *testing.T) {
t.Parallel()
app := fiber.New()
cacheInvalidatorExecuted := false
app.Use(New(Config{
CacheInvalidator: func(c fiber.Ctx) bool {
cacheInvalidatorExecuted = true
return fiber.Query[bool](c, "invalidate")
},
MaxBytes: 10 * 1024 * 1024,
}))
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/?invalidate=true", http.NoBody))
require.NoError(t, err)
require.False(t, cacheInvalidatorExecuted)
})
}
func Test_CacheInvalidation_removeFromHeap(t *testing.T) {
t.Parallel()
t.Run("Invalidate and remove from the heap", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
CacheInvalidator: func(c fiber.Ctx) bool {
return fiber.Query[bool](c, "invalidate")
},
MaxBytes: 10 * 1024 * 1024,
}))
count := 0
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
require.True(t, bytes.Equal(body, bodyCached))
require.NotEmpty(t, respCached.Header.Get(fiber.HeaderCacheControl))
respInvalidate, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/?invalidate=true", http.NoBody))
require.NoError(t, err)
bodyInvalidate, err := io.ReadAll(respInvalidate.Body)
require.NoError(t, err)
require.NotEqual(t, body, bodyInvalidate)
})
}
func Test_CacheStorage_CustomHeaders(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Storage: memory.New(),
MaxBytes: 10 * 1024 * 1024,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Response().Header.Set("Content-Type", "text/xml")
c.Response().Header.Set("Content-Encoding", "utf8")
return c.Send([]byte("<xml><value>Test</value></xml>"))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
respCached, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
bodyCached, err := io.ReadAll(respCached.Body)
require.NoError(t, err)
require.True(t, bytes.Equal(body, bodyCached))
require.NotEmpty(t, respCached.Header.Get(fiber.HeaderCacheControl))
}
// Because time points are updated once every X milliseconds, entries in tests can often have
// equal expiration times and thus be in a random order. This closure hands out increasing
// time intervals to maintain strong ascending order of expiration
func stableAscendingExpiration() func(c1 fiber.Ctx, c2 *Config) time.Duration {
i := 0
return func(_ fiber.Ctx, _ *Config) time.Duration {
i++
return time.Hour * time.Duration(i)
}
}
func Test_Cache_MaxBytesOrder(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 2,
ExpirationGenerator: stableAscendingExpiration(),
}))
app.Get("/*", func(c fiber.Ctx) error {
return c.SendString("1")
})
cases := [][]string{
// Insert a, b into cache of size 2 bytes (responses are 1 byte)
{"/a", cacheMiss},
{"/b", cacheMiss},
{"/a", cacheHit},
{"/b", cacheHit},
// Add c -> a evicted
{"/c", cacheMiss},
{"/b", cacheHit},
// Add a again -> b evicted
{"/a", cacheMiss},
{"/c", cacheHit},
// Add b -> c evicted
{"/b", cacheMiss},
{"/c", cacheMiss},
}
for idx, tcase := range cases {
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, tcase[0], http.NoBody))
require.NoError(t, err)
require.Equal(t, tcase[1], rsp.Header.Get("X-Cache"), "Case %v", idx)
}
}
func Test_Cache_MaxBytesSizes(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 7,
ExpirationGenerator: stableAscendingExpiration(),
}))
app.Get("/*", func(c fiber.Ctx) error {
path := c.RequestCtx().URI().LastPathSegment()
size, err := strconv.Atoi(string(path))
require.NoError(t, err)
return c.Send(make([]byte, size))
})
cases := [][]string{
{"/1", cacheMiss},
{"/2", cacheMiss},
{"/3", cacheMiss},
{"/4", cacheMiss}, // 1+2+3+4 > 7 => 1,2 are evicted now
{"/3", cacheHit},
{"/1", cacheMiss},
{"/2", cacheMiss},
{"/8", cacheUnreachable}, // too big to cache -> unreachable
}
for idx, tcase := range cases {
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, tcase[0], http.NoBody))
require.NoError(t, err)
require.Equal(t, tcase[1], rsp.Header.Get("X-Cache"), "Case %v", idx)
}
}
func Test_Cache_UncacheableStatusCodes(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
app.Get("/:statusCode", func(c fiber.Ctx) error {
statusCode, err := strconv.Atoi(c.Params("statusCode"))
require.NoError(t, err)
return c.Status(statusCode).SendString("foo")
})
uncacheableStatusCodes := []int{
// Informational responses
fiber.StatusContinue,
fiber.StatusSwitchingProtocols,
fiber.StatusProcessing,
fiber.StatusEarlyHints,
// Successful responses
fiber.StatusCreated,
fiber.StatusAccepted,
fiber.StatusResetContent,
fiber.StatusMultiStatus,
fiber.StatusAlreadyReported,
fiber.StatusIMUsed,
// Redirection responses
fiber.StatusFound,
fiber.StatusSeeOther,
fiber.StatusNotModified,
fiber.StatusUseProxy,
fiber.StatusSwitchProxy,
fiber.StatusTemporaryRedirect,
// Client error responses
fiber.StatusBadRequest,
fiber.StatusUnauthorized,
fiber.StatusPaymentRequired,
fiber.StatusForbidden,
fiber.StatusNotAcceptable,
fiber.StatusProxyAuthRequired,
fiber.StatusRequestTimeout,
fiber.StatusConflict,
fiber.StatusLengthRequired,
fiber.StatusPreconditionFailed,
fiber.StatusRequestEntityTooLarge,
fiber.StatusUnsupportedMediaType,
fiber.StatusRequestedRangeNotSatisfiable,
fiber.StatusExpectationFailed,
fiber.StatusMisdirectedRequest,
fiber.StatusUnprocessableEntity,
fiber.StatusLocked,
fiber.StatusFailedDependency,
fiber.StatusTooEarly,
fiber.StatusUpgradeRequired,
fiber.StatusPreconditionRequired,
fiber.StatusTooManyRequests,
fiber.StatusRequestHeaderFieldsTooLarge,
fiber.StatusTeapot,
fiber.StatusUnavailableForLegalReasons,
// Server error responses
fiber.StatusInternalServerError,
fiber.StatusBadGateway,
fiber.StatusServiceUnavailable,
fiber.StatusGatewayTimeout,
fiber.StatusHTTPVersionNotSupported,
fiber.StatusVariantAlsoNegotiates,
fiber.StatusInsufficientStorage,
fiber.StatusLoopDetected,
fiber.StatusNotExtended,
fiber.StatusNetworkAuthenticationRequired,
}
for _, v := range uncacheableStatusCodes {
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, fmt.Sprintf("/%d", v), http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, v, resp.StatusCode)
}
}
func TestCacheAgeHeader(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
app.Get("/", func(c fiber.Ctx) error { return c.SendString("ok") })
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, "0", resp.Header.Get(fiber.HeaderAge))
time.Sleep(4 * time.Second)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
age, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
require.Positive(t, age)
}
func TestCacheUpstreamAge(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 3 * time.Second}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderAge, "5")
return c.SendString("hi")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, "5", resp.Header.Get(fiber.HeaderAge))
time.Sleep(1500 * time.Millisecond)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, "5", resp.Header.Get(fiber.HeaderAge))
}
func Test_CacheRequestMaxAgeRevalidates(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 30 * time.Second,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|req-max-age-zero"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=30")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "max-age=0")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CacheExpiresFutureAllowsCaching(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
StoreResponseHeaders: true,
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderExpires, time.Now().Add(30*time.Second).UTC().Format(http.TimeFormat))
return c.SendString("expires" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "expires1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "expires1", string(body))
}
func Test_CacheExpiresPastPreventsCaching(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderExpires, time.Now().Add(-1*time.Minute).UTC().Format(http.TimeFormat))
return c.SendString("expires" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "expires1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "expires2", string(body))
}
func Test_CacheAllowsSharedCacheMustRevalidateWithAuthorization(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 30 * time.Second,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|must-revalidate-auth"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "must-revalidate, max-age=60")
return c.SendString("auth" + strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "auth1", string(body))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "auth1", string(body))
}
func Test_CacheAllowsSharedCacheProxyRevalidateWithAuthorization(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 30 * time.Second,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|proxy-revalidate-auth"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "proxy-revalidate, max-age=60")
return c.SendString("proxy" + strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "proxy1", string(body))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "proxy1", string(body))
}
func Test_CacheInvalidExpiresStoredAsStale(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
app := fiber.New()
app.Use(New(Config{
Expiration: 30 * time.Second,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|invalid-expires"
},
Storage: storage,
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public")
c.Set(fiber.HeaderExpires, "invalid-date")
return c.SendString("body" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body1", string(body))
expectedKey := "GET|/|invalid-expires"
require.Contains(t, storage.data, expectedKey)
require.Contains(t, storage.data, expectedKey+"_body")
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body2", string(body))
require.Contains(t, storage.data, expectedKey)
require.Contains(t, storage.data, expectedKey+"_body")
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body3", string(body))
require.Contains(t, storage.data, expectedKey)
require.Contains(t, storage.data, expectedKey+"_body")
}
func Test_CacheSMaxAgeOverridesMaxAgeWhenShorter(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=10, s-maxage=1")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
time.Sleep(1700 * time.Millisecond)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CacheSMaxAgeOverridesMaxAgeWhenLonger(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=1, s-maxage=2")
return c.SendString(strconv.Itoa(count))
})
for time.Now().Nanosecond() >= int(100*time.Millisecond) {
time.Sleep(10 * time.Millisecond)
}
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
time.Sleep(1200 * time.Millisecond)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
time.Sleep(1700 * time.Millisecond)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CacheOnlyIfCachedMiss(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("ok")
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "only-if-cached")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, fiber.StatusGatewayTimeout, resp.StatusCode)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, 0, count)
}
func Test_CacheOnlyIfCachedStaleNotServed(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=1")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
time.Sleep(1500 * time.Millisecond)
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "only-if-cached")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, fiber.StatusGatewayTimeout, resp.StatusCode)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
}
func Test_CacheMaxStaleServesStaleResponse(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=2")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
time.Sleep(2500 * time.Millisecond)
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "max-stale=5")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equalf(t, cacheHit, resp.Header.Get("X-Cache"), "dirs=%+v Age=%s count=%d", parseRequestCacheControlString("max-stale=5"), resp.Header.Get(fiber.HeaderAge), count)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
require.Equal(t, 1, count)
}
func Test_CacheMaxStaleRespectsMustRevalidate(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=1, must-revalidate")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
time.Sleep(1500 * time.Millisecond)
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "max-stale=30")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
require.Equal(t, 2, count)
}
func Test_CacheMaxStaleRespectsProxyRevalidateSharedAuth(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "s-maxage=1, proxy-revalidate")
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer abc")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
time.Sleep(1500 * time.Millisecond)
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer abc")
req.Header.Set(fiber.HeaderCacheControl, "max-stale=30")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
require.Equal(t, 2, count)
}
func Test_CachePreservesCacheControlHeaders(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
expires := time.Now().Add(10 * time.Second).UTC().Format(http.TimeFormat)
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=5, immutable")
c.Set(fiber.HeaderExpires, expires)
c.Set(fiber.HeaderETag, `W/"abc"`)
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, "public, max-age=5, immutable", resp.Header.Get(fiber.HeaderCacheControl))
require.Equal(t, expires, resp.Header.Get(fiber.HeaderExpires))
require.Equal(t, `W/"abc"`, resp.Header.Get(fiber.HeaderETag))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, "public, max-age=5, immutable", resp.Header.Get(fiber.HeaderCacheControl))
require.Equal(t, expires, resp.Header.Get(fiber.HeaderExpires))
require.Equal(t, `W/"abc"`, resp.Header.Get(fiber.HeaderETag))
}
func setResponseDate(date time.Time) fiber.Handler {
return func(c fiber.Ctx) error {
if err := c.Next(); err != nil {
return err
}
c.Response().Header.Set(fiber.HeaderDate, date.UTC().Format(http.TimeFormat))
return nil
}
}
func Test_CacheDateAndAgeHandling(t *testing.T) {
t.Parallel()
type testCase struct {
name string
cacheControl string
cacheHeader string
dateOffset time.Duration
expiration time.Duration
expectAgeAtLeast int
expectCount int
originAge int
}
cases := []testCase{
{
name: "age derived from past date without Age header",
dateOffset: -1 * time.Minute,
cacheControl: "public, max-age=120",
cacheHeader: cacheHit,
expiration: 5 * time.Minute,
expectAgeAtLeast: 1,
expectCount: 1,
},
{
name: "stale due to past date despite max-age",
dateOffset: -90 * time.Second,
cacheControl: "public, max-age=30",
cacheHeader: cacheUnreachable,
expiration: 5 * time.Minute,
expectCount: 2,
originAge: 90,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: tc.expiration}))
app.Use(setResponseDate(time.Now().Add(tc.dateOffset).UTC()))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
if tc.originAge > 0 {
c.Response().Header.Set(fiber.HeaderAge, strconv.Itoa(tc.originAge))
}
c.Set(fiber.HeaderCacheControl, tc.cacheControl)
return c.SendString(strconv.Itoa(count))
})
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
if tc.cacheHeader == cacheHit {
time.Sleep(2 * time.Second)
}
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, tc.cacheHeader, resp.Header.Get("X-Cache"))
if tc.cacheHeader == cacheHit {
ageVal, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
require.GreaterOrEqual(t, ageVal, tc.expectAgeAtLeast)
require.Equal(t, 1, count)
} else {
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, strconv.Itoa(tc.expectCount), string(body))
require.Equal(t, tc.expectCount, count)
}
})
}
}
func Test_CacheClampsInvalidStoredDate(t *testing.T) {
t.Parallel()
storage := newMutatingStorage(func(key string, val []byte) []byte {
if strings.HasSuffix(key, "_body") {
return val
}
var it item
if _, err := it.UnmarshalMsg(val); err != nil {
return val
}
it.date = uint64(math.MaxInt64) + 1024
updated, err := it.MarshalMsg(nil)
if err != nil {
return val
}
return updated
})
app := fiber.New()
app.Use(New(Config{
Expiration: time.Minute,
Storage: storage,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
parsedDate, err := http.ParseTime(resp.Header.Get(fiber.HeaderDate))
require.NoError(t, err)
require.WithinDuration(t, time.Now(), parsedDate, time.Minute)
ageVal, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
require.Less(t, ageVal, 60)
require.GreaterOrEqual(t, ageVal, 0)
}
func Test_CacheClampsFutureStoredDate(t *testing.T) {
t.Parallel()
storage := newMutatingStorage(func(key string, val []byte) []byte {
if strings.HasSuffix(key, "_body") {
return val
}
var it item
if _, err := it.UnmarshalMsg(val); err != nil {
return val
}
future := time.Now().Add(2 * time.Second).UTC()
sec := future.Unix()
if sec < 0 {
sec = 0
}
it.date = uint64(sec) //nolint:gosec // safe: sec is clamped to non-negative range
updated, err := it.MarshalMsg(nil)
if err != nil {
return val
}
return updated
})
app := fiber.New()
app.Use(New(Config{
Expiration: time.Minute,
Storage: storage,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
parsedDate, err := http.ParseTime(resp.Header.Get(fiber.HeaderDate))
require.NoError(t, err)
require.False(t, parsedDate.After(time.Now()))
ageVal, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
require.GreaterOrEqual(t, ageVal, 0)
}
func Test_RequestPragmaNoCacheTriggersMiss(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: time.Minute,
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("body" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body1", string(body))
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderPragma, "no-cache")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body2", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body2", string(body))
}
func Test_CacheStaleResponseAddsWarning110(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 2 * time.Second,
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=1")
return c.SendString("body" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "max-stale=5")
// Wait for the cached response to become stale (max-age=1)
// Add extra time to ensure the entry has expired
time.Sleep(1200 * time.Millisecond)
deadline := time.Now().Add(3 * time.Second)
for {
resp, err = app.Test(req)
require.NoError(t, err)
if resp.Header.Get("X-Cache") == cacheHit {
ageVal, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
if ageVal >= 1 {
// Check that Warning header is present before breaking
warnings := resp.Header.Values(fiber.HeaderWarning)
if len(warnings) > 0 {
break
}
}
}
require.True(t, time.Now().Before(deadline), "response did not become stale before deadline")
time.Sleep(50 * time.Millisecond)
}
warnings := resp.Header.Values(fiber.HeaderWarning)
require.NotEmpty(t, warnings, "Warning header should be present when serving stale response")
found := false
for _, w := range warnings {
if strings.Contains(w, "110") {
found = true
break
}
}
require.True(t, found, "warning 110 not found in %v", warnings)
}
func Test_CacheHeuristicFreshnessAddsWarning113(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 2 * time.Second,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("body")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
for _, w := range resp.Header.Values(fiber.HeaderWarning) {
require.NotContains(t, w, "113", "warning 113 should not be present for explicitly fresh responses")
}
}
func Test_CacheHeuristicFreshnessAddsWarning113AfterThreshold(t *testing.T) {
t.Parallel()
storage := newMutatingStorage(func(key string, val []byte) []byte {
if strings.HasSuffix(key, "_body") {
return val
}
var it item
if _, err := it.UnmarshalMsg(val); err != nil {
return val
}
oldDate := time.Now().Add(-25 * time.Hour).UTC()
sec := oldDate.Unix()
if sec < 0 {
sec = 0
}
it.date = uint64(sec) //nolint:gosec // safe: sec is clamped to non-negative range
future := time.Now().Add(48 * time.Hour).UTC()
expSec := future.Unix()
if expSec < 0 {
expSec = 0
}
it.exp = uint64(expSec) //nolint:gosec // safe: expSec is clamped to non-negative range
it.ttl = uint64((48 * time.Hour) / time.Second)
updated, err := it.MarshalMsg(nil)
if err != nil {
return val
}
return updated
})
app := fiber.New()
app.Use(New(Config{
Expiration: 2 * time.Second,
Storage: storage,
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("body" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
warnings := resp.Header.Values(fiber.HeaderWarning)
require.NotEmpty(t, warnings)
found := false
for _, w := range warnings {
if strings.Contains(w, "113") {
found = true
break
}
}
require.True(t, found, "warning 113 not found in %v", warnings)
}
func Test_CacheAgeHeaderIsCappedAtMaxDeltaSeconds(t *testing.T) {
t.Parallel()
const veryLargeAge = uint64(math.MaxInt32) + 1000
storage := newMutatingStorage(func(key string, val []byte) []byte {
if strings.HasSuffix(key, "_body") {
return val
}
var it item
if _, err := it.UnmarshalMsg(val); err != nil {
return val
}
it.age = veryLargeAge
updated, err := it.MarshalMsg(nil)
if err != nil {
return val
}
return updated
})
app := fiber.New()
app.Use(New(Config{
Expiration: time.Minute,
Storage: storage,
}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("body")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
ageVal, err := strconv.Atoi(resp.Header.Get(fiber.HeaderAge))
require.NoError(t, err)
require.Equal(t, math.MaxInt32, ageVal)
}
func Test_CacheMinFreshForcesRevalidation(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=5")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "min-fresh=10")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equalf(t, cacheMiss, resp.Header.Get("X-Cache"), "dirs=%+v Age=%s count=%d", parseRequestCacheControlString("min-fresh=10"), resp.Header.Get(fiber.HeaderAge), count)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CachePermanentRedirectCached(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 30 * time.Second,
StoreResponseHeaders: true,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|status-308"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=30")
c.Set(fiber.HeaderLocation, "/dest")
return c.Status(fiber.StatusPermanentRedirect).SendString("redir" + strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusPermanentRedirect, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "redir1", string(body))
require.Equal(t, "/dest", resp.Header.Get(fiber.HeaderLocation))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, fiber.StatusPermanentRedirect, resp.StatusCode)
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "redir1", string(body))
require.Equal(t, "/dest", resp.Header.Get(fiber.HeaderLocation))
}
func Test_CacheNoStoreDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "no-store")
return c.SendString("ok")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
}
func Test_CacheNoCacheDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "no-cache")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CacheNoCacheDirectiveOverridesExistingEntry(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var noCacheMode atomic.Bool
app.Get("/", func(c fiber.Ctx) error {
if noCacheMode.Load() {
c.Set(fiber.HeaderCacheControl, "no-cache")
return c.SendString("no-cache")
}
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("cacheable")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "cacheable", string(body))
noCacheMode.Store(true)
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "no-cache")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "no-cache", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "no-cache", string(body))
}
func Test_CacheRespectsUpstreamAgeForFreshness(t *testing.T) {
t.Parallel()
t.Run("skipsCachingWhenAgeExhaustsFreshness", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|age-exhausted"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=2")
c.Set(fiber.HeaderAge, "2")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
})
t.Run("expiresAfterRemainingLifetime", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|age-remaining"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=2")
c.Set(fiber.HeaderAge, "1")
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
time.Sleep(1500 * time.Millisecond)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
})
}
func Test_CacheVarySeparatesVariants(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|vary-separated"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderVary, fiber.HeaderAcceptLanguage)
return c.SendString(c.Get(fiber.HeaderAcceptLanguage) + strconv.Itoa(count))
})
reqEN := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqEN.Header.Set(fiber.HeaderAcceptLanguage, "en")
resp, err := app.Test(reqEN)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "en1", string(body))
reqFR := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqFR.Header.Set(fiber.HeaderAcceptLanguage, "fr")
resp, err = app.Test(reqFR)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "fr2", string(body))
reqENRepeat := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqENRepeat.Header.Set(fiber.HeaderAcceptLanguage, "en")
resp, err = app.Test(reqENRepeat)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "en1", string(body))
reqFRRepeat := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
reqFRRepeat.Header.Set(fiber.HeaderAcceptLanguage, "fr")
resp, err = app.Test(reqFRRepeat)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "fr2", string(body))
}
func Test_CacheVaryStarUncacheable(t *testing.T) {
t.Parallel()
for _, disableVaryHeaders := range []bool{false, true} {
t.Run(fmt.Sprintf("DisableVaryHeaders=%t", disableVaryHeaders), func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
DisableVaryHeaders: disableVaryHeaders,
KeyGenerator: func(c fiber.Ctx) string {
return c.Path() + "|vary-star"
},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderVary, "*")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
})
}
}
func Test_CachePrivateDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "private")
return c.SendString(strconv.Itoa(count))
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CachePrivateDirectiveWithAuthorization(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "private")
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func Test_CachePrivateDirectiveInvalidatesExistingEntry(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var privateMode atomic.Bool
app.Get("/", func(c fiber.Ctx) error {
if privateMode.Load() {
c.Set(fiber.HeaderCacheControl, "private")
return c.SendString("private")
}
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("public")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "public", string(body))
privateMode.Store(true)
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderCacheControl, "no-cache")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "private", string(body))
privateMode.Store(false)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "public", string(body))
}
func Test_CacheControlNotOverwritten(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second, StoreResponseHeaders: true}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "private")
return c.SendString("ok")
})
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, "private", resp.Header.Get(fiber.HeaderCacheControl))
}
func Test_CacheMaxAgeDirective(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
app.Get("/", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "max-age=1")
return c.SendString("1")
})
_, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
time.Sleep(1500 * time.Millisecond)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
}
func Test_ParseMaxAge(t *testing.T) {
t.Parallel()
tests := []struct {
header string
expect time.Duration
ok bool
}{
{"max-age=60", 60 * time.Second, true},
{"public, max-age=86400", 86400 * time.Second, true},
{"no-store", 0, false},
{"max-age=invalid", 0, false},
{"public, s-maxage=100, max-age=50", 50 * time.Second, true},
{"MAX-AGE=20", 20 * time.Second, true},
{"public , max-age=0", 0, true},
{"public , max-age", 0, false},
}
for _, tt := range tests {
t.Run(tt.header, func(t *testing.T) {
t.Parallel()
d, ok := parseMaxAge(tt.header)
if tt.ok != ok {
t.Fatalf("expected ok=%v got %v", tt.ok, ok)
}
if ok && d != tt.expect {
t.Fatalf("expected %v got %v", tt.expect, d)
}
})
}
}
func Test_AllowsSharedCache(t *testing.T) {
t.Parallel()
tests := []struct {
directives string
expect bool
}{
{"public", true},
{"private", false},
{"s-maxage=60", true},
{"public, max-age=60", true},
{"public, must-revalidate", true},
{"max-age=60", false},
{"no-cache", false},
{"no-cache, s-maxage=60", true},
{"", false},
}
for _, tt := range tests {
t.Run(tt.directives, func(t *testing.T) {
t.Parallel()
got := allowsSharedCache(tt.directives)
require.Equal(t, tt.expect, got, "directives: %q", tt.directives)
})
}
t.Run("private overrules public", func(t *testing.T) {
t.Parallel()
got := allowsSharedCache(strings.ToUpper("private, public"))
require.False(t, got)
})
}
func TestCacheSkipsAuthorizationByDefault(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
}
func TestCacheBypassesExistingEntryForAuthorization(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New())
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(strconv.Itoa(count))
})
nonAuthReq := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
resp, err := app.Test(nonAuthReq)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
authReq := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
authReq.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(authReq)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "2", string(body))
resp, err = app.Test(nonAuthReq)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "1", string(body))
}
func TestCacheAllowsSharedCacheWithAuthorization(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString("ok")
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
req = httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "ok", string(body))
require.Equal(t, 1, count)
}
func TestCacheAllowsAuthorizationWithRevalidateDirectives(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cacheControl string
expires string
expectedBody string
expectedBody2 string
expectFirst string
expectSecond string
}{
{
name: "must-revalidate",
cacheControl: "must-revalidate, max-age=60",
expectedBody: "ok-1",
expectedBody2: "ok-1",
expectFirst: cacheMiss,
expectSecond: cacheHit,
},
{
name: "proxy-revalidate",
cacheControl: "proxy-revalidate, max-age=60",
expectedBody: "ok-1",
expectedBody2: "ok-1",
expectFirst: cacheMiss,
expectSecond: cacheHit,
},
{
name: "expires header",
cacheControl: "",
expires: time.Now().Add(1 * time.Minute).UTC().Format(http.TimeFormat),
expectedBody: "ok-1",
expectedBody2: "ok-2",
expectFirst: cacheUnreachable,
expectSecond: cacheUnreachable,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, tt.cacheControl)
if tt.expires != "" {
c.Set(fiber.HeaderExpires, tt.expires)
}
return c.SendString(fmt.Sprintf("ok-%d", count))
})
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer token")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, tt.expectFirst, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, tt.expectedBody, string(body))
resp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, tt.expectSecond, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, tt.expectedBody2, string(body))
if tt.expectSecond == cacheHit {
require.Equal(t, 1, count)
} else {
require.Equal(t, 2, count)
}
})
}
}
func TestCacheSeparatesAuthorizationValues(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 10 * time.Second}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
c.Set(fiber.HeaderCacheControl, "public, max-age=60")
return c.SendString(fmt.Sprintf("body-%d-%s", count, c.Get(fiber.HeaderAuthorization)))
})
newRequest := func(token string) *http.Request {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set(fiber.HeaderAuthorization, "Bearer "+token)
return req
}
authTokenA := "token-a"
authTokenB := "token-b"
resp, err := app.Test(newRequest(authTokenA))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body-1-Bearer "+authTokenA, string(body))
resp, err = app.Test(newRequest(authTokenA))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body-1-Bearer "+authTokenA, string(body))
require.Equal(t, 1, count)
resp, err = app.Test(newRequest(authTokenB))
require.NoError(t, err)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body-2-Bearer "+authTokenB, string(body))
require.Equal(t, 2, count)
resp, err = app.Test(newRequest(authTokenB))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body-2-Bearer "+authTokenB, string(body))
resp, err = app.Test(newRequest(authTokenA))
require.NoError(t, err)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
body, err = io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "body-1-Bearer "+authTokenA, string(body))
require.Equal(t, 2, count)
}
// go test -v -run=^$ -bench=Benchmark_Cache -benchmem -count=4
func Benchmark_Cache(b *testing.B) {
app := fiber.New()
app.Use(New())
app.Get("/demo", func(c fiber.Ctx) error {
data, _ := os.ReadFile("../../.github/README.md") //nolint:errcheck // We're inside a benchmark
return c.Status(fiber.StatusTeapot).Send(data)
})
h := app.Handler()
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(fiber.MethodGet)
fctx.Request.SetRequestURI("/demo")
b.ReportAllocs()
for b.Loop() {
h(fctx)
}
require.Equal(b, fiber.StatusTeapot, fctx.Response.Header.StatusCode())
require.Greater(b, len(fctx.Response.Body()), 30000)
}
func Benchmark_Cache_Miss(b *testing.B) {
app := fiber.New()
app.Use(New())
app.Get("/*", func(c fiber.Ctx) error {
data, _ := os.ReadFile("../../.github/README.md") //nolint:errcheck // We're inside a benchmark
return c.Status(fiber.StatusOK).Send(data)
})
h := app.Handler()
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(fiber.MethodGet)
b.ReportAllocs()
b.ResetTimer()
var n int
for b.Loop() {
n++
fctx.Request.SetRequestURI("/demo/" + strconv.Itoa(n))
h(fctx)
}
require.Equal(b, fiber.StatusOK, fctx.Response.Header.StatusCode())
require.Greater(b, len(fctx.Response.Body()), 30000)
}
// go test -v -run=^$ -bench=Benchmark_Cache_Storage -benchmem -count=4
func Benchmark_Cache_Storage(b *testing.B) {
app := fiber.New()
app.Use(New(Config{
Storage: memory.New(),
}))
app.Get("/demo", func(c fiber.Ctx) error {
data, _ := os.ReadFile("../../.github/README.md") //nolint:errcheck // We're inside a benchmark
return c.Status(fiber.StatusTeapot).Send(data)
})
h := app.Handler()
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(fiber.MethodGet)
fctx.Request.SetRequestURI("/demo")
b.ReportAllocs()
for b.Loop() {
h(fctx)
}
require.Equal(b, fiber.StatusTeapot, fctx.Response.Header.StatusCode())
require.Greater(b, len(fctx.Response.Body()), 30000)
}
func Benchmark_Cache_AdditionalHeaders(b *testing.B) {
app := fiber.New()
app.Use(New(Config{
StoreResponseHeaders: true,
}))
app.Get("/demo", func(c fiber.Ctx) error {
c.Response().Header.Add("X-Foobar", "foobar")
return c.SendStatus(418)
})
h := app.Handler()
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(fiber.MethodGet)
fctx.Request.SetRequestURI("/demo")
b.ReportAllocs()
for b.Loop() {
h(fctx)
}
require.Equal(b, fiber.StatusTeapot, fctx.Response.Header.StatusCode())
require.Equal(b, []byte("foobar"), fctx.Response.Header.Peek("X-Foobar"))
}
func Benchmark_Cache_MaxSize(b *testing.B) {
// The benchmark is run with three different MaxSize parameters
// 1) 0: Tracking is disabled = no overhead
// 2) MaxInt32: Enough to store all entries = no removals
// 3) 100: Small size = constant insertions and removals
cases := []uint{0, math.MaxUint32, 100}
names := []string{"Disabled", "Unlim", "LowBounded"}
for i, size := range cases {
b.Run(names[i], func(b *testing.B) {
app := fiber.New()
app.Use(New(Config{MaxBytes: size}))
app.Get("/*", func(c fiber.Ctx) error {
return c.Status(fiber.StatusTeapot).SendString("1")
})
h := app.Handler()
fctx := &fasthttp.RequestCtx{}
fctx.Request.Header.SetMethod(fiber.MethodGet)
b.ReportAllocs()
n := 0
for b.Loop() {
n++
fctx.Request.SetRequestURI(fmt.Sprintf("/%v", n))
h(fctx)
}
require.Equal(b, fiber.StatusTeapot, fctx.Response.Header.StatusCode())
})
}
}
func Test_Cache_RevalidationWithMaxBytes(t *testing.T) {
t.Parallel()
t.Run("max-age=0 revalidation removes old entry on storage success", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 100,
}))
requestCount := 0
app.Get("/test", func(c fiber.Ctx) error {
requestCount++
c.Set(fiber.HeaderCacheControl, "max-age=60")
return c.SendString(fmt.Sprintf("response-%d", requestCount))
})
// First request - cache the response
req1 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp1, err := app.Test(req1)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp1.Header.Get("X-Cache"))
// Request with max-age=0 to force revalidation
req2 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req2.Header.Set(fiber.HeaderCacheControl, "max-age=0")
resp2, err := app.Test(req2)
require.NoError(t, err)
body2, err := io.ReadAll(resp2.Body)
require.NoError(t, err)
require.Equal(t, "response-2", string(body2))
// Next request should serve the NEW cached entry
req3 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp3, err := app.Test(req3)
require.NoError(t, err)
require.Equal(t, cacheHit, resp3.Header.Get("X-Cache"))
body3, err := io.ReadAll(resp3.Body)
require.NoError(t, err)
require.Equal(t, "response-2", string(body3), "New entry should be cached")
})
t.Run("min-fresh revalidation with MaxBytes", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 100,
}))
requestCount := 0
app.Get("/test", func(c fiber.Ctx) error {
requestCount++
c.Set(fiber.HeaderCacheControl, "max-age=2")
return c.SendString(fmt.Sprintf("response-%d", requestCount))
})
// First request - cache the response
req1 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp1, err := app.Test(req1)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp1.Header.Get("X-Cache"))
// Wait a bit so the entry has aged
time.Sleep(1 * time.Second)
// Request with min-fresh that exceeds remaining freshness
req2 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req2.Header.Set(fiber.HeaderCacheControl, "min-fresh=5")
resp2, err := app.Test(req2)
require.NoError(t, err)
body2, err := io.ReadAll(resp2.Body)
require.NoError(t, err)
require.Equal(t, "response-2", string(body2))
// Next request should serve the NEW cached entry
req3 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp3, err := app.Test(req3)
require.NoError(t, err)
require.Equal(t, cacheHit, resp3.Header.Get("X-Cache"))
body3, err := io.ReadAll(resp3.Body)
require.NoError(t, err)
require.Equal(t, "response-2", string(body3))
})
t.Run("revalidation respects MaxBytes eviction", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 20, // Only room for 2 responses of 10 bytes each
ExpirationGenerator: stableAscendingExpiration(),
}))
app.Get("/*", func(c fiber.Ctx) error {
c.Set(fiber.HeaderCacheControl, "max-age=60")
return c.SendString("1234567890") // 10 bytes
})
// Cache /a and /b
req1 := httptest.NewRequest(fiber.MethodGet, "/a", http.NoBody)
resp1, err := app.Test(req1)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp1.Header.Get("X-Cache"))
req2 := httptest.NewRequest(fiber.MethodGet, "/b", http.NoBody)
resp2, err := app.Test(req2)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp2.Header.Get("X-Cache"))
// Both should be cached
req3 := httptest.NewRequest(fiber.MethodGet, "/a", http.NoBody)
resp3, err := app.Test(req3)
require.NoError(t, err)
require.Equal(t, cacheHit, resp3.Header.Get("X-Cache"))
req4 := httptest.NewRequest(fiber.MethodGet, "/b", http.NoBody)
resp4, err := app.Test(req4)
require.NoError(t, err)
require.Equal(t, cacheHit, resp4.Header.Get("X-Cache"))
// Revalidate /a with max-age=0
req5 := httptest.NewRequest(fiber.MethodGet, "/a", http.NoBody)
req5.Header.Set(fiber.HeaderCacheControl, "max-age=0")
_, err = app.Test(req5)
require.NoError(t, err)
// /a should be revalidated and cached again
req6 := httptest.NewRequest(fiber.MethodGet, "/a", http.NoBody)
resp6, err := app.Test(req6)
require.NoError(t, err)
require.Equal(t, cacheHit, resp6.Header.Get("X-Cache"))
// /b should still be cached (heap accounting should be correct)
req7 := httptest.NewRequest(fiber.MethodGet, "/b", http.NoBody)
resp7, err := app.Test(req7)
require.NoError(t, err)
require.Equal(t, cacheHit, resp7.Header.Get("X-Cache"))
})
t.Run("revalidation with non-cacheable response preserves old entry", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 100,
}))
requestCount := 0
app.Get("/test", func(c fiber.Ctx) error {
requestCount++
if requestCount == 1 {
c.Set(fiber.HeaderCacheControl, "max-age=60")
return c.SendString("cacheable")
}
// Second request returns no-store
c.Set(fiber.HeaderCacheControl, "no-store")
return c.SendString("not-cacheable")
})
// First request - cache the response
req1 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp1, err := app.Test(req1)
require.NoError(t, err)
require.Equal(t, cacheMiss, resp1.Header.Get("X-Cache"))
body1, err := io.ReadAll(resp1.Body)
require.NoError(t, err)
require.Equal(t, "cacheable", string(body1))
// Request with max-age=0 to force revalidation
// The new response will be no-store (not cacheable)
req2 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req2.Header.Set(fiber.HeaderCacheControl, "max-age=0")
resp2, err := app.Test(req2)
require.NoError(t, err)
body2, err := io.ReadAll(resp2.Body)
require.NoError(t, err)
require.Equal(t, "not-cacheable", string(body2))
// Next request should still serve the OLD cached entry
// because the new response was not cacheable and old entry should remain tracked
req3 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
resp3, err := app.Test(req3)
require.NoError(t, err)
require.Equal(t, cacheHit, resp3.Header.Get("X-Cache"))
body3, err := io.ReadAll(resp3.Body)
require.NoError(t, err)
require.Equal(t, "cacheable", string(body3), "Old entry should still be cached")
})
}
// Test_parseCacheControlDirectives_QuotedStrings tests RFC 9111 Section 5.2 compliance
// for quoted-string values in Cache-Control directives
func Test_parseCacheControlDirectives_QuotedStrings(t *testing.T) {
t.Parallel()
tests := []struct {
name string
expected map[string]string
input string
}{
{
name: "simple quoted value",
input: `community="UCI"`,
expected: map[string]string{
"community": "UCI",
},
},
{
name: "multiple directives with quoted values",
input: `max-age=3600, community="UCI", custom="value"`,
expected: map[string]string{
"max-age": "3600",
"community": "UCI",
"custom": "value",
},
},
{
name: "quoted value with spaces",
input: `custom="value with spaces"`,
expected: map[string]string{
"custom": "value with spaces",
},
},
{
name: "quoted value with escaped quote",
input: `custom="value with \"quotes\""`,
expected: map[string]string{
"custom": `value with "quotes"`,
},
},
{
name: "quoted value with escaped backslash",
input: `custom="value with \\ backslash"`,
expected: map[string]string{
"custom": `value with \ backslash`,
},
},
{
name: "mixed quoted and unquoted values",
input: `max-age=3600, community="UCI", no-cache, custom="test"`,
expected: map[string]string{
"max-age": "3600",
"community": "UCI",
"no-cache": "",
"custom": "test",
},
},
{
name: "quoted empty value",
input: `custom=""`,
expected: map[string]string{
"custom": "",
},
},
{
name: "spaces around quoted value",
input: `custom = "value" , another="test"`,
expected: map[string]string{
"custom": "value",
"another": "test",
},
},
{
name: "unquoted token value",
input: `max-age=3600`,
expected: map[string]string{
"max-age": "3600",
},
},
{
name: "complex mixed case",
input: `max-age=3600, s-maxage=7200, community="UCI", no-store, custom="value with \"escaped\" quotes"`,
expected: map[string]string{
"max-age": "3600",
"s-maxage": "7200",
"community": "UCI",
"no-store": "",
"custom": `value with "escaped" quotes`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := make(map[string]string)
parseCacheControlDirectives([]byte(tt.input), func(key, value []byte) {
result[string(key)] = string(value)
})
require.Equal(t, tt.expected, result)
})
}
}
// Test_unquoteCacheDirective tests the unquoting logic for quoted-string values
func Test_unquoteCacheDirective(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input []byte
expected []byte
}{
{
name: "simple quoted string",
input: []byte(`"value"`),
expected: []byte("value"),
},
{
name: "empty quoted string",
input: []byte(`""`),
expected: []byte(""),
},
{
name: "quoted string with spaces",
input: []byte(`"value with spaces"`),
expected: []byte("value with spaces"),
},
{
name: "quoted string with escaped quote",
input: []byte(`"value with \"quote\""`),
expected: []byte(`value with "quote"`),
},
{
name: "quoted string with escaped backslash",
input: []byte(`"value with \\ backslash"`),
expected: []byte(`value with \ backslash`),
},
{
name: "quoted string with multiple escapes",
input: []byte(`"a\"b\\c\"d"`),
expected: []byte(`a"b\c"d`),
},
{
name: "too short input",
input: []byte(`"`),
expected: []byte(`"`),
},
{
name: "empty input",
input: []byte(``),
expected: []byte(``),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := unquoteCacheDirective(tt.input)
require.Equal(t, tt.expected, result)
})
}
}
// Test_Cache_MaxBytes_InsufficientSpace tests the "insufficient space" error path
// when an entry is larger than MaxBytes, ensuring such entries are treated as unreachable
func Test_Cache_MaxBytes_InsufficientSpace(t *testing.T) {
t.Parallel()
t.Run("entry larger than MaxBytes with empty cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 10, // Very small cache
Expiration: 1 * time.Hour,
}))
app.Get("/large", func(c fiber.Ctx) error {
// Return data larger than MaxBytes
return c.Send(make([]byte, 20))
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/large", http.NoBody))
require.NoError(t, err)
// Should be unreachable because entry is too large
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("entry larger than MaxBytes after eviction", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 15,
ExpirationGenerator: stableAscendingExpiration(),
}))
app.Get("/*", func(c fiber.Ctx) error {
path := c.Path()
if path == "/small" {
return c.Send(make([]byte, 5))
}
return c.Send(make([]byte, 20))
})
// Cache a small entry first
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/small", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Try to cache a large entry - should return unreachable since it won't fit even after eviction
rsp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/large", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
}
func Test_Cache_MaxBytes_DeletionFailureRestoresTracking(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
app := fiber.New()
app.Use(New(Config{
MaxBytes: 4,
Expiration: 1 * time.Hour,
Storage: storage,
}))
app.Get("/:name", func(c fiber.Ctx) error {
return c.SendString("data")
})
// Seed the cache with a single entry
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/first", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
var storedKeys []string
storage.mu.Lock()
for key := range storage.data {
storedKeys = append(storedKeys, key)
if strings.Contains(key, "/first") {
storage.errs["del|"+key] = errors.New("delete failed")
}
}
storage.mu.Unlock()
t.Logf("stored keys after first cache: %v", storedKeys)
// Next request triggers eviction; deletion failure should surface an error
rsp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/second", http.NoBody))
require.NoError(t, err)
body, err := io.ReadAll(rsp.Body)
require.NoError(t, err)
require.Equal(t, fiber.StatusInternalServerError, rsp.StatusCode)
require.Contains(t, string(body), "failed to delete key")
require.NoError(t, rsp.Body.Close())
var remainingKeys []string
storage.mu.RLock()
for key := range storage.data {
remainingKeys = append(remainingKeys, key)
}
storage.mu.RUnlock()
t.Logf("stored keys after deletion failure: %v", remainingKeys)
storage.mu.Lock()
storage.errs = make(map[string]error)
storage.mu.Unlock()
// Another request should succeed and be cacheable after restoring heap tracking
rsp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/third", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
require.NoError(t, rsp.Body.Close())
}
// Test_Cache_MaxBytes_ConcurrencyAndRaceConditions tests that the race condition fix works correctly
// under concurrent load, verifying that storedBytes never exceeds MaxBytes even with multiple
// goroutines making simultaneous requests
func Test_Cache_MaxBytes_ConcurrencyAndRaceConditions(t *testing.T) {
t.Parallel()
t.Run("concurrent requests with MaxBytes limit", func(t *testing.T) {
t.Parallel()
app := fiber.New()
const maxBytes = uint(1000)
const numGoroutines = 20
const requestsPerGoroutine = 5
app.Use(New(Config{
MaxBytes: maxBytes,
Expiration: 10 * time.Second,
}))
app.Get("/*", func(c fiber.Ctx) error {
// Return data that will fill up the cache
return c.Send(make([]byte, 50))
})
// Launch multiple goroutines making concurrent requests
var wg sync.WaitGroup
errChan := make(chan error, numGoroutines*requestsPerGoroutine)
for i := 0; i < numGoroutines; i++ {
id := i
wg.Add(1) //nolint:revive // Standard WaitGroup pattern is appropriate here
go func() {
defer wg.Done()
for j := 0; j < requestsPerGoroutine; j++ {
path := fmt.Sprintf("/test-%d-%d", id, j)
req := httptest.NewRequest(fiber.MethodGet, path, http.NoBody)
_, err := app.Test(req)
if err != nil {
errChan <- err
}
}
}()
}
wg.Wait()
close(errChan)
// Check for errors
for err := range errChan {
require.NoError(t, err, "concurrent request failed")
}
// The test passes if no errors occurred and no race conditions were detected by -race flag
})
t.Run("concurrent requests near capacity triggers eviction", func(t *testing.T) {
t.Parallel()
app := fiber.New()
const maxBytes = uint(200)
const numRequests = 10
app.Use(New(Config{
MaxBytes: maxBytes,
Expiration: 10 * time.Second,
}))
app.Get("/*", func(c fiber.Ctx) error {
// Each response is about 50 bytes, so we'll exceed capacity
return c.Send(make([]byte, 50))
})
// Make concurrent requests that will trigger evictions
var wg sync.WaitGroup
for i := 0; i < numRequests; i++ {
id := i
wg.Add(1) //nolint:revive // Standard WaitGroup pattern is appropriate here
go func() {
defer wg.Done()
path := fmt.Sprintf("/item-%d", id)
req := httptest.NewRequest(fiber.MethodGet, path, http.NoBody)
_, err := app.Test(req)
if err != nil {
t.Logf("request error: %v", err)
}
}()
}
wg.Wait()
// Test passes if no race conditions or panics occurred
// The -race flag will detect any remaining race conditions
})
}
// Test_Cache_HelperFunctions tests various helper functions for better coverage
func Test_Cache_HelperFunctions(t *testing.T) {
t.Parallel()
t.Run("parseHTTPDate empty", func(t *testing.T) {
t.Parallel()
result, ok := parseHTTPDate([]byte{})
require.False(t, ok)
require.Equal(t, uint64(0), result)
})
t.Run("parseHTTPDate invalid", func(t *testing.T) {
t.Parallel()
result, ok := parseHTTPDate([]byte("invalid"))
require.False(t, ok)
require.Equal(t, uint64(0), result)
})
t.Run("parseHTTPDate valid", func(t *testing.T) {
t.Parallel()
result, ok := parseHTTPDate([]byte("Mon, 02 Jan 2006 15:04:05 GMT"))
require.True(t, ok)
require.Positive(t, result)
})
t.Run("safeUnixSeconds negative", func(t *testing.T) {
t.Parallel()
result := safeUnixSeconds(time.Unix(-1, 0))
require.Equal(t, uint64(0), result)
})
t.Run("safeUnixSeconds positive", func(t *testing.T) {
t.Parallel()
result := safeUnixSeconds(time.Unix(1234567890, 0))
require.Equal(t, uint64(1234567890), result)
})
t.Run("remainingFreshness nil", func(t *testing.T) {
t.Parallel()
result := remainingFreshness(nil, 100)
require.Equal(t, uint64(0), result)
})
t.Run("remainingFreshness zero exp", func(t *testing.T) {
t.Parallel()
e := &item{exp: 0}
result := remainingFreshness(e, 100)
require.Equal(t, uint64(0), result)
})
t.Run("remainingFreshness expired", func(t *testing.T) {
t.Parallel()
e := &item{exp: 100}
result := remainingFreshness(e, 200)
require.Equal(t, uint64(0), result)
})
t.Run("remainingFreshness valid", func(t *testing.T) {
t.Parallel()
e := &item{exp: 200}
result := remainingFreshness(e, 100)
require.Equal(t, uint64(100), result)
})
t.Run("lookupCachedHeader not found", func(t *testing.T) {
t.Parallel()
headers := []cachedHeader{{key: []byte("Content-Type"), value: []byte("text/html")}}
value, found := lookupCachedHeader(headers, "Authorization")
require.False(t, found)
require.Nil(t, value)
})
t.Run("lookupCachedHeader case insensitive", func(t *testing.T) {
t.Parallel()
headers := []cachedHeader{{key: []byte("Authorization"), value: []byte("Bearer token")}}
value, found := lookupCachedHeader(headers, "authorization")
require.True(t, found)
require.Equal(t, []byte("Bearer token"), value)
})
t.Run("secondsToDuration zero", func(t *testing.T) {
t.Parallel()
result := secondsToDuration(0)
require.Equal(t, time.Duration(0), result)
})
t.Run("secondsToDuration large", func(t *testing.T) {
t.Parallel()
result := secondsToDuration(9223372036)
require.Greater(t, result, time.Duration(0))
})
t.Run("secondsToTime zero", func(t *testing.T) {
t.Parallel()
result := secondsToTime(0)
require.Equal(t, time.Unix(0, 0).UTC(), result)
})
t.Run("secondsToTime value", func(t *testing.T) {
t.Parallel()
result := secondsToTime(1234567890)
require.Equal(t, time.Unix(1234567890, 0).UTC(), result)
})
t.Run("isHeuristicFreshness short age", func(t *testing.T) {
t.Parallel()
cfg := &Config{Expiration: 1 * time.Hour}
e := &item{cacheControl: []byte("public")}
result := isHeuristicFreshness(e, cfg, 3600)
require.False(t, result)
})
t.Run("isHeuristicFreshness with expires", func(t *testing.T) {
t.Parallel()
cfg := &Config{Expiration: 1 * time.Hour}
e := &item{cacheControl: []byte("public"), expires: []byte("Wed, 21 Oct 2015 07:28:00 GMT")}
result := isHeuristicFreshness(e, cfg, uint64(25*time.Hour/time.Second))
require.False(t, result)
})
t.Run("isHeuristicFreshness true", func(t *testing.T) {
t.Parallel()
cfg := &Config{Expiration: 1 * time.Hour}
e := &item{cacheControl: []byte("public")}
result := isHeuristicFreshness(e, cfg, uint64(25*time.Hour/time.Second))
require.True(t, result)
})
t.Run("cacheBodyFetchError miss", func(t *testing.T) {
t.Parallel()
mask := func(_ string) string { return "***" }
err := cacheBodyFetchError(mask, "key", errCacheMiss)
require.Error(t, err)
require.Contains(t, err.Error(), "no cached body")
})
t.Run("cacheBodyFetchError other", func(t *testing.T) {
t.Parallel()
mask := func(_ string) string { return "***" }
originalErr := errors.New("storage error")
err := cacheBodyFetchError(mask, "key", originalErr)
require.Equal(t, originalErr, err)
})
}
// Test_Cache_VaryAndAuth tests vary and auth functionality
func Test_Cache_VaryAndAuth(t *testing.T) {
t.Parallel()
t.Run("storeVaryManifest failure", func(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
storage.errs["set|manifest"] = errors.New("storage fail")
manager := &manager{storage: storage}
err := storeVaryManifest(context.Background(), manager, "manifest", []string{"Accept"}, 3600*time.Second)
require.Error(t, err)
})
t.Run("loadVaryManifest not found", func(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
manager := &manager{storage: storage}
varyNames, found, err := loadVaryManifest(context.Background(), manager, "nonexistent")
require.NoError(t, err)
require.False(t, found)
require.Nil(t, varyNames)
})
t.Run("vary with multiple headers", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Vary", "Accept, Accept-Encoding")
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
req2 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req2.Header.Set("Accept", "application/json")
req2.Header.Set("Accept-Encoding", "gzip")
rsp2, err := app.Test(req2)
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("auth with must-revalidate", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "must-revalidate, max-age=3600")
return c.SendString("content")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer token1")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
req2 := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req2.Header.Set("Authorization", "Bearer token1")
rsp2, err := app.Test(req2)
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
}
// Test_Cache_DateAndCacheControl tests date parsing and cache control
func Test_Cache_DateAndCacheControl(t *testing.T) {
t.Parallel()
t.Run("date header parsing", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Date", "Mon, 02 Jan 2006 15:04:05 GMT")
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("invalid date header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Date", "invalid")
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("cache control with quoted values", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", `max-age=3600, ext="value, with, commas"`)
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("cache control with spaces", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600 , public , must-revalidate")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
}
// Test_Cache_CacheControlCombinations tests common cache control directive combinations
func Test_Cache_CacheControlCombinations(t *testing.T) {
t.Parallel()
t.Run("max-age with public", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public, max-age=3600")
return c.SendString("public content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("max-age with private", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "private, max-age=3600")
return c.SendString("private content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("s-maxage overrides max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public, max-age=60, s-maxage=3600")
return c.SendString("content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("no-store prevents caching", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "no-store")
return c.SendString("no store content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp2.Header.Get("X-Cache"))
})
t.Run("no-cache with etag", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "no-cache")
c.Response().Header.Set("ETag", `"123456"`)
return c.SendString("no-cache content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("must-revalidate with max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "must-revalidate, max-age=3600")
return c.SendString("must revalidate content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("proxy-revalidate with max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public, proxy-revalidate, max-age=3600")
return c.SendString("proxy revalidate content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("immutable with max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public, max-age=31536000, immutable")
return c.SendString("immutable content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("max-age=0 with must-revalidate", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=0, must-revalidate")
return c.SendString("always revalidate")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("public with no explicit max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public")
return c.SendString("public no max-age")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("multiple cache directives with extensions", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", `public, max-age=3600, custom="value"`)
return c.SendString("content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("private overrides public", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "public, private, max-age=3600")
return c.SendString("conflicting directives")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("stale-while-revalidate with max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=60, stale-while-revalidate=120")
return c.SendString("stale while revalidate")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("stale-if-error with max-age", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=60, stale-if-error=3600")
return c.SendString("stale if error")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
}
// Test_Cache_RequestResponseDirectives tests caching behavior with various request/response cache-control directives
func Test_Cache_RequestResponseDirectives(t *testing.T) {
t.Parallel()
t.Run("negative expiration skips caching", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: -1 * time.Second}))
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.NotEqual(t, cacheMiss, rsp.Header.Get("X-Cache"))
require.NotEqual(t, cacheHit, rsp.Header.Get("X-Cache"))
})
t.Run("request with no-store directive", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "no-store")
rsp, err := app.Test(req)
require.NoError(t, err)
require.NotEqual(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("request with pragma no-cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Pragma", "no-cache")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("non-get-head method bypasses cache", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Post("/test", func(c fiber.Ctx) error {
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("request with min-fresh directive", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=60")
return c.SendString("test")
})
// First request to cache
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Second request with min-fresh that's too high
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "min-fresh=120")
rsp, err = app.Test(req)
require.NoError(t, err)
// Should be a miss because min-fresh requirement not met
cacheStatus := rsp.Header.Get("X-Cache")
require.Contains(t, []string{cacheMiss, cacheUnreachable}, cacheStatus, "min-fresh requirement should prevent cache hit")
})
t.Run("request with max-age=0 directive", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
// First request to cache
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Second request with max-age=0
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "max-age=0")
rsp, err = app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("request with max-stale directive", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Second}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=1")
return c.SendString("test")
})
// First request to cache
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Wait for it to become stale
time.Sleep(2 * time.Second)
// Request with max-stale to accept stale content
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "max-stale=60")
rsp, err = app.Test(req)
require.NoError(t, err)
// max-stale should allow serving stale content
cacheStatus := rsp.Header.Get("X-Cache")
// Should be either a hit (if stale is served) or miss (if revalidated)
require.Contains(t, []string{cacheHit, cacheMiss, "stale"}, cacheStatus, "max-stale should allow stale content or revalidate")
})
t.Run("response with expires header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
futureTime := time.Now().Add(1 * time.Hour).UTC().Format(http.TimeFormat)
c.Response().Header.Set("Expires", futureTime)
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("response with age header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
c.Response().Header.Set("Age", "30")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("custom key generator", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyGenerator: func(c fiber.Ctx) string {
return "custom-" + c.Path()
},
}))
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("response with warning header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Second}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=1")
return c.SendString("test")
})
// Cache the response
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Wait for it to become stale
time.Sleep(2 * time.Second)
// Request again - should get stale warning or revalidate
rsp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
// Check that either cache miss (revalidation) or warning header is present
cacheStatus := rsp.Header.Get("X-Cache")
warningHeader := rsp.Header.Get("Warning")
require.True(t, cacheStatus == cacheMiss || warningHeader != "", "stale response should either revalidate or have warning header")
})
t.Run("external storage with body key", func(t *testing.T) {
t.Parallel()
storage := newFailingCacheStorage()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
Storage: storage,
}))
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("test content")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Verify body key is stored
hasBodyKey := false
storage.mu.RLock()
for k := range storage.data {
if strings.Contains(k, "_body") {
hasBodyKey = true
break
}
}
storage.mu.RUnlock()
require.True(t, hasBodyKey)
})
t.Run("only-if-cached with cache miss", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "only-if-cached")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, fiber.StatusGatewayTimeout, rsp.StatusCode)
})
t.Run("only-if-cached with cache hit", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
// First request to cache
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Second request with only-if-cached
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "only-if-cached")
rsp2, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("cache control with uppercase directives", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "PUBLIC, MAX-AGE=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
}
// Test_Cache_ConfigurationAndResponseHandling tests cache behavior for specific configuration and response edge cases.
func Test_Cache_ConfigurationAndResponseHandling(t *testing.T) {
t.Parallel()
t.Run("response with Vary star prevents caching", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Vary", "*")
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("next function prevents caching", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
Next: func(c fiber.Ctx) bool {
return c.Path() == "/skip"
},
}))
app.Get("/skip", func(c fiber.Ctx) error {
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/skip", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("non-cacheable status code", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
return c.Status(fiber.StatusCreated).SendString("created")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("body larger than MaxBytes", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
MaxBytes: 10,
}))
app.Get("/test", func(c fiber.Ctx) error {
return c.Send(make([]byte, 100))
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("authorization without shared cache directives", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Authorization", "******")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp.Header.Get("X-Cache"))
})
t.Run("disable cache control header generation", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
DisableCacheControl: true,
}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
})
t.Run("disable value redaction", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
DisableValueRedaction: true,
}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("response with ETag header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
c.Response().Header.Set("ETag", `"abc123"`)
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
require.Equal(t, `"abc123"`, rsp2.Header.Get("ETag"))
})
t.Run("response with Content-Encoding header", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
c.Response().Header.Set("Content-Encoding", "gzip")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
require.Equal(t, "gzip", rsp2.Header.Get("Content-Encoding"))
})
t.Run("response with custom headers preserved", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
StoreResponseHeaders: true,
}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
c.Response().Header.Set("X-Custom-Header", "custom-value")
return c.SendString("test")
})
rsp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheHit, rsp2.Header.Get("X-Cache"))
require.Equal(t, "custom-value", rsp2.Header.Get("X-Custom-Header"))
})
t.Run("revalidation scenario with cache miss", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
// Request with no-cache forces revalidation
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Cache-Control", "no-cache")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
})
t.Run("delete vary manifest on no-cache response", func(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
// First request creates vary manifest
if c.Query("first") != "" {
c.Response().Header.Set("Vary", "Accept")
c.Response().Header.Set("Cache-Control", "max-age=3600")
} else {
// Second request returns no-cache to delete manifest
c.Response().Header.Set("Cache-Control", "no-cache")
}
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test?first=true", http.NoBody)
req.Header.Set("Accept", "application/json")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Second request without Vary should delete manifest
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheUnreachable, rsp2.Header.Get("X-Cache"))
})
t.Run("vary manifest deletion on different vary response", func(t *testing.T) {
t.Parallel()
app := fiber.New()
var counter atomic.Int32
app.Use(New(Config{Expiration: 1 * time.Hour}))
app.Get("/test", func(c fiber.Ctx) error {
if counter.Add(1) == 1 {
c.Response().Header.Set("Vary", "Accept")
}
// Second response has no Vary header - should delete manifest
c.Response().Header.Set("Cache-Control", "max-age=3600")
return c.SendString("test")
})
req := httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody)
req.Header.Set("Accept", "application/json")
rsp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp.Header.Get("X-Cache"))
// Second request - different vary behavior
rsp2, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/test", http.NoBody))
require.NoError(t, err)
require.Equal(t, cacheMiss, rsp2.Header.Get("X-Cache"))
})
}
// Test_hasDirective covers RFC 9111 §5.2 directive parsing including
// space, tab, and '=' as valid terminators (fixes #4143).
func Test_hasDirective(t *testing.T) {
t.Parallel()
tests := []struct {
name string
cc string
directive string
want bool
}{
// Basic matches
{"exact match", "no-cache", "no-cache", true},
{"comma separated", "public, no-cache, max-age=0", "no-cache", true},
{"at start", "no-cache, max-age=0", "no-cache", true},
{"at end", "public, no-cache", "no-cache", true},
{"not present", "public, max-age=0", "no-cache", false},
{"shorter token does not match", "no-catch", "no-cache", false},
{"substring of longer token", "no-cache-extended", "no-cache", false},
// Trailing whitespace (#4143)
{"trailing space", "no-cache ", "no-cache", true},
{"trailing tab", "no-cache\t", "no-cache", true},
{"private trailing space", "private ", "private", true},
// Directive with value (#4143)
{"directive with equals", `no-cache="Set-Cookie"`, "no-cache", true},
{"max-age with value", "max-age=3600", "max-age", true},
{"s-maxage with value in list", "public, s-maxage=600, max-age=3600", "s-maxage", true},
// Tab as separator before directive
{"tab before directive", "public,\tno-cache", "no-cache", true},
// Case insensitive
{"case insensitive", "No-Cache", "no-cache", true},
// Empty / edge cases
{"empty header", "", "no-cache", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := hasDirective(tc.cc, tc.directive)
require.Equal(t, tc.want, got)
})
}
}