- 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
305 lines
10 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|