calcomblock/ratelimit_test.go
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

86 lines
2.3 KiB
Go

package main
import (
"testing"
"time"
)
// TestRateLimiter_AllowsUpToCapAndBlocksRest verifies the WO-018 contract:
// 10 calls from the same IP succeed; the 11th is denied.
func TestRateLimiter_AllowsUpToCapAndBlocksRest(t *testing.T) {
rl := NewRateLimiter(10)
for i := 1; i <= 10; i++ {
if !rl.Allow("203.0.113.1") {
t.Fatalf("call %d unexpectedly denied", i)
}
}
if rl.Allow("203.0.113.1") {
t.Fatalf("11th call must be denied")
}
}
// TestRateLimiter_IsolatesByIP confirms that buckets are keyed per-IP.
func TestRateLimiter_IsolatesByIP(t *testing.T) {
rl := NewRateLimiter(2)
for i := range 2 {
if !rl.Allow("a") {
t.Fatalf("call %d from 'a' should succeed", i+1)
}
}
if rl.Allow("a") {
t.Fatalf("third call from 'a' should be denied")
}
if !rl.Allow("b") {
t.Fatalf("first call from 'b' should succeed even after 'a' is full")
}
}
// TestRateLimiter_WindowResetsAfterHour verifies the fixed-window semantics:
// once a bucket's resetsAt is in the past, the next Allow() call starts a
// fresh window with count=1. This guards against a regression where the
// limiter forgets to clear/reset stale buckets and locks out legitimate
// traffic indefinitely.
//
// We exercise this by mutating the bucket's resetsAt directly to a time in
// the past — equivalent to fast-forwarding the clock without waiting an hour.
// The helper accesses the unexported buckets map; this is safe because
// _test.go files compile inside the same package.
func TestRateLimiter_WindowResetsAfterHour(t *testing.T) {
rl := NewRateLimiter(3)
const ip = "198.51.100.7"
// Fill the bucket.
for i := range 3 {
if !rl.Allow(ip) {
t.Fatalf("call %d should succeed", i+1)
}
}
if rl.Allow(ip) {
t.Fatalf("4th call should be denied (bucket full)")
}
// Fast-forward the clock by pushing resetsAt into the past.
rl.mu.Lock()
b, ok := rl.buckets[ip]
if !ok {
rl.mu.Unlock()
t.Fatalf("bucket missing for ip %s after filling", ip)
}
b.resetsAt = time.Now().Add(-time.Hour - time.Second)
rl.mu.Unlock()
// Next call must reset the window and succeed.
if !rl.Allow(ip) {
t.Fatalf("Allow() must reset the bucket after window expiry — got false")
}
// Bucket count must be 1 (fresh window), not 4.
rl.mu.Lock()
got := rl.buckets[ip].count
rl.mu.Unlock()
if got != 1 {
t.Errorf("bucket count after reset: got %d, want 1 (fresh window)", got)
}
}