Files
summercms/modules/surf/store_lookup_test.go
Jakub Zych fa4d89d0b9 feat(19.3-01): share rate limits across instances through cooler's Redis store
- new module cooler: Install, Handle, OpenRedis (no URL in errors), RateLimitStore
  (atomic fixed-window Lua script with MemoryStore semantics, per-instance fallback)
- serve installs cooler and publishes the Redis surf.Store before BuildRouter;
  BuildRouter looks up a published Store, route:list keeps the memory store
- config keys http.rate_limit.store and redis.url; go-redis v9.23.0
- cooler README, root table row, surf README and rate-limiting, configuration
  and scaling docs
2026-10-15 17:35:05 +02:00

152 lines
4.2 KiB
Go

package surf
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// Smoke tests only; plan 05 of phase 19.3 owns full coverage.
const storeHTTPYAML = `body_limits:
default_bytes: 1048576
upload_bytes: 1048576
rate_limit:
store: redis
`
// redisStoreApp builds an app whose config selects the Redis rate-limit store
// at redisURL.
func redisStoreApp(t *testing.T, redisURL string) *backpack.App {
t.Helper()
dir := t.TempDir()
files := map[string]string{
"app.yaml": "name: store-test\n",
"http.yaml": storeHTTPYAML,
"redis.yaml": fmt.Sprintf("url: %q\n", redisURL),
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
type countingStore struct{ n atomic.Int64 }
func (s *countingStore) Attempt(_ string, max int, decay time.Duration) (bool, int, time.Duration) {
n := int(s.n.Add(1))
return n <= max, n, decay
}
func TestBuildRouterStoreSmoke(t *testing.T) {
// Redis selected but never installed (route:list's path): BuildRouter
// must not dial and keeps the in-process store.
app := redisStoreApp(t, "redis://127.0.0.1:1")
start := time.Now()
r, err := BuildRouter(app, nil)
if err != nil {
t.Fatal(err)
}
if d := time.Since(start); d > time.Second {
t.Fatalf("BuildRouter took %s; it must not dial Redis", d)
}
if _, ok := r.limiter.store.(*MemoryStore); !ok {
t.Fatalf("limiter store = %T, want *MemoryStore", r.limiter.store)
}
// A published Store is what the throttle counts in.
app = redisStoreApp(t, "redis://127.0.0.1:1")
fake := &countingStore{}
if err := app.Publish[Store](fake); err != nil {
t.Fatal(err)
}
p := assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}
h, err := Assemble(app, []party.Plugin{p})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil))
if rec.Code != http.StatusOK || fake.n.Load() != 1 {
t.Fatalf("status %d, fake attempts %d; want 200 through the published store", rec.Code, fake.n.Load())
}
}
func TestRateLimitAcrossInstancesSmoke(t *testing.T) {
if testing.Short() {
t.Skip("requires testcontainers redis")
}
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
defer cancel()
ctr, err := testcontainers.Run(ctx, "redis:7-alpine",
testcontainers.WithExposedPorts("6379/tcp"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")),
)
if err != nil {
t.Fatalf("redis: testcontainers: %v", err)
}
t.Cleanup(func() { _ = testcontainers.TerminateContainer(ctr) })
host, err := ctr.Host(ctx)
if err != nil {
t.Fatal(err)
}
port, err := ctr.MappedPort(ctx, "6379/tcp")
if err != nil {
t.Fatal(err)
}
url := fmt.Sprintf("redis://%s:%s/0", host, port.Port())
instance := func() http.Handler {
app := redisStoreApp(t, url)
hd, err := installCooler(ctx, app)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hd.Close() })
p := assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}
h, err := Assemble(app, []party.Plugin{p})
if err != nil {
t.Fatal(err)
}
return h
}
a, b := instance(), instance()
get := func(h http.Handler) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
req.RemoteAddr = "192.0.2.10:4000"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for i, tc := range []struct {
h http.Handler
want int
}{{a, http.StatusOK}, {b, http.StatusOK}, {a, http.StatusTooManyRequests}} {
rec := get(tc.h)
if rec.Code != tc.want {
t.Fatalf("request %d: status %d, want %d", i+1, rec.Code, tc.want)
}
if tc.want == http.StatusTooManyRequests && rec.Header().Get("Retry-After") == "" {
t.Fatal("429 without Retry-After")
}
}
}