Files
summercms/modules/cooler/install_test.go
Jakub Zych 78ba385793 test(19.3-05): cooler caches, rate-limit parity, Install and after-commit purge
- miniredis v2.40.0 as a test-only dependency (approved at the Task 4 check)
- memory and Redis cache suites, Flush limited to <prefix>cache:
- one parity table through surf.MemoryStore, miniredis and real Redis
- Install, FromApp and site_cache validation; Postgres after-commit purge
- gate: cooler names in the go and docker stages
2026-10-15 19:49:34 +02:00

305 lines
10 KiB
Go

package cooler_test
import (
"context"
"log/slog"
"strings"
"sync"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/alicebob/miniredis/v2"
)
// appWithLogger returns an app on cfg whose published logger writes to the
// returned buffer.
func appWithLogger(t *testing.T, cfg map[string]any) (*backpack.App, *lockedBuffer) {
t.Helper()
c := emptyConfig(t)
for k, v := range cfg {
mustSet(t, c, k, v)
}
app := backpack.New(c)
buf := &lockedBuffer{}
if err := app.Publish(slog.New(slog.NewTextHandler(buf, nil))); err != nil {
t.Fatal(err)
}
return app, buf
}
func TestInstallConfigErrors(t *testing.T) {
const secretURL = "redis://alice:s3cretpw@cache.example.test:6379/0\x7f"
cases := []struct {
name string
cfg map[string]any
names []string // substrings the error must contain
}{
{"unknown cache driver", map[string]any{cooler.CacheDriverKey: "memcached"}, []string{cooler.CacheDriverKey, "memcached"}},
{"unknown rate-limit store", map[string]any{cooler.RateLimitStoreKey: "database"}, []string{cooler.RateLimitStoreKey, "database"}},
{"redis store without redis.url", map[string]any{cooler.RateLimitStoreKey: "redis"}, []string{cooler.RedisURLKey, cooler.RateLimitStoreKey}},
{"redis cache without redis.url", map[string]any{cooler.CacheDriverKey: "redis"}, []string{cooler.RedisURLKey, cooler.CacheDriverKey}},
{"a malformed redis.url", map[string]any{cooler.RateLimitStoreKey: "redis", cooler.RedisURLKey: secretURL}, []string{cooler.RedisURLKey}},
{"an unsupported scheme", map[string]any{cooler.CacheDriverKey: "redis", cooler.RedisURLKey: "http://cache.example.test"}, []string{cooler.RedisURLKey}},
{"max_entries 0", map[string]any{cooler.CacheMaxEntriesKey: 0}, []string{cooler.CacheMaxEntriesKey}},
{"max_entries not a number", map[string]any{cooler.CacheMaxEntriesKey: "lots"}, []string{cooler.CacheMaxEntriesKey}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
app, _ := appWithLogger(t, tc.cfg)
h, err := cooler.Install(context.Background(), app)
if err == nil {
_ = h.Close()
t.Fatal("Install succeeded, want a boot error")
}
for _, want := range tc.names {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not name %q", err, want)
}
}
if strings.Contains(err.Error(), "alice") || strings.Contains(err.Error(), "s3cretpw") {
t.Fatalf("error %q leaks the URL's credentials", err)
}
if c, ok := app.Lookup[cooler.Cache](); ok && c != nil {
t.Fatal("a failed Install published a Cache")
}
})
}
if _, err := cooler.Install(context.Background(), nil); err == nil {
t.Fatal("Install(nil app) must fail")
}
if _, err := cooler.FromApp(nil); err == nil {
t.Fatal("FromApp(nil app) must fail")
}
app, _ := appWithLogger(t, map[string]any{cooler.CacheDriverKey: "file"})
if _, err := cooler.FromApp(app); err == nil || !strings.Contains(err.Error(), cooler.CacheDriverKey) {
t.Fatalf("FromApp with an unknown driver = %v", err)
}
}
func TestInstallPingWarning(t *testing.T) {
addr := deadAddr(t)
app, logs := appWithLogger(t, map[string]any{
cooler.RateLimitStoreKey: "redis",
cooler.RedisURLKey: "redis://alice:s3cretpw@" + addr + "/0",
})
start := time.Now()
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatalf("an unreachable Redis must not fail Install: %v", err)
}
t.Cleanup(func() { _ = h.Close() })
if elapsed := time.Since(start); elapsed > cooler.PingTimeout+time.Second {
t.Fatalf("Install took %s, want at most about %s", elapsed, cooler.PingTimeout)
}
lines := logs.lines()
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], "endpoint="+addr) {
t.Fatalf("log = %q, want one warning naming the endpoint", lines)
}
if strings.Contains(lines[0], "alice") || strings.Contains(lines[0], "s3cretpw") || strings.Contains(lines[0], "redis://") {
t.Fatalf("warning leaks the URL: %s", lines[0])
}
if !h.RateLimitRedis() {
t.Fatal("the Redis store must stay selected so it recovers when Redis answers")
}
// The cache stays the memory default.
if v, err := h.Cache().Remember(context.Background(), "k", time.Minute, func(context.Context) ([]byte, error) { return []byte("v"), nil }); err != nil || string(v) != "v" {
t.Fatalf("Remember = %q %v", v, err)
}
if err := h.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := h.Close(); err != nil {
t.Fatalf("a second Close: %v", err)
}
if h.RateLimitRedis() {
t.Fatal("RateLimitRedis after Close")
}
var nilHandle *cooler.Handle
if nilHandle.Close() != nil || nilHandle.Cache() != nil || nilHandle.RateLimitRedis() {
t.Fatal("a nil Handle must be inert")
}
}
func TestInstallReusesFromApp(t *testing.T) {
t.Run("memory: FromApp's Cache survives Install", func(t *testing.T) {
app, _ := appWithLogger(t, nil)
first, err := cooler.FromApp(app)
if err != nil {
t.Fatal(err)
}
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
if h.Cache() != first {
t.Fatal("Install replaced the Cache FromApp published")
}
if again, _ := cooler.FromApp(app); again != first {
t.Fatal("FromApp built a second Cache")
}
// With the memory store, RateLimitStore hands back the fallback itself.
fallback := surf.NewMemoryStore(0)
if h.RateLimitRedis() || h.RateLimitStore(fallback) != cooler.Attempter(fallback) {
t.Fatal("the memory store must return the fallback unchanged")
}
})
t.Run("redis: Install publishes one Redis cache that FromApp returns", func(t *testing.T) {
mr := miniredis.RunT(t)
app, _ := appWithLogger(t, map[string]any{
cooler.CacheDriverKey: "redis",
cooler.CachePrefixKey: "acme:",
cooler.RedisURLKey: "redis://" + mr.Addr() + "/0",
})
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
c, err := cooler.FromApp(app)
if err != nil || c != h.Cache() {
t.Fatalf("FromApp = %v %v, want Install's Cache", c, err)
}
if err := c.Set(context.Background(), "k", []byte("v"), 0); err != nil {
t.Fatal(err)
}
if !mr.Exists("acme:cache:k") {
t.Fatalf("keys = %q, want acme:cache:k", mr.Keys())
}
})
t.Run("concurrent FromApp calls share one Cache", func(t *testing.T) {
app, _ := appWithLogger(t, nil)
var wg sync.WaitGroup
got := make([]cooler.Cache, 20)
for i := range got {
wg.Add(1)
go func() {
defer wg.Done()
got[i], _ = cooler.FromApp(app)
}()
}
wg.Wait()
for i, c := range got {
if c == nil || c != got[0] {
t.Fatalf("call %d returned %v, want the shared Cache", i, c)
}
}
})
}
func TestFromAppLazy(t *testing.T) {
mr := miniredis.RunT(t)
app, _ := appWithLogger(t, map[string]any{
cooler.CacheDriverKey: "redis",
cooler.RedisURLKey: "redis://" + mr.Addr() + "/0",
})
c, err := cooler.FromApp(app)
if err != nil {
t.Fatal(err)
}
if n := mr.TotalConnectionCount(); n != 0 {
t.Fatalf("FromApp opened %d connections, want none before the first command", n)
}
if err := c.Set(context.Background(), "k", []byte("v"), 0); err != nil {
t.Fatal(err)
}
if n := mr.TotalConnectionCount(); n == 0 {
t.Fatal("the first command did not dial")
}
// A Redis that does not exist still builds at once.
dead, _ := appWithLogger(t, map[string]any{
cooler.CacheDriverKey: "redis",
cooler.RedisURLKey: "redis://" + deadAddr(t) + "/0",
})
start := time.Now()
if _, err := cooler.FromApp(dead); err != nil {
t.Fatalf("FromApp with an unreachable Redis: %v", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("FromApp took %s; it must not dial", elapsed)
}
}
func TestInstallSiteCacheValidation(t *testing.T) {
const token = "install-site-cache-token-0123456789abcd"
t.Run("a bad site_cache configuration is a boot error", func(t *testing.T) {
for _, cfg := range []map[string]any{
{cooler.SiteCachePurgeURLKey: "http://127.0.0.1:3000/purge"},
{cooler.SiteCachePurgeTokenKey: token},
{cooler.SiteCachePurgeURLKey: "http://example.test/purge", cooler.SiteCachePurgeTokenKey: token},
{cooler.SiteCachePurgeURLKey: "http://127.0.0.1/purge", cooler.SiteCachePurgeTokenKey: "short"},
} {
app, _ := appWithLogger(t, cfg)
h, err := cooler.Install(context.Background(), app)
if err == nil {
_ = h.Close()
t.Fatalf("Install(%v) succeeded, want a boot error", cfg)
}
if !strings.Contains(err.Error(), "site_cache.") || strings.Contains(err.Error(), token) {
t.Fatalf("error %q must name a site_cache key and never the token", err)
}
if p, ok := app.Lookup[*cooler.SiteCachePurger](); ok && p != nil {
t.Fatal("a failed Install published a purger")
}
}
})
t.Run("a valid configuration is published and logged by endpoint", func(t *testing.T) {
app, logs := appWithLogger(t, map[string]any{
cooler.SiteCachePurgeURLKey: "http://127.0.0.1:3000/api/__site__/cache/purge",
cooler.SiteCachePurgeTokenKey: token,
})
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
p := cooler.SiteCacheFromApp(app)
if p == nil || p.Endpoint() != "127.0.0.1:3000/api/__site__/cache/purge" {
t.Fatalf("published purger = %v", p)
}
lines := logs.lines()
if len(lines) != 1 || !strings.Contains(lines[0], "site cache purge enabled") || strings.Contains(lines[0], token) {
t.Fatalf("log = %q", lines)
}
})
t.Run("a published alias purger is reused, even with bad framework keys", func(t *testing.T) {
app, _ := appWithLogger(t, map[string]any{cooler.SiteCachePurgeURLKey: "ftp://nowhere"})
alias, err := cooler.NewSiteCachePurgerFor("acme.purge_url", "acme.purge_token", "http://127.0.0.1:4000/purge", token)
if err != nil {
t.Fatal(err)
}
if err := app.Publish(alias); err != nil {
t.Fatal(err)
}
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatalf("Install with a published alias purger: %v", err)
}
t.Cleanup(func() { _ = h.Close() })
if got := cooler.SiteCacheFromApp(app); got != alias {
t.Fatal("Install replaced the alias purger")
}
})
t.Run("no keys: no purger", func(t *testing.T) {
app, _ := appWithLogger(t, nil)
h, err := cooler.Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = h.Close() })
if p := cooler.SiteCacheFromApp(app); p != nil {
t.Fatalf("SiteCacheFromApp = %v, want nil", p)
}
})
}