Files
multica/server/internal/handler/webhook_rate_limiter_test.go
Multica Eve e07b5403ab MUL-4502: make autopilot webhook admission durable (#5386)
* fix(autopilots): make webhook admission durable

Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): address webhook delivery review

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-14 16:20:33 +08:00

154 lines
5.0 KiB
Go

package handler
import (
"context"
"strings"
"testing"
"time"
)
func TestMemoryWebhookRateLimiter_AllowsBelowLimit(t *testing.T) {
l := NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 3, Window: time.Minute})
ctx := context.Background()
for i := 0; i < 3; i++ {
if !l.Allow(ctx, "tok") {
t.Fatalf("request %d should be allowed", i)
}
}
}
func TestMemoryWebhookRateLimiter_RejectsAboveLimit(t *testing.T) {
l := NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 2, Window: time.Minute})
ctx := context.Background()
if !l.Allow(ctx, "tok") {
t.Fatal("first should pass")
}
if !l.Allow(ctx, "tok") {
t.Fatal("second should pass")
}
if l.Allow(ctx, "tok") {
t.Fatal("third should be rejected")
}
// Different key must have its own budget.
if !l.Allow(ctx, "other") {
t.Fatal("different key should pass")
}
}
func TestMemoryWebhookRateLimiter_WindowExpiry(t *testing.T) {
l := NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 1, Window: 10 * time.Millisecond})
ctx := context.Background()
if !l.Allow(ctx, "tok") {
t.Fatal("first should pass")
}
if l.Allow(ctx, "tok") {
t.Fatal("second should fail (within window)")
}
time.Sleep(20 * time.Millisecond)
if !l.Allow(ctx, "tok") {
t.Fatal("third should pass after window")
}
}
func TestMemoryWebhookRateLimiter_ZeroLimitDisabled(t *testing.T) {
l := NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 0, Window: time.Minute})
ctx := context.Background()
for i := 0; i < 100; i++ {
if !l.Allow(ctx, "tok") {
t.Fatalf("Limit=0 should be unbounded, rejected at %d", i)
}
}
}
func TestMemoryWebhookRateLimiter_CheckDoesNotConsumeBudget(t *testing.T) {
l := NewMemoryWebhookRateLimiter(WebhookRateLimit{Limit: 1, Window: time.Minute})
ctx := context.Background()
if !webhookLimiterCheck(ctx, l, "shared-ip") || !webhookLimiterCheck(ctx, l, "shared-ip") {
t.Fatal("non-consuming checks should remain allowed before bad debt is charged")
}
if !l.Allow(ctx, "shared-ip") {
t.Fatal("check unexpectedly consumed the only budget slot")
}
if webhookLimiterCheck(ctx, l, "shared-ip") {
t.Fatal("check should reject after bad debt reaches the limit")
}
if retry := webhookLimiterRetryAfter(ctx, l, "shared-ip"); retry <= 0 || retry > time.Minute {
t.Fatalf("unexpected retry interval: %v", retry)
}
}
// TestWebhookLimiterLuaScript_StructureGuard pins the Lua script so any future
// edit that reorders trim/count/insert (or drops EXPIRE) is caught even when
// no live Redis is available to the test process. The per-pod race the script
// guards against ("two pods both see count = limit-1 and both insert") only
// triggers if the three calls run atomically; this regression guard is the
// cheap-but-effective complement to the live-Redis test below.
func TestWebhookLimiterLuaScript_StructureGuard(t *testing.T) {
// redis.NewScript stores the body, so we just sanity-check ordering by
// matching the source string. The exact whitespace is brittle but the
// alternative (Lua AST parsing) is overkill for a 10-line script.
src := webhookLimiterAllowSource()
mustBefore := func(a, b string) {
t.Helper()
ia, ib := strings.Index(src, a), strings.Index(src, b)
if ia < 0 || ib < 0 {
t.Fatalf("script must contain %q and %q, src=%s", a, b, src)
}
if ia >= ib {
t.Fatalf("expected %q to appear before %q, src=%s", a, b, src)
}
}
mustBefore("ZREMRANGEBYSCORE", "ZCARD")
mustBefore("ZCARD", "ZADD")
mustBefore("ZADD", "EXPIRE")
}
func TestRedisWebhookRateLimiter_RejectsAboveLimit(t *testing.T) {
rdb := newRedisTestClient(t)
defer rdb.Close()
l := NewRedisWebhookRateLimiter(rdb, WebhookRateLimit{Limit: 3, Window: time.Minute})
ctx := context.Background()
for i := 0; i < 3; i++ {
if !l.Allow(ctx, "tok") {
t.Fatalf("request %d should be allowed", i)
}
}
if l.Allow(ctx, "tok") {
t.Fatal("fourth request should be rejected")
}
// Different key has its own budget.
if !l.Allow(ctx, "other") {
t.Fatal("different key should pass")
}
}
func TestRedisWebhookIPRateLimiter_HasSeparateBudgetFromTokenLimiter(t *testing.T) {
// Per-IP and per-token use the SAME Lua script but DIFFERENT key
// prefixes. Exhausting one budget must not affect the other —
// regression-protect by exhausting per-token then proving per-IP is
// untouched against the same Redis instance.
rdb := newRedisTestClient(t)
defer rdb.Close()
tok := NewRedisWebhookRateLimiter(rdb, WebhookRateLimit{Limit: 1, Window: time.Minute})
ip := NewRedisWebhookIPRateLimiter(rdb, WebhookRateLimit{Limit: 2, Window: time.Minute})
ctx := context.Background()
if !tok.Allow(ctx, "alice") {
t.Fatal("token limiter first request should pass")
}
if tok.Allow(ctx, "alice") {
t.Fatal("token limiter second request should be rejected")
}
// IP limiter must still have its full budget.
if !ip.Allow(ctx, "alice") {
t.Fatal("IP limiter first request should pass")
}
if !ip.Allow(ctx, "alice") {
t.Fatal("IP limiter second request should pass")
}
if ip.Allow(ctx, "alice") {
t.Fatal("IP limiter third request should be rejected")
}
}