fe5a3d6d3b
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
A fresh email login that hit the per-IP email rate limit stranded the user in an unrecoverable "offline" state that survived a full PWA restart, even though the network was fine. Root cause: the gateway email class (auth.email.request + auth.email.login, keyed per IP, 5/10min burst 2) trips on the third event, which in the natural "request code -> wrong code -> correct code" sequence is the correct-code login. The gateway returns ResourceExhausted; the client mapped it to 'rate_limited', which retry.ts classified as retryable + a connection code, so exec() called reportOffline(). That pushes the net-state machine into connecting, whose recovery probe is an authenticated profile.get -- but the login screen has no session, so the probe can never succeed and the machine latches offline. The transport kill switch (assertOnline) then refuses the very login that would fix it, and because the IP's email bucket refills only 1 token / 120s, each fresh attempt after a restart is rate-limited again -> offline again. Fix (client, narrow -- the trigger): - A rate-limit is no longer treated as connectivity. retry.ts no longer marks 'rate_limited' retryable or a connection code, so exec() never reports it offline; it surfaces as the existing error.rate_limited "slow down" message. Not auto-retrying it also stops the ~20s button freeze and avoids feeding the gateway's ban tripwire with 6 extra rejections per attempt. Fix (server): - Raise the email-code burst 2 -> 4 so the honest request + a mistyped code + the correct one is not throttled mid-login. Defence-in-depth over the backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle). Tests: retry classification units updated to the new semantics; a gateway regression guard asserts the honest three-event email flow passes under the default policy. gateway/README.md rate-limit note updated. The deeper gap (the net-state recovery probe is session-gated, so any real transport failure on the session-less login/confirm screens still cannot self-heal) is left as a known residual, deferred by the owner.
65 lines
2.0 KiB
Go
65 lines
2.0 KiB
Go
package ratelimit_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"scrabble/gateway/internal/config"
|
|
"scrabble/gateway/internal/ratelimit"
|
|
)
|
|
|
|
func TestAllowEnforcesBurst(t *testing.T) {
|
|
l := ratelimit.New()
|
|
p := ratelimit.PerMinute(60, 3) // 1/s, burst 3
|
|
allowed := 0
|
|
for i := 0; i < 5; i++ {
|
|
if l.Allow("ip:1.2.3.4", p) {
|
|
allowed++
|
|
}
|
|
}
|
|
if allowed != 3 {
|
|
t.Fatalf("allowed %d of 5, want 3 (burst)", allowed)
|
|
}
|
|
}
|
|
|
|
func TestAllowIsolatesKeys(t *testing.T) {
|
|
l := ratelimit.New()
|
|
p := ratelimit.PerMinute(60, 1)
|
|
if !l.Allow("user:a", p) {
|
|
t.Fatal("first key should be allowed")
|
|
}
|
|
if !l.Allow("user:b", p) {
|
|
t.Fatal("a different key must have its own bucket")
|
|
}
|
|
if l.Allow("user:a", p) {
|
|
t.Fatal("the first key's bucket should now be empty")
|
|
}
|
|
}
|
|
|
|
func TestPerWindow(t *testing.T) {
|
|
// 5 events per 10 minutes, burst 2: the third immediate call is denied.
|
|
p := ratelimit.Per(5, 10*time.Minute, 2)
|
|
l := ratelimit.New()
|
|
got := []bool{l.Allow("email:x", p), l.Allow("email:x", p), l.Allow("email:x", p)}
|
|
if !got[0] || !got[1] || got[2] {
|
|
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
|
}
|
|
}
|
|
|
|
// TestEmailBurstAllowsHonestLoginFlow guards the default email-code burst against being
|
|
// tightened back below the honest login sequence: requesting a code, submitting one wrong
|
|
// code, then the correct one are three immediate email-class events from one IP, and all
|
|
// must pass. A burst of 2 denied the correct-code login, which the client mis-read as going
|
|
// offline and could not recover from on the session-less login screen. This is defence in
|
|
// depth over the backend's own per-code attempt cap and code TTL.
|
|
func TestEmailBurstAllowsHonestLoginFlow(t *testing.T) {
|
|
rl := config.DefaultRateLimit()
|
|
p := ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst)
|
|
l := ratelimit.New()
|
|
for i, want := range []bool{true, true, true} { // request code, wrong code, right code
|
|
if got := l.Allow("email:198.51.100.7", p); got != want {
|
|
t.Fatalf("honest email event %d allowed = %v, want %v", i+1, got, want)
|
|
}
|
|
}
|
|
}
|