Files
GarminHealthLab/backend/tests/test_garmin_mfa.py
ericwyuan 9503fca370 feat(auth): 接入 auth-hub 统一登录,网页登录与 Garmin 同步彻底分离
网页身份改由 auth-hub 做 OAuth2 + PKCE 单点登录,本地邮箱/密码登录与注册整条链路删除
(routes/auth.py、auth.py 的密码哈希、config.py 的 ALLOW_REGISTRATION)。Garmin 账号绑定/
同步保持完全独立、可选:routes/garmin.py 不再直接查 users 表,Garmin 邮箱回退统一走新增
的 services/garmin.py::get_remembered_email()(优先读 garmin_tokens 当前绑定,兼容早期账号
落在 users.garmin_email 的历史值),彻底把「你是谁」和「你绑没绑 Garmin」两件事拆开。

- db.py: users 表新增 auth_hub_sub/auth_hub_username,MIGRATIONS 补上这两列(此前遗漏导致
  已存在的生产 MariaDB 表永远不会自动加列);同时把历史遗留的 garmin_email/
  garmin_password_hash NOT NULL 约束在线迁移为可空,因为新账号不再在注册时收集这些字段。
- routes/auth.py: 修掉 /callback 路由重复拼接 /api/auth 前缀导致 404 的 bug。
- client: LoginPage 去掉本地登录/注册标签页,只保留 auth-hub 统一登录;登录成功/失败后都
  用 history.replaceState 清理地址栏,修掉 Framework7 browserHistory 读取
  /auth/callback?code=... 导致「找不到页面」的问题。
- 新增 test_auth_hub_client.py 锁定 find_or_create_user 按 auth_hub_sub 幂等——生产上曾经因为
  这个函数在没有该测试保护时被测试触发,误建过一个空账号,靠手工核对 health_data 计数才发现。
- 生产 auth-hub 侧另行为该项目注册了正式 client(未随本次提交变更,凭证只存在服务器 .env)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 23:12:17 +08:00

279 lines
10 KiB
Python

"""
Unit tests for the web-driven two-factor Garmin login.
No network and no real library: a stub Garmin/garth pair stands in, and its
`prompt_mfa` callback is invoked exactly the way garth invokes it — blocking,
mid-login — because that blocking behaviour is the whole reason this flow
needs a background thread and a database rendezvous.
"""
import time
import pytest
from services import garmin as garmin_svc
from services import garmin_auth
def wait_for(predicate, timeout=8.0, interval=0.05):
"""Poll until the background thread reaches the expected state."""
deadline = time.time() + timeout
while time.time() < deadline:
value = predicate()
if value:
return value
time.sleep(interval)
return None
def status_of(session_id):
row = garmin_auth.get_session(session_id)
return row["status"] if row else None
def wait_status(session_id, *wanted, timeout=8.0):
return wait_for(lambda: status_of(session_id) in wanted and status_of(session_id),
timeout=timeout)
class StubGarth:
profile = {"displayName": "Tester", "fullName": "Test User"}
def __init__(self, needs_mfa=True, accept_code="123456", fail_login=False):
self.needs_mfa = needs_mfa
self.accept_code = accept_code
self.fail_login = fail_login
self.seen_code = None
self.logged_in_with = None
def login(self, email, password, prompt_mfa=None):
self.logged_in_with = (email, password)
if self.fail_login:
raise RuntimeError("401 Unauthorized")
if self.needs_mfa:
# garth calls this synchronously, in the middle of the login.
self.seen_code = prompt_mfa()
if self.seen_code != self.accept_code:
raise RuntimeError("验证码错误")
def dumps(self):
return "token-blob"
class StubGarmin:
"""Class factory: `make()` returns something usable as `Garmin`."""
last = None
@classmethod
def make(cls, **garth_kwargs):
def factory(is_cn=False, **_):
instance = cls()
instance.garth = StubGarth(**garth_kwargs)
cls.last = instance
return instance
return lambda: factory
@pytest.fixture(autouse=True)
def _fast_polling(monkeypatch):
"""Keep the rendezvous poll short so tests stay quick."""
monkeypatch.setattr(garmin_auth, "POLL_INTERVAL_SECONDS", 0.05)
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 5)
class TestMfaFlow:
def test_login_parks_waiting_for_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "awaiting_code") == "awaiting_code"
def test_submitting_the_code_completes_the_login(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
ok, _ = garmin_auth.submit_code(sid, user["id"], "123456")
assert ok is True
assert wait_status(sid, "done", "failed") == "done"
def test_token_is_saved_on_success(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert garmin_svc.has_token(user["id"]) is True
assert garmin_svc.load_token(user["id"]) == "token-blob"
def test_the_code_reaches_garth(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "123456")
wait_status(sid, "done", "failed")
assert StubGarmin.last.garth.seen_code == "123456"
def test_account_without_mfa_completes_without_a_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw",
import_garmin=StubGarmin.make(needs_mfa=False),
)
assert wait_status(sid, "done", "failed") == "done"
assert garmin_svc.has_token(user["id"]) is True
def test_wrong_code_fails_with_a_reason(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.submit_code(sid, user["id"], "000000")
assert wait_status(sid, "done", "failed") == "failed"
assert "验证码错误" in garmin_auth.get_session(sid)["error"]
assert garmin_svc.has_token(user["id"]) is False
def test_bad_password_fails_before_any_code(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "wrong",
import_garmin=StubGarmin.make(fail_login=True),
)
assert wait_status(sid, "failed") == "failed"
assert "401" in garmin_auth.get_session(sid)["error"]
def test_timeout_when_no_code_arrives(self, db, user, monkeypatch):
monkeypatch.setattr(garmin_auth, "CODE_WAIT_SECONDS", 0.2)
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
assert wait_status(sid, "failed") == "failed"
assert "超时" in garmin_auth.get_session(sid)["error"]
class TestRendezvousIsNotInMemory:
"""The handoff must survive the code arriving on a different worker, so it
goes through the database rather than process memory."""
def test_code_written_directly_to_the_row_is_picked_up(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
# Exactly what another worker's request would do: write the row.
db.execute(
"UPDATE garmin_mfa_sessions SET code = ? WHERE id = ?", ["123456", sid]
)
assert wait_status(sid, "done", "failed") == "done"
def test_cancelling_releases_the_parked_thread(self, db, user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
garmin_auth.cancel(sid, user["id"])
# The row is gone, so the waiting thread must stop rather than spin
# until its timeout.
assert wait_for(lambda: garmin_auth.get_session(sid) is None)
class TestSessionIsolation:
def test_another_users_session_is_not_readable(self, db, user, make_user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
other = make_user("o@example.com")
assert garmin_auth.get_session(sid, other["id"]) is None
def test_another_user_cannot_submit_a_code(self, db, user, make_user):
sid = garmin_auth.start_login(
user["id"], "g@example.com", "pw", import_garmin=StubGarmin.make()
)
wait_status(sid, "awaiting_code")
other = make_user("o2@example.com")
ok, _ = garmin_auth.submit_code(sid, other["id"], "123456")
assert ok is False
def test_unknown_session_is_refused(self, db, user):
ok, msg = garmin_auth.submit_code("no-such-session", user["id"], "123456")
assert ok is False
assert "不存在" in msg
class TestPasswordHandling:
def test_password_is_never_written_to_the_session_row(self, db, user):
sid = garmin_auth.start_login(
user["id"], "hunter2@example.com", "SuperSecret123",
import_garmin=StubGarmin.make(),
)
wait_status(sid, "awaiting_code")
row = garmin_auth.get_session(sid)
assert "SuperSecret123" not in str(dict(row))
class TestEndpoints:
def test_all_require_auth(self, client):
assert client.post("/api/garmin/login", json={}).status_code == 401
assert client.get("/api/garmin/login-status?session=x").status_code == 401
assert client.post("/api/garmin/mfa", json={}).status_code == 401
def test_login_requires_a_password(self, client, auth):
r = client.post("/api/garmin/login", headers=auth, json={})
assert r.status_code == 400
def test_login_returns_a_session(self, client, auth, monkeypatch):
monkeypatch.setattr(
garmin_auth, "start_login", lambda *a, **k: "session-123"
)
r = client.post(
"/api/garmin/login",
headers=auth,
json={"garminEmail": "g@example.com", "garminPassword": "pw"},
)
assert r.status_code == 202
assert r.get_json()["session"] == "session-123"
def test_status_of_unknown_session_is_404(self, client, auth):
assert client.get(
"/api/garmin/login-status?session=nope", headers=auth
).status_code == 404
def test_mfa_requires_both_fields(self, client, auth):
assert client.post(
"/api/garmin/mfa", headers=auth, json={"session": "x"}
).status_code == 400
def test_full_flow_through_http(self, client, auth, user, db, monkeypatch):
monkeypatch.setattr(
garmin_svc, "_import_garmin", StubGarmin.make()
)
r = client.post(
"/api/garmin/login",
headers=auth,
json={"garminEmail": "g@example.com", "garminPassword": "pw"},
)
sid = r.get_json()["session"]
assert wait_status(sid, "awaiting_code") == "awaiting_code"
assert client.get(
f"/api/garmin/login-status?session={sid}", headers=auth
).get_json()["status"] == "awaiting_code"
r = client.post(
"/api/garmin/mfa", headers=auth, json={"session": sid, "code": "123456"}
)
assert r.status_code == 200
assert wait_status(sid, "done", "failed") == "done"
assert client.get("/api/garmin/auth-status", headers=auth).get_json()[
"hasToken"] is True