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>
86 lines
2.3 KiB
Go
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)
|
|
}
|
|
}
|