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