| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sync" |
| 6 | "sync/atomic" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // limitClock is an injectable, advanceable clock for emailLimits tests. |
| 12 | type limitClock struct{ t time.Time } |
| 13 | |
| 14 | func newLimitClock() *limitClock { |
| 15 | return &limitClock{t: time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)} |
| 16 | } |
| 17 | func (c *limitClock) now() time.Time { return c.t } |
| 18 | func (c *limitClock) advance(d time.Duration) { c.t = c.t.Add(d) } |
| 19 | |
| 20 | func newTestLimits(c *limitClock) *emailLimits { return &emailLimits{now: c.now} } |
| 21 | |
| 22 | func 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 | |
| 59 | func 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 | |
| 88 | func 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 | |
| 122 | func 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 | |
| 141 | func 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. |
| 185 | func 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. |
| 196 | func guessAllowed(l *emailLimits, addr, ip string) bool { |
| 197 | done, ok := l.reserveGuess(addr, ip) |
| 198 | done(true) |
| 199 | return ok |
| 200 | } |
| 201 | |
| 202 | func 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 | |
| 231 | func 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. |
| 255 | func 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. |
| 287 | func 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 | |
| 328 | func 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 | |
| 352 | func limitKeyCount(l *emailLimits) int { |
| 353 | n := 0 |
| 354 | for _, w := range l.sweepWindows() { |
| 355 | n += len(w.hits) |
| 356 | } |
| 357 | return n |
| 358 | } |
| 359 | |
| 360 | func 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 | |
| 397 | func 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 | |