package surf import ( "context" "fmt" "net" "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") } } } // Unit tests (plan 05 of phase 19.3) for BuildRouter's rate-limit store // lookup (D-09). func TestBuildRouterStoreFallback(t *testing.T) { r, err := BuildRouter(nil, nil) if err != nil { t.Fatal(err) } if _, ok := r.limiter.store.(*MemoryStore); !ok { t.Fatalf("nil app: store = %T", r.limiter.store) } app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}) r, err = BuildRouter(app, nil) if err != nil { t.Fatal(err) } mem, ok := r.limiter.store.(*MemoryStore) if !ok { t.Fatalf("no published store: store = %T", r.limiter.store) } // The fallback store counts: two attempts, max 1. if allowed, _, _ := mem.Attempt("k", 1, time.Minute); !allowed { t.Fatal("first attempt refused") } if allowed, _, _ := mem.Attempt("k", 1, time.Minute); allowed { t.Fatal("second attempt admitted") } } func TestBuildRouterUsesPublishedStore(t *testing.T) { app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits}) fake := &countingStore{} if err := app.Publish[Store](fake); err != nil { t.Fatal(err) } r, err := BuildRouter(app, []party.Plugin{assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:2,1"}}}) if err != nil { t.Fatal(err) } if r.limiter.store != Store(fake) { t.Fatalf("limiter store = %T, want the published store", r.limiter.store) } h, err := r.compile() if err != nil { t.Fatal(err) } for i, want := range []int{http.StatusOK, http.StatusOK, http.StatusTooManyRequests} { rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil)) if rec.Code != want { t.Fatalf("request %d: status %d, want %d", i+1, rec.Code, want) } } if fake.n.Load() != 3 { t.Fatalf("published store saw %d attempts, want 3", fake.n.Load()) } } func TestBuildRouterNeverDials(t *testing.T) { // A listener standing in for Redis counts every connection. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } var accepted atomic.Int64 go func() { for { c, err := ln.Accept() if err != nil { return } accepted.Add(1) _ = c.Close() } }() t.Cleanup(func() { _ = ln.Close() }) app := redisStoreApp(t, "redis://"+ln.Addr().String()+"/0") start := time.Now() r, err := BuildRouter(app, []party.Plugin{assemblePlugin{id: "golem15.demo", path: "/ping", use: []string{"throttle:5,1"}}}) if err != nil { t.Fatal(err) } h, err := r.compile() if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/ping", nil)) if rec.Code != http.StatusOK { t.Fatalf("status %d", rec.Code) } if d := time.Since(start); d > time.Second { t.Fatalf("BuildRouter and one request took %s", d) } if _, ok := r.limiter.store.(*MemoryStore); !ok { t.Fatalf("store = %T, want *MemoryStore without install", r.limiter.store) } time.Sleep(50 * time.Millisecond) if n := accepted.Load(); n != 0 { t.Fatalf("Redis was dialled %d times without installCooler", n) } }