fix(garmin): 重新绑定走的是同一个被封的登录接口,也得拦住
用户问:限流了,重新输账号密码验证码换个新令牌行不行。
不行,而且是最糟的一种试法。`garth.login()` 和 `refresh_oauth2()` 打的是
同一个 SSO 端点,流程还更重;限流按**账号**计(不是按 IP、按 UA),换设备
换网络都绕不开;而窗口内每次尝试都会把窗口往后推。
而这正是被卡住时第一个会去试的操作,代码里却只有 `_connect` 的刷新有闸门,
重新绑定那条路照发不误。
- start_login 在 sso 冷却窗口内直接拒绝,不建会话行、不碰网络
- 错误信息说清三件事:为什么现在不试、什么时候恢复、换设备没用
- 路由返 429(请求本身没毛病,是该晚点再来)并带 retryAfterSeconds
- 数据端点的 429 不参与拦截,force 可以推翻
前端补上 UI:报错文案早先承诺了「同步页选择强制重试」,但那个按钮不存在。
现在只在被冷却拒绝之后才出现,样式刻意做得不像第二个「开始同步」——它是给
估算失准时的出口,不是随手可点的第二选择。
顺带修一个正要被我引入的 bug:`onClick={syncHistory}` 会把 MouseEvent 当成
force 传进去,等于每次点开始同步都跳过冷却。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -276,3 +276,52 @@ class TestEndpoints:
|
||||
assert wait_status(sid, "done", "failed") == "done"
|
||||
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
|
||||
"hasToken"] is True
|
||||
|
||||
|
||||
class TestRebindingIsGatedToo:
|
||||
""""Just re-enter the password and get a fresh token" is the obvious thing
|
||||
to try when syncing is blocked — and it is the worst thing to try.
|
||||
|
||||
`garth.login()` is the same SSO endpoint that is doing the blocking, by a
|
||||
heavier path than the token refresh, and the limit is keyed to the account
|
||||
so a new device or network reaches the same wall.
|
||||
"""
|
||||
|
||||
def test_login_is_refused_inside_the_window(self, db, user):
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
with pytest.raises(garmin_auth.LoginRateLimited):
|
||||
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||
|
||||
def test_no_session_row_is_created_by_a_refusal(self, db, user):
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
with pytest.raises(garmin_auth.LoginRateLimited):
|
||||
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||
rows = db.query_all(
|
||||
"SELECT id FROM garmin_mfa_sessions WHERE user_id = ?", [user["id"]])
|
||||
assert rows == []
|
||||
|
||||
def test_a_data_429_does_not_block_rebinding(self, db, user, monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(garmin_auth.threading, "Thread",
|
||||
lambda **kw: type("T", (), {"start": lambda s: started.append(1)})())
|
||||
garmin_svc._note_rate_limit(user["id"], "data")
|
||||
garmin_auth.start_login(user["id"], "a@example.com", "pw")
|
||||
assert started == [1]
|
||||
|
||||
def test_force_overrules_the_estimate(self, db, user, monkeypatch):
|
||||
started = []
|
||||
monkeypatch.setattr(garmin_auth.threading, "Thread",
|
||||
lambda **kw: type("T", (), {"start": lambda s: started.append(1)})())
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
garmin_auth.start_login(user["id"], "a@example.com", "pw", force=True)
|
||||
assert started == [1]
|
||||
|
||||
def test_the_endpoint_answers_429_with_a_retry_hint(self, client, auth, db, user):
|
||||
garmin_svc._note_rate_limit(user["id"], "sso")
|
||||
resp = client.post("/api/garmin/login",
|
||||
json={"garminPassword": "pw", "garminEmail": "a@example.com"},
|
||||
headers=auth)
|
||||
assert resp.status_code == 429
|
||||
body = resp.get_json()
|
||||
assert body["retryAfterSeconds"] > 0
|
||||
assert "延长封锁" in body["error"]
|
||||
|
||||
Reference in New Issue
Block a user