Skip to content

Commit 70053ba

Browse files
zeevdrclaude
andauthored
test(e2e): cover rate-limit enforcement (#282) (#327)
PR #267 wired per-tenant + per-method rate limiting on the gRPC server. Unit tests covered the interceptor and bucket-keying; nothing exercised the live wiring end-to-end. Add three e2e tests against the docker-compose service (defaults: RATE_LIMIT_BURST=10, authed=100rps): - TestRateLimit_ExhaustsAuthedBucket — hammer one method on one tenant with role=admin until ResourceExhausted; assert at least burst-1 successes preceded the trip. - TestRateLimit_PerTenantIsolation — exhaust tenant A; a single call from tenant B (separate bucket key) must succeed. - TestRateLimit_PerMethodIsolation — exhaust GetSchema for tenant A; a single call to ListTenants for the same tenant (separate method key) must not return ResourceExhausted. A small burstUntilExhausted helper centralises the "blast until trip" loop with a hard upper bound (burst*3) so a misconfigured server fails fast instead of looping forever. Closes #282 Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c76f4fe commit 70053ba

1 file changed

Lines changed: 103 additions & 0 deletions

File tree

e2e/ratelimit_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//go:build e2e
2+
3+
package e2e
4+
5+
import (
6+
"context"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"google.golang.org/grpc/codes"
12+
"google.golang.org/grpc/status"
13+
)
14+
15+
// Defaults from cmd/server/main.go: RATE_LIMIT_BURST=10, authed=100rps. The
16+
// docker-compose service in this repo does not override these, so the
17+
// authenticated bucket trips on the (burst+1)th rapid call from the same
18+
// tenant on the same method.
19+
const ratelimitBurst = 10
20+
21+
// burstUntilExhausted hammers fn until it returns ResourceExhausted, up to
22+
// burst*3 attempts. Returns the number of successful calls before the trip
23+
// and the final ResourceExhausted error. Fails the test if some other error
24+
// is returned or if the limiter never trips.
25+
func burstUntilExhausted(t *testing.T, fn func() error) (successes int, rlErr error) {
26+
t.Helper()
27+
for i := 0; i < ratelimitBurst*3; i++ {
28+
err := fn()
29+
if err == nil {
30+
successes++
31+
continue
32+
}
33+
if status.Code(err) == codes.ResourceExhausted {
34+
return successes, err
35+
}
36+
t.Fatalf("unexpected error at iteration %d (after %d successes): %v", i, successes, err)
37+
}
38+
t.Fatalf("rate limiter never tripped after %d successes", successes)
39+
return
40+
}
41+
42+
// TestRateLimit_ExhaustsAuthedBucket: an authenticated client hammering one
43+
// method on one tenant trips the limiter after ~burst calls.
44+
func TestRateLimit_ExhaustsAuthedBucket(t *testing.T) {
45+
fixture := bootstrapMatrixFixture(t, "rl-exhaust")
46+
conn := dial(t)
47+
c := scopedClients(t, conn, roleAdmin, fixture.tenantID)
48+
ctx := context.Background()
49+
50+
successes, err := burstUntilExhausted(t, func() error {
51+
_, e := c.admin.GetSchema(ctx, fixture.schemaID)
52+
return e
53+
})
54+
55+
assert.Equal(t, codes.ResourceExhausted, status.Code(err))
56+
// Burst is the upper bound on successes-before-trip; replenishment may
57+
// add a few more during the loop, but we should always see at least
58+
// burst-1 successes (allow a small slack for clock drift).
59+
assert.GreaterOrEqual(t, successes, ratelimitBurst-1, "should have allowed at least burst-1 requests through")
60+
}
61+
62+
// TestRateLimit_PerTenantIsolation: exhausting tenant A does not affect
63+
// tenant B — each authenticated bucket is keyed by tenant.
64+
func TestRateLimit_PerTenantIsolation(t *testing.T) {
65+
a := bootstrapMatrixFixture(t, "rl-tenant-a")
66+
b := bootstrapMatrixFixture(t, "rl-tenant-b")
67+
conn := dial(t)
68+
cA := scopedClients(t, conn, roleAdmin, a.tenantID)
69+
cB := scopedClients(t, conn, roleAdmin, b.tenantID)
70+
ctx := context.Background()
71+
72+
_, _ = burstUntilExhausted(t, func() error {
73+
_, e := cA.admin.GetSchema(ctx, a.schemaID)
74+
return e
75+
})
76+
77+
// Tenant B has its own bucket and must not be blocked.
78+
_, err := cB.admin.GetSchema(ctx, b.schemaID)
79+
require.NoError(t, err, "tenant B should not be affected by tenant A exhaustion")
80+
}
81+
82+
// TestRateLimit_PerMethodIsolation: exhausting one method on a tenant does
83+
// not affect a different method on the same tenant — buckets are keyed by
84+
// (tenant, method).
85+
func TestRateLimit_PerMethodIsolation(t *testing.T) {
86+
f := bootstrapMatrixFixture(t, "rl-method")
87+
conn := dial(t)
88+
c := scopedClients(t, conn, roleAdmin, f.tenantID)
89+
ctx := context.Background()
90+
91+
_, _ = burstUntilExhausted(t, func() error {
92+
_, e := c.admin.GetSchema(ctx, f.schemaID)
93+
return e
94+
})
95+
96+
// ListTenants is a different method — its bucket is independent. We
97+
// don't care whether the call returns NotFound / OK / something else;
98+
// only that ResourceExhausted is NOT returned.
99+
_, err := c.admin.ListTenants(ctx, f.schemaID)
100+
if err != nil && status.Code(err) == codes.ResourceExhausted {
101+
t.Fatalf("ListTenants was rate-limited despite being a different method bucket: %v", err)
102+
}
103+
}

0 commit comments

Comments
 (0)