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) } }) }