mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* 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>
154 lines
5.0 KiB
Go
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")
|
|
}
|
|
}
|