carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "fmt"
5 "sync"
6 "sync/atomic"
7 "testing"
8 "time"
9)
10
11// limitClock is an injectable, advanceable clock for emailLimits tests.
12type limitClock struct{ t time.Time }
13
14func newLimitClock() *limitClock {
15 return &limitClock{t: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)}
16}
17func (c *limitClock) now() time.Time { return c.t }
18func (c *limitClock) advance(d time.Duration) { c.t = c.t.Add(d) }
19
20func newTestLimits(c *limitClock) *emailLimits { return &emailLimits{now: c.now} }
21
22func TestSigninSendLimits(t *testing.T) {
23 c := newLimitClock()
24 l := newTestLimits(c)
25 const addr = "bob@example.com"
26
27 if !l.signinSend(addr, "1.1.1.1") {
28 t.Fatal("first send refused")
29 }
30 c.advance(30 * time.Second)
31 if l.signinSend(addr, "1.1.1.1") {
32 t.Fatal("second send within 60s allowed")
33 }
34 // The refusal recorded nothing: 30s later (60s after the first send) is
35 // allowed. A refusal that pushed the window would still be refused.
36 c.advance(30 * time.Second)
37 if !l.signinSend(addr, "1.1.1.1") {
38 t.Fatal("send at 60s refused: the earlier refusal must not have been recorded")
39 }
40
41 // Sends 3, 4 and 5 at 61s spacing; the 6th within the hour is refused.
42 for i := 3; i <= 5; i++ {
43 c.advance(61 * time.Second)
44 if !l.signinSend(addr, "1.1.1.1") {
45 t.Fatalf("send %d refused", i)
46 }
47 }
48 c.advance(61 * time.Second)
49 if l.signinSend(addr, "1.1.1.1") {
50 t.Fatal("6th send within an hour allowed")
51 }
52 // Once the first send leaves the hour, one more fits.
53 c.advance(time.Hour)
54 if !l.signinSend(addr, "1.1.1.1") {
55 t.Fatal("send after the hour rolled over refused")
56 }
57}
58
59func TestSigninSendDailyCap(t *testing.T) {
60 c := newLimitClock()
61 l := newTestLimits(c)
62 const addr = "bob@example.com"
63 // Four sends per hour-ish block, spaced so the hourly cap of 5 never binds,
64 // until 20 have gone out within the day.
65 sent := 0
66 for sent < 20 {
67 for i := 0; i < 4 && sent < 20; i++ {
68 if !l.signinSend(addr, "1.1.1.1") {
69 t.Fatalf("send %d refused", sent+1)
70 }
71 sent++
72 c.advance(61 * time.Second)
73 }
74 c.advance(time.Hour)
75 }
76 // 20 sends, the last about 20h in. The 21st must be refused by the daily cap.
77 if l.signinSend(addr, "1.1.1.1") {
78 t.Fatal("21st send within a day allowed")
79 }
80 // A refusal records nothing: after the first send ages out of the day, the
81 // address can send again.
82 c.advance(24 * time.Hour)
83 if !l.signinSend(addr, "1.1.1.1") {
84 t.Fatal("send after the day rolled over refused")
85 }
86}
87
88func TestSigninSendIPAndGlobal(t *testing.T) {
89 c := newLimitClock()
90 l := newTestLimits(c)
91 for i := 0; i < 10; i++ {
92 if !l.signinSend(fmt.Sprintf("u%d@example.com", i), "2.2.2.2") {
93 t.Fatalf("send %d from one IP refused", i+1)
94 }
95 }
96 if l.signinSend("u10@example.com", "2.2.2.2") {
97 t.Fatal("11th send from one IP allowed")
98 }
99 // All-or-nothing: the refused address was not charged, so it can still
100 // send from another IP straight away.
101 if !l.signinSend("u10@example.com", "3.3.3.3") {
102 t.Fatal("address charged by a send refused for its IP")
103 }
104
105 // Console-wide: 200 per hour in total.
106 c2 := newLimitClock()
107 g := newTestLimits(c2)
108 for i := 0; i < 200; i++ {
109 if !g.signinSend(fmt.Sprintf("g%d@example.com", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250)) {
110 t.Fatalf("send %d console-wide refused", i+1)
111 }
112 }
113 if g.signinSend("g200@example.com", "10.9.9.9") {
114 t.Fatal("201st send console-wide allowed")
115 }
116 c2.advance(time.Hour + time.Second)
117 if !g.signinSend("g200@example.com", "10.9.9.9") {
118 t.Fatal("send after the hour refused")
119 }
120}
121
122func TestSigninSendRefusalByIPDoesNotChargeGlobal(t *testing.T) {
123 c := newLimitClock()
124 l := newTestLimits(c)
125 for i := 0; i < 10; i++ {
126 l.signinSend(fmt.Sprintf("u%d@example.com", i), "2.2.2.2")
127 }
128 // 190 refusals from the saturated IP must leave the global budget alone.
129 for i := 0; i < 190; i++ {
130 if l.signinSend(fmt.Sprintf("x%d@example.com", i), "2.2.2.2") {
131 t.Fatal("saturated IP allowed")
132 }
133 }
134 for i := 0; i < 190; i++ {
135 if !l.signinSend(fmt.Sprintf("y%d@example.com", i), fmt.Sprintf("10.1.%d.%d", i/100, i%100)) {
136 t.Fatalf("send %d refused: refusals charged the global window", i+1)
137 }
138 }
139}
140
141func TestStepUpSendSeparate(t *testing.T) {
142 c := newLimitClock()
143 l := newTestLimits(c)
144 const addr = "bob@example.com"
145
146 // A sign-in send does not consume step-up budget.
147 if !l.signinSend(addr, "1.1.1.1") {
148 t.Fatal("sign-in send refused")
149 }
150 if !l.stepUpSend(addr) {
151 t.Fatal("step-up send refused after a sign-in send")
152 }
153 // And a step-up send does not consume sign-in budget.
154 c.advance(61 * time.Second)
155 if !l.signinSend(addr, "1.1.1.1") {
156 t.Fatal("sign-in send refused after a step-up send")
157 }
158 if l.stepUpSend(addr) != true {
159 t.Fatal("step-up send at 61s refused")
160 }
161
162 // 1 per 60 s.
163 if l.stepUpSend(addr) {
164 t.Fatal("step-up send inside 60s allowed")
165 }
166 // 10 per hour: 8 more (10 total) at 61s spacing, then the 11th refused.
167 for i := 3; i <= 10; i++ {
168 c.advance(61 * time.Second)
169 if !l.stepUpSend(addr) {
170 t.Fatalf("step-up send %d refused", i)
171 }
172 }
173 c.advance(61 * time.Second)
174 if l.stepUpSend(addr) {
175 t.Fatal("11th step-up send within an hour allowed")
176 }
177 // Step-up sends never touched the sign-in per-address budget beyond the
178 // two sign-in sends above.
179 if !l.signinSend(addr, "1.1.1.1") {
180 t.Fatal("sign-in send refused after step-up sends")
181 }
182}
183
184// wrongGuess reserves one guess and keeps it, as a wrong code does.
185func wrongGuess(t *testing.T, l *emailLimits, addr, ip string) {
186 t.Helper()
187 done, ok := l.reserveGuess(addr, ip)
188 if !ok {
189 t.Fatalf("guess for %s from %s refused", addr, ip)
190 }
191 done(false)
192}
193
194// guessAllowed reports whether a guess would be allowed now, taking the
195// reservation straight back as a right code does.
196func guessAllowed(l *emailLimits, addr, ip string) bool {
197 done, ok := l.reserveGuess(addr, ip)
198 done(true)
199 return ok
200}
201
202func TestCodeGuessCaps(t *testing.T) {
203 c := newLimitClock()
204 l := newTestLimits(c)
205 const addr = "bob@example.com"
206
207 if !guessAllowed(l, addr, "1.1.1.1") {
208 t.Fatal("fresh address not allowed")
209 }
210 for i := 0; i < 9; i++ {
211 wrongGuess(t, l, addr, fmt.Sprintf("9.9.9.%d", i))
212 }
213 if !guessAllowed(l, addr, "1.1.1.1") {
214 t.Fatal("blocked after 9 wrong codes")
215 }
216 wrongGuess(t, l, addr, "9.9.9.99")
217 // Ten wrong codes against one address block it from any IP.
218 if guessAllowed(l, addr, "1.1.1.1") || guessAllowed(l, addr, "4.4.4.4") {
219 t.Fatal("address allowed after 10 wrong codes")
220 }
221 // Another address from a fresh IP is unaffected.
222 if !guessAllowed(l, "other@example.com", "1.1.1.1") {
223 t.Fatal("other address blocked")
224 }
225 c.advance(24*time.Hour + time.Second)
226 if !guessAllowed(l, addr, "1.1.1.1") {
227 t.Fatal("address still blocked after 24h")
228 }
229}
230
231func TestCodeGuessIPCap(t *testing.T) {
232 c := newLimitClock()
233 l := newTestLimits(c)
234 for i := 0; i < 29; i++ {
235 wrongGuess(t, l, fmt.Sprintf("a%d@example.com", i), "5.5.5.5")
236 }
237 if !guessAllowed(l, "fresh@example.com", "5.5.5.5") {
238 t.Fatal("IP blocked after 29 wrong codes")
239 }
240 wrongGuess(t, l, "a29@example.com", "5.5.5.5")
241 // 30 wrong codes from one IP block that IP for any address.
242 if guessAllowed(l, "fresh@example.com", "5.5.5.5") {
243 t.Fatal("IP allowed after 30 wrong codes")
244 }
245 if !guessAllowed(l, "fresh@example.com", "6.6.6.6") {
246 t.Fatal("another IP blocked")
247 }
248 c.advance(time.Hour + time.Second)
249 if !guessAllowed(l, "fresh@example.com", "5.5.5.5") {
250 t.Fatal("IP still blocked after an hour")
251 }
252}
253
254// A right code takes its reservation back, against both keys, once.
255func TestCodeGuessRightCodeRefunds(t *testing.T) {
256 c := newLimitClock()
257 l := newTestLimits(c)
258 const addr = "bob@example.com"
259 for i := 0; i < 9; i++ {
260 wrongGuess(t, l, addr, "1.1.1.1")
261 }
262 for i := 0; i < 5; i++ {
263 done, ok := l.reserveGuess(addr, "1.1.1.1")
264 if !ok {
265 t.Fatalf("right code %d refused after 9 wrong", i+1)
266 }
267 done(true)
268 done(true) // a second report changes nothing
269 }
270 if n := l.addrWrong.count(addr, c.now().Add(-24*time.Hour)); n != 9 {
271 t.Fatalf("address holds %d guesses, want the 9 wrong", n)
272 }
273 if n := l.ipWrong.count("1.1.1.1", c.now().Add(-time.Hour)); n != 9 {
274 t.Fatalf("IP holds %d guesses, want the 9 wrong", n)
275 }
276 // A kept reservation is the tenth wrong code.
277 done, _ := l.reserveGuess(addr, "1.1.1.1")
278 done(false)
279 done(true) // too late: done acts once
280 if guessAllowed(l, addr, "1.1.1.1") {
281 t.Fatal("address allowed after 10 kept guesses")
282 }
283}
284
285// The cap is one step: with nine wrong codes recorded, any number of guesses
286// arriving together let exactly one through.
287func TestCodeGuessConcurrentBoundary(t *testing.T) {
288 for _, tc := range []struct {
289 name string
290 addr, ip func(i int) string
291 have int
292 }{
293 {"address", func(int) string { return "bob@example.com" }, func(i int) string { return fmt.Sprintf("10.0.0.%d", i) }, wrongCodesPerAddrDay - 1},
294 {"ip", func(i int) string { return fmt.Sprintf("u%d@example.com", i) }, func(int) string { return "5.5.5.5" }, wrongCodesPerIPHour - 1},
295 } {
296 t.Run(tc.name, func(t *testing.T) {
297 c := newLimitClock()
298 l := newTestLimits(c)
299 for i := 0; i < tc.have; i++ {
300 wrongGuess(t, l, tc.addr(1000+i), tc.ip(1000+i))
301 }
302 const n = 32
303 var (
304 start = make(chan struct{})
305 wg sync.WaitGroup
306 allowed atomic.Int32
307 )
308 for i := 0; i < n; i++ {
309 wg.Add(1)
310 go func() {
311 defer wg.Done()
312 <-start
313 if done, ok := l.reserveGuess(tc.addr(i), tc.ip(i)); ok {
314 allowed.Add(1)
315 done(false)
316 }
317 }()
318 }
319 close(start)
320 wg.Wait()
321 if got := allowed.Load(); got != 1 {
322 t.Fatalf("%d of %d concurrent guesses allowed at the boundary, want exactly 1", got, n)
323 }
324 })
325 }
326}
327
328func TestSigninSendSubmitCap(t *testing.T) {
329 c := newLimitClock()
330 l := newTestLimits(c)
331 for i := 0; i < 30; i++ {
332 if !l.submit("7.7.7.7") {
333 t.Fatalf("submission %d refused", i+1)
334 }
335 }
336 if l.submit("7.7.7.7") {
337 t.Fatal("31st submission within an hour allowed")
338 }
339 if !l.submit("8.8.8.8") {
340 t.Fatal("another IP refused")
341 }
342 // The submit budget is separate from the sign-in send IP budget.
343 if !l.signinSend("a@example.com", "7.7.7.7") {
344 t.Fatal("sign-in send refused by the submit window")
345 }
346 c.advance(time.Hour + time.Second)
347 if !l.submit("7.7.7.7") {
348 t.Fatal("submission after the hour refused")
349 }
350}
351
352func limitKeyCount(l *emailLimits) int {
353 n := 0
354 for _, w := range l.sweepWindows() {
355 n += len(w.hits)
356 }
357 return n
358}
359
360func TestSigninSendSweepIsBounded(t *testing.T) {
361 c := newLimitClock()
362 l := newTestLimits(c)
363 // 10,000 live keys in one window: one call inspects at most 64 and drops none.
364 for i := 0; i < 10000; i++ {
365 l.submits.add(fmt.Sprintf("ip%d", i), c.now())
366 }
367 l.mu.Lock()
368 inspected := l.sweep(c.now())
369 l.mu.Unlock()
370 if inspected > limitSweepBudget || inspected == 0 {
371 t.Fatalf("sweep inspected %d keys, want 1..%d", inspected, limitSweepBudget)
372 }
373 if got := len(l.submits.hits); got != 10000 {
374 t.Fatalf("sweep dropped live keys: %d left", got)
375 }
376 // The same bound holds through submit, spread over several large windows.
377 for i := 0; i < 10000; i++ {
378 l.addrSends.add(fmt.Sprintf("a%d", i), c.now())
379 l.stepUps.add(fmt.Sprintf("s%d", i), c.now())
380 }
381 for i := 0; i < 5; i++ {
382 l.mu.Lock()
383 inspected = l.sweep(c.now())
384 l.mu.Unlock()
385 if inspected > limitSweepBudget {
386 t.Fatalf("sweep inspected %d keys, budget is %d", inspected, limitSweepBudget)
387 }
388 }
389 if !l.submit("probe") {
390 t.Fatal("submit refused")
391 }
392 if got := limitKeyCount(l); got != 30001 {
393 t.Fatalf("live keys changed: %d, want 30001", got)
394 }
395}
396
397func TestSigninSendSweepCleansStaleAcrossCalls(t *testing.T) {
398 c := newLimitClock()
399 l := newTestLimits(c)
400 // 50 stale keys in each of the six windows: 300 in all, well over one
401 // call's budget, including stepUps.
402 for i := 0; i < 50; i++ {
403 for _, w := range l.sweepWindows() {
404 w.add(fmt.Sprintf("old%d", i), c.now())
405 }
406 }
407 if got := limitKeyCount(l); got != 300 {
408 t.Fatalf("setup: %d keys", got)
409 }
410 c.advance(25 * time.Hour)
411 l.submit("11.0.0.1")
412 left := limitKeyCount(l)
413 if left == 0 || left < 300+1-limitSweepBudget {
414 t.Fatalf("one submit left %d of 301 keys; want a bounded step (dropped at most %d)", left, limitSweepBudget)
415 }
416 for i := 0; i < 20 && limitKeyCount(l) > 1; i++ {
417 l.submit("11.0.0.1")
418 }
419 // Only the live submit key remains, and every window was cleaned, stepUps too.
420 if got := limitKeyCount(l); got != 1 {
421 t.Fatalf("%d keys left after repeated submits, want 1", got)
422 }
423 if len(l.stepUps.hits) != 0 {
424 t.Fatal("stepUps not swept")
425 }
426}
427