Files
base/plugins/waitlist/plugin_test.go
zeekay a47ee006c6 feat(plugins/waitlist): viral waitlist plugin
Adds plugins/waitlist/ — a Base Go plugin that exposes a viral waitlist
under /v1/waitlist on any Base process. Replaces the standalone Next.js
+ Upstash Redis surface that lived in github.com/hanzoai/waitlist.

Surface
- POST /v1/waitlist/join     register, atomically credit referrer
- GET  /v1/waitlist/status   look up rank + share URL
- GET  /v1/waitlist/export   admin-only CSV (superuser or shared secret)

Storage
- Two collections auto-created on bootstrap: waitlists, waitlist_entries
- Indexes: UNIQUE(waitlist,email), UNIQUE(waitlist,refCode),
  (waitlist, referralCount DESC, createdAt ASC) for rank queries
- All mutations inside RunInTransaction — idempotent join, atomic
  referral counts, collision-safe refCode allocation

Anti-abuse
- crypto/rand 8-char refCode from 36-char alphabet (no 0/O/1/l/I/etc)
- Optional Cloudflare Turnstile verify (no-op when secret unset)
- In-process sliding-window IP rate limit (default 5/IP/hour, tested)
- Disposable email domain blocklist (overridable via Config)

Registration
- waitlist.MustRegister(app, waitlist.Config{Enabled: true, ...})
- TURNSTILE_SECRET_KEY and WAITLIST_ADMIN_SECRET resolved from env

Tests
- plugin_test.go: refCode uniqueness (5000 codes), email validation,
  disposable-domain check, sliding limiter (window expiry + disable),
  config defaults, prefix validation
2026-06-09 22:58:01 -07:00

128 lines
3.0 KiB
Go

// Copyright (C) 2020-2026, Hanzo AI Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package waitlist
import (
"testing"
"time"
)
func TestRefCodeUniqueness(t *testing.T) {
seen := map[string]struct{}{}
for i := 0; i < 5000; i++ {
c := generateRefCode()
if len(c) != 8 {
t.Fatalf("ref code length = %d, want 8", len(c))
}
for _, r := range c {
if !contains(refCodeAlphabet, byte(r)) {
t.Fatalf("ref code %q contains illegal char %q", c, r)
}
}
if _, dup := seen[c]; dup {
t.Fatalf("collision after %d codes (alphabet too small or RNG broken)", i)
}
seen[c] = struct{}{}
}
}
func TestEmailValidation(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"[email protected]", true},
{"[email protected]", true},
{"", false},
{"no-at-sign", false},
{"a@b", false},
{"@b.co", false},
}
for _, c := range cases {
if got := isValidEmail(c.in); got != c.want {
t.Errorf("isValidEmail(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestEmailDomain(t *testing.T) {
if got := emailDomain("[email protected]"); got != "example.com" {
t.Errorf("emailDomain lowercase failed: %q", got)
}
if got := emailDomain("no-at"); got != "" {
t.Errorf("emailDomain bad input: %q", got)
}
}
func TestSlidingLimiter(t *testing.T) {
now := time.Unix(1000, 0)
l := newSlidingLimiter(3, time.Minute)
l.now = func() time.Time { return now }
for i := 0; i < 3; i++ {
if !l.allow("ip-a") {
t.Fatalf("hit %d should be allowed", i)
}
}
if l.allow("ip-a") {
t.Fatal("4th hit must be denied")
}
if !l.allow("ip-b") {
t.Fatal("different key must be allowed")
}
// Advance past window — counter resets.
now = now.Add(2 * time.Minute)
if !l.allow("ip-a") {
t.Fatal("after window expiry, should be allowed")
}
}
func TestSlidingLimiterDisabled(t *testing.T) {
l := newSlidingLimiter(0, time.Minute) // limit=0 disables
for i := 0; i < 100; i++ {
if !l.allow("x") {
t.Fatalf("disabled limiter should always allow")
}
}
}
func TestConfigResolveDefaults(t *testing.T) {
c := Config{Enabled: true}
c.resolve()
if c.JoinRateLimit != 5 {
t.Errorf("default rate limit = %d, want 5", c.JoinRateLimit)
}
if c.JoinRateWindow != time.Hour {
t.Errorf("default window = %v, want 1h", c.JoinRateWindow)
}
if c.waitlistsCollection() != "waitlists" {
t.Errorf("default collection name = %q", c.waitlistsCollection())
}
}
func TestConfigCollectionPrefix(t *testing.T) {
c := Config{Enabled: true, CollectionPrefix: "tenant1"}
if err := c.validate(); err != nil {
t.Errorf("alphanumeric prefix should validate: %v", err)
}
if c.waitlistsCollection() != "tenant1_waitlists" {
t.Errorf("prefixed collection name = %q", c.waitlistsCollection())
}
c = Config{Enabled: true, CollectionPrefix: "Bad-Prefix"}
if err := c.validate(); err == nil {
t.Error("prefix with uppercase/hyphen should be rejected")
}
}
func contains(s string, b byte) bool {
for i := 0; i < len(s); i++ {
if s[i] == b {
return true
}
}
return false
}