package console import ( "fmt" "sync" "sync/atomic" "testing" "time" ) // limitClock is an injectable, advanceable clock for emailLimits tests. type limitClock struct{ t time.Time } func newLimitClock() *limitClock { return &limitClock{t: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)} } func (c *limitClock) now() time.Time { return c.t } func (c *limitClock) advance(d time.Duration) { c.t = c.t.Add(d) } func newTestLimits(c *limitClock) *emailLimits { return &emailLimits{now: c.now} } func TestSigninSendLimits(t *testing.T) { c := newLimitClock() l := newTestLimits(c) const addr = "bob@example.com" if !l.signinSend(addr, "1.1.1.1") { t.Fatal("first send refused") } c.advance(30 * time.Second) if l.signinSend(addr, "1.1.1.1") { t.Fatal("second send within 60s allowed") } // The refusal recorded nothing: 30s later (60s after the first send) is // allowed. A refusal that pushed the window would still be refused. c.advance(30 * time.Second) if !l.signinSend(addr, "1.1.1.1") { t.Fatal("send at 60s refused: the earlier refusal must not have been recorded") } // Sends 3, 4 and 5 at 61s spacing; the 6th within the hour is refused. for i := 3; i <= 5; i++ { c.advance(61 * time.Second) if !l.signinSend(addr, "1.1.1.1") { t.Fatalf("send %d refused", i) } } c.advance(61 * time.Second) if l.signinSend(addr, "1.1.1.1") { t.Fatal("6th send within an hour allowed") } // Once the first send leaves the hour, one more fits. c.advance(time.Hour) if !l.signinSend(addr, "1.1.1.1") { t.Fatal("send after the hour rolled over refused") } } func TestSigninSendDailyCap(t *testing.T) { c := newLimitClock() l := newTestLimits(c) const addr = "bob@example.com" // Four sends per hour-ish block, spaced so the hourly cap of 5 never binds, // until 20 have gone out within the day. sent := 0 for sent < 20 { for i := 0; i < 4 && sent < 20; i++ { if !l.signinSend(addr, "1.1.1.1") { t.Fatalf("send %d refused", sent+1) } sent++ c.advance(61 * time.Second) } c.advance(time.Hour) } // 20 sends, the last about 20h in. The 21st must be refused by the daily cap. if l.signinSend(addr, "1.1.1.1") { t.Fatal("21st send within a day allowed") } // A refusal records nothing: after the first send ages out of the day, the // address can send again. c.advance(24 * time.Hour) if !l.signinSend(addr, "1.1.1.1") { t.Fatal("send after the day rolled over refused") } } func TestSigninSendIPAndGlobal(t *testing.T) { c := newLimitClock() l := newTestLimits(c) for i := 0; i < 10; i++ { if !l.signinSend(fmt.Sprintf("u%d@example.com", i), "2.2.2.2") { t.Fatalf("send %d from one IP refused", i+1) } } if l.signinSend("u10@example.com", "2.2.2.2") { t.Fatal("11th send from one IP allowed") } // All-or-nothing: the refused address was not charged, so it can still // send from another IP straight away. if !l.signinSend("u10@example.com", "3.3.3.3") { t.Fatal("address charged by a send refused for its IP") } // Console-wide: 200 per hour in total. c2 := newLimitClock() g := newTestLimits(c2) for i := 0; i < 200; i++ { if !g.signinSend(fmt.Sprintf("g%d@example.com", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250)) { t.Fatalf("send %d console-wide refused", i+1) } } if g.signinSend("g200@example.com", "10.9.9.9") { t.Fatal("201st send console-wide allowed") } c2.advance(time.Hour + time.Second) if !g.signinSend("g200@example.com", "10.9.9.9") { t.Fatal("send after the hour refused") } } func TestSigninSendRefusalByIPDoesNotChargeGlobal(t *testing.T) { c := newLimitClock() l := newTestLimits(c) for i := 0; i < 10; i++ { l.signinSend(fmt.Sprintf("u%d@example.com", i), "2.2.2.2") } // 190 refusals from the saturated IP must leave the global budget alone. for i := 0; i < 190; i++ { if l.signinSend(fmt.Sprintf("x%d@example.com", i), "2.2.2.2") { t.Fatal("saturated IP allowed") } } for i := 0; i < 190; i++ { if !l.signinSend(fmt.Sprintf("y%d@example.com", i), fmt.Sprintf("10.1.%d.%d", i/100, i%100)) { t.Fatalf("send %d refused: refusals charged the global window", i+1) } } } func TestStepUpSendSeparate(t *testing.T) { c := newLimitClock() l := newTestLimits(c) const addr = "bob@example.com" // A sign-in send does not consume step-up budget. if !l.signinSend(addr, "1.1.1.1") { t.Fatal("sign-in send refused") } if !l.stepUpSend(addr) { t.Fatal("step-up send refused after a sign-in send") } // And a step-up send does not consume sign-in budget. c.advance(61 * time.Second) if !l.signinSend(addr, "1.1.1.1") { t.Fatal("sign-in send refused after a step-up send") } if l.stepUpSend(addr) != true { t.Fatal("step-up send at 61s refused") } // 1 per 60 s. if l.stepUpSend(addr) { t.Fatal("step-up send inside 60s allowed") } // 10 per hour: 8 more (10 total) at 61s spacing, then the 11th refused. for i := 3; i <= 10; i++ { c.advance(61 * time.Second) if !l.stepUpSend(addr) { t.Fatalf("step-up send %d refused", i) } } c.advance(61 * time.Second) if l.stepUpSend(addr) { t.Fatal("11th step-up send within an hour allowed") } // Step-up sends never touched the sign-in per-address budget beyond the // two sign-in sends above. if !l.signinSend(addr, "1.1.1.1") { t.Fatal("sign-in send refused after step-up sends") } } // wrongGuess reserves one guess and keeps it, as a wrong code does. func wrongGuess(t *testing.T, l *emailLimits, addr, ip string) { t.Helper() done, ok := l.reserveGuess(addr, ip) if !ok { t.Fatalf("guess for %s from %s refused", addr, ip) } done(false) } // guessAllowed reports whether a guess would be allowed now, taking the // reservation straight back as a right code does. func guessAllowed(l *emailLimits, addr, ip string) bool { done, ok := l.reserveGuess(addr, ip) done(true) return ok } func TestCodeGuessCaps(t *testing.T) { c := newLimitClock() l := newTestLimits(c) const addr = "bob@example.com" if !guessAllowed(l, addr, "1.1.1.1") { t.Fatal("fresh address not allowed") } for i := 0; i < 9; i++ { wrongGuess(t, l, addr, fmt.Sprintf("9.9.9.%d", i)) } if !guessAllowed(l, addr, "1.1.1.1") { t.Fatal("blocked after 9 wrong codes") } wrongGuess(t, l, addr, "9.9.9.99") // Ten wrong codes against one address block it from any IP. if guessAllowed(l, addr, "1.1.1.1") || guessAllowed(l, addr, "4.4.4.4") { t.Fatal("address allowed after 10 wrong codes") } // Another address from a fresh IP is unaffected. if !guessAllowed(l, "other@example.com", "1.1.1.1") { t.Fatal("other address blocked") } c.advance(24*time.Hour + time.Second) if !guessAllowed(l, addr, "1.1.1.1") { t.Fatal("address still blocked after 24h") } } func TestCodeGuessIPCap(t *testing.T) { c := newLimitClock() l := newTestLimits(c) for i := 0; i < 29; i++ { wrongGuess(t, l, fmt.Sprintf("a%d@example.com", i), "5.5.5.5") } if !guessAllowed(l, "fresh@example.com", "5.5.5.5") { t.Fatal("IP blocked after 29 wrong codes") } wrongGuess(t, l, "a29@example.com", "5.5.5.5") // 30 wrong codes from one IP block that IP for any address. if guessAllowed(l, "fresh@example.com", "5.5.5.5") { t.Fatal("IP allowed after 30 wrong codes") } if !guessAllowed(l, "fresh@example.com", "6.6.6.6") { t.Fatal("another IP blocked") } c.advance(time.Hour + time.Second) if !guessAllowed(l, "fresh@example.com", "5.5.5.5") { t.Fatal("IP still blocked after an hour") } } // A right code takes its reservation back, against both keys, once. func TestCodeGuessRightCodeRefunds(t *testing.T) { c := newLimitClock() l := newTestLimits(c) const addr = "bob@example.com" for i := 0; i < 9; i++ { wrongGuess(t, l, addr, "1.1.1.1") } for i := 0; i < 5; i++ { done, ok := l.reserveGuess(addr, "1.1.1.1") if !ok { t.Fatalf("right code %d refused after 9 wrong", i+1) } done(true) done(true) // a second report changes nothing } if n := l.addrWrong.count(addr, c.now().Add(-24*time.Hour)); n != 9 { t.Fatalf("address holds %d guesses, want the 9 wrong", n) } if n := l.ipWrong.count("1.1.1.1", c.now().Add(-time.Hour)); n != 9 { t.Fatalf("IP holds %d guesses, want the 9 wrong", n) } // A kept reservation is the tenth wrong code. done, _ := l.reserveGuess(addr, "1.1.1.1") done(false) done(true) // too late: done acts once if guessAllowed(l, addr, "1.1.1.1") { t.Fatal("address allowed after 10 kept guesses") } } // The cap is one step: with nine wrong codes recorded, any number of guesses // arriving together let exactly one through. func TestCodeGuessConcurrentBoundary(t *testing.T) { for _, tc := range []struct { name string addr, ip func(i int) string have int }{ {"address", func(int) string { return "bob@example.com" }, func(i int) string { return fmt.Sprintf("10.0.0.%d", i) }, wrongCodesPerAddrDay - 1}, {"ip", func(i int) string { return fmt.Sprintf("u%d@example.com", i) }, func(int) string { return "5.5.5.5" }, wrongCodesPerIPHour - 1}, } { t.Run(tc.name, func(t *testing.T) { c := newLimitClock() l := newTestLimits(c) for i := 0; i < tc.have; i++ { wrongGuess(t, l, tc.addr(1000+i), tc.ip(1000+i)) } const n = 32 var ( start = make(chan struct{}) wg sync.WaitGroup allowed atomic.Int32 ) for i := 0; i < n; i++ { wg.Add(1) go func() { defer wg.Done() <-start if done, ok := l.reserveGuess(tc.addr(i), tc.ip(i)); ok { allowed.Add(1) done(false) } }() } close(start) wg.Wait() if got := allowed.Load(); got != 1 { t.Fatalf("%d of %d concurrent guesses allowed at the boundary, want exactly 1", got, n) } }) } } func TestSigninSendSubmitCap(t *testing.T) { c := newLimitClock() l := newTestLimits(c) for i := 0; i < 30; i++ { if !l.submit("7.7.7.7") { t.Fatalf("submission %d refused", i+1) } } if l.submit("7.7.7.7") { t.Fatal("31st submission within an hour allowed") } if !l.submit("8.8.8.8") { t.Fatal("another IP refused") } // The submit budget is separate from the sign-in send IP budget. if !l.signinSend("a@example.com", "7.7.7.7") { t.Fatal("sign-in send refused by the submit window") } c.advance(time.Hour + time.Second) if !l.submit("7.7.7.7") { t.Fatal("submission after the hour refused") } } func limitKeyCount(l *emailLimits) int { n := 0 for _, w := range l.sweepWindows() { n += len(w.hits) } return n } func TestSigninSendSweepIsBounded(t *testing.T) { c := newLimitClock() l := newTestLimits(c) // 10,000 live keys in one window: one call inspects at most 64 and drops none. for i := 0; i < 10000; i++ { l.submits.add(fmt.Sprintf("ip%d", i), c.now()) } l.mu.Lock() inspected := l.sweep(c.now()) l.mu.Unlock() if inspected > limitSweepBudget || inspected == 0 { t.Fatalf("sweep inspected %d keys, want 1..%d", inspected, limitSweepBudget) } if got := len(l.submits.hits); got != 10000 { t.Fatalf("sweep dropped live keys: %d left", got) } // The same bound holds through submit, spread over several large windows. for i := 0; i < 10000; i++ { l.addrSends.add(fmt.Sprintf("a%d", i), c.now()) l.stepUps.add(fmt.Sprintf("s%d", i), c.now()) } for i := 0; i < 5; i++ { l.mu.Lock() inspected = l.sweep(c.now()) l.mu.Unlock() if inspected > limitSweepBudget { t.Fatalf("sweep inspected %d keys, budget is %d", inspected, limitSweepBudget) } } if !l.submit("probe") { t.Fatal("submit refused") } if got := limitKeyCount(l); got != 30001 { t.Fatalf("live keys changed: %d, want 30001", got) } } func TestSigninSendSweepCleansStaleAcrossCalls(t *testing.T) { c := newLimitClock() l := newTestLimits(c) // 50 stale keys in each of the six windows: 300 in all, well over one // call's budget, including stepUps. for i := 0; i < 50; i++ { for _, w := range l.sweepWindows() { w.add(fmt.Sprintf("old%d", i), c.now()) } } if got := limitKeyCount(l); got != 300 { t.Fatalf("setup: %d keys", got) } c.advance(25 * time.Hour) l.submit("11.0.0.1") left := limitKeyCount(l) if left == 0 || left < 300+1-limitSweepBudget { t.Fatalf("one submit left %d of 301 keys; want a bounded step (dropped at most %d)", left, limitSweepBudget) } for i := 0; i < 20 && limitKeyCount(l) > 1; i++ { l.submit("11.0.0.1") } // Only the live submit key remains, and every window was cleaned, stepUps too. if got := limitKeyCount(l); got != 1 { t.Fatalf("%d keys left after repeated submits, want 1", got) } if len(l.stepUps.hits) != 0 { t.Fatal("stepUps not swept") } }