package services import ( "testing" "time" "github.com/google/uuid" ) // allowInviteTestService builds a bare InviteService that exercises the // rate-limiter without touching SQL or SMTP. The DB and mail fields stay // nil — only the counter logic is under test here. func allowInviteTestService() *InviteService { return &InviteService{ allowedDomains: func() []string { return []string{"hlc.com"} }, sentBy: map[uuid.UUID][]time.Time{}, clock: func() time.Time { return time.Now() }, } } // Fills the slot up to the cap, then asserts the next call is refused. func TestInviteRateLimit_WithinWindow(t *testing.T) { s := allowInviteTestService() uid := uuid.New() for i := 0; i < InviteRateLimit; i++ { if !s.allowInvite(uid) { t.Fatalf("slot %d should succeed", i) } } if s.allowInvite(uid) { t.Fatal("slot beyond cap should be denied") } } // After the window rolls over, old stamps are dropped and the user is // allowed to send again. This is the main invariant behind the 24h cap. func TestInviteRateLimit_WindowRollover(t *testing.T) { s := allowInviteTestService() uid := uuid.New() now := time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC) s.clock = func() time.Time { return now } for i := 0; i < InviteRateLimit; i++ { if !s.allowInvite(uid) { t.Fatalf("slot %d should succeed at t=now", i) } } if s.allowInvite(uid) { t.Fatal("11th slot should be denied at t=now") } // Jump well past the window — all stamps should age out. s.clock = func() time.Time { return now.Add(InviteRateWindow + time.Minute) } if !s.allowInvite(uid) { t.Fatal("slot after rollover should succeed") } if got := s.RemainingToday(uid); got != InviteRateLimit-1 { t.Errorf("RemainingToday after rollover = %d, want %d", got, InviteRateLimit-1) } } // Concurrent senders must not be able to double-spend the last slot — the // limiter holds the mutex across the check-and-insert. func TestInviteRateLimit_Concurrent(t *testing.T) { s := allowInviteTestService() uid := uuid.New() done := make(chan bool, InviteRateLimit*2) for i := 0; i < InviteRateLimit*2; i++ { go func() { done <- s.allowInvite(uid) }() } granted := 0 for i := 0; i < InviteRateLimit*2; i++ { if <-done { granted++ } } if granted != InviteRateLimit { t.Errorf("granted=%d, want %d", granted, InviteRateLimit) } } // domainAllowed must match case-insensitively and fail-closed when the // whitelist is empty. func TestDomainAllowed(t *testing.T) { s := &InviteService{allowedDomains: func() []string { return []string{"hlc.com", "HLC.de"} }} allow := map[string]bool{ "alice@hlc.com": true, "alice@HLC.COM": true, "alice@hlc.de": true, "alice@hlc.com.evil.ru": false, "alice@example.org": false, "malformed": false, } for addr, want := range allow { if got := s.domainAllowed(addr); got != want { t.Errorf("domainAllowed(%q) = %v, want %v", addr, got, want) } } // Empty whitelist = deny everything. s.allowedDomains = func() []string { return nil } if s.domainAllowed("alice@hlc.com") { t.Error("empty whitelist should deny") } }