Files
shaguabijia-app-server/tests/test_auth.py
T
marco b50495bebe feat: 短信接入极光真实发送 + 新增运营 admin 后台子应用
短信(SMS_MOCK 切 mock/real):
- integrations/sms.py 重写: real 模式走极光短信 REST /v1/messages 自定义验证码(本服务 secrets
  生成 6 位码 + 进程内存 + 本地校验一次性/防爆破), 鉴权复用极光一键登录 JG_APP_KEY/MASTER_SECRET
  (同一极光应用, 上线只需 SMS_MOCK=false); mock 仍"任意6位通过"不动其余测试
- 防刷四层: 单号冷却 + 单号每日上限 + 单IP rate_limit(/sms/send 10/min、/sms/login 20/min)
  + 单码失败次数作废; SmsError 带 status_code 映射 429/503/400
- config 增 SMS_SEND_ENDPOINT/SIGN_ID/TEMPLATE_ID/CODE_LENGTH/DAILY_LIMIT/MAX_VERIFY_ATTEMPTS;
  test_auth 加 real 模式单测; sms.md/后端技术实现/待办账本同步

admin 后台(app/admin/ 独立子应用, uvicorn app.admin.main:admin_app :8771):
- 复用主仓 models/repositories/integrations + 同库, 鉴权完全隔离(ADMIN_JWT_SECRET≠JWT_SECRET_KEY
  + payload typ=admin + bcrypt 密码 + 可选 IP 白名单); 主 app 不 import 本包, admin 崩不影响主进程
- 路由: 登录 / 账号管理(RBAC: super_admin·finance·operator) / 用户列表+360详情+封禁+手动调币 /
  钱包流水 / 提现重试对账 / 反馈工单 / 数据大盘; 全写操作落 admin_audit_log(涉钱与业务写同事务)
- 涉钱逻辑(调微信/退款/对账)复用 app.repositories.wallet 不重写
- 新增 models/admin.py(AdminUser/AdminAuditLog) + admin_tables 迁移 + create_admin.py +
  deploy/shaguabijia-admin.service; 依赖加 bcrypt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:02:41 +08:00

209 lines
7.5 KiB
Python

"""auth 流程测试。
- mock 路径(SMS_MOCK=true,conftest 设置):验证码不真发,校验放行任意 6 位,测 JWT 闭环。
- real 路径:monkeypatch SMS_MOCK=false + 拦 httpx,测极光发送协议 + 真实校验(一次性/
防爆破/余额回滚),不真发短信。
"""
from __future__ import annotations
import time
import pytest
from app.integrations import sms
def _reset(phone: str) -> None:
"""清该号的进程内存状态,隔离 real 模式用例。"""
sms._codes.pop(phone, None)
sms._last_sent.pop(phone, None)
sms._daily_count.pop(phone, None)
class _OkResp:
"""模拟极光发送成功响应。"""
status_code = 200
def json(self):
return {"msg_id": "test-msg-id"}
# ============================ mock 路径(JWT 闭环)============================
def test_sms_login_and_me_flow(client) -> None:
"""sms_send → sms_login(mock 任意6位)→ /me → refresh/logout 全闭环。"""
phone = "13800138000"
r = client.post("/api/v1/auth/sms/send", json={"phone": phone})
assert r.status_code == 200, r.text
body = r.json()
assert body["sent"] is True
assert body["mock"] is True
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
assert r.status_code == 200, r.text
data = r.json()
assert "access_token" in data and "refresh_token" in data
assert data["user"]["phone"] == phone
assert data["user"]["register_channel"] == "sms"
access, refresh = data["access_token"], data["refresh_token"]
r = client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200, r.text
assert r.json()["phone"] == phone
r = client.get("/api/v1/auth/me")
assert r.status_code == 401
r = client.post("/api/v1/auth/refresh", json={"refresh_token": refresh})
assert r.status_code == 200, r.text
assert "access_token" in r.json()
# 用 access 当 refresh → 拒绝(防 token 类型混用)
r = client.post("/api/v1/auth/refresh", json={"refresh_token": access})
assert r.status_code == 401
r = client.post("/api/v1/auth/logout", headers={"Authorization": f"Bearer {access}"})
assert r.status_code == 200
assert r.json()["ok"] is True
def test_sms_send_too_frequent(client) -> None:
phone = "13900139000"
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 200
assert client.post("/api/v1/auth/sms/send", json={"phone": phone}).status_code == 429
def test_sms_login_bad_code(client) -> None:
"""mock 下校验位数:5 位过 schema 但业务要 6 位 → 400。"""
phone = "13700137000"
client.post("/api/v1/auth/sms/send", json={"phone": phone})
r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "12345"})
assert r.status_code == 400
assert "invalid sms code" in r.json()["detail"]
def test_phone_format_validation(client) -> None:
r = client.post("/api/v1/auth/sms/send", json={"phone": "1234567"})
assert r.status_code == 422
def test_sms_daily_limit(monkeypatch) -> None:
"""单号每日上限(send 的防刷逻辑 mock/real 都跑;函数级绕开 60s 冷却)。"""
phone = "13511135000"
_reset(phone)
limit = 3
monkeypatch.setattr(sms.settings, "SMS_DAILY_LIMIT_PER_PHONE", limit)
for _ in range(limit):
sms.send_code(phone)
sms._last_sent.pop(phone, None) # 绕开冷却,只测每日上限
with pytest.raises(sms.SmsError, match="上限"):
sms.send_code(phone)
# ============================ real 路径(不真发)============================
def test_sms_real_send_calls_jiguang(monkeypatch) -> None:
"""real 模式:本服务生成 code 并按协议调极光 /v1/messages(拦 httpx,不真发)。"""
phone = "13422134000"
_reset(phone)
captured: dict = {}
def _fake_post(url, json, headers, timeout): # noqa: A002
captured.update(url=url, body=json, auth=headers.get("Authorization", ""))
return _OkResp()
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", _fake_post)
sms.send_code(phone)
assert captured["url"] == sms.settings.SMS_SEND_ENDPOINT
assert captured["body"]["mobile"] == phone
assert captured["body"]["sign_id"] == sms.settings.SMS_SIGN_ID
assert captured["body"]["temp_id"] == sms.settings.SMS_TEMPLATE_ID
assert captured["body"]["temp_para"]["code"] == sms._codes[phone].code
assert captured["auth"].startswith("Basic ")
def test_sms_real_verify_one_time_and_wrong(monkeypatch) -> None:
"""real 校验:错误码拒(不消费)→ 正确码成功 → 验过即作废。"""
phone = "13455134000"
_reset(phone)
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp())
sms.send_code(phone)
code = sms._codes[phone].code
wrong = "000000" if code != "000000" else "111111"
assert sms.verify_code(phone, wrong) is False
assert sms.verify_code(phone, code) is True
assert sms.verify_code(phone, code) is False # 已作废
def test_sms_real_verify_attempts_exhausted(monkeypatch) -> None:
"""real 校验:错误次数到上限即作废,正确码也不再通过(防爆破)。"""
phone = "13466134000"
_reset(phone)
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.settings, "SMS_MAX_VERIFY_ATTEMPTS", 3)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _OkResp())
sms.send_code(phone)
code = sms._codes[phone].code
wrong = "000000" if code != "000000" else "111111"
for _ in range(3):
assert sms.verify_code(phone, wrong) is False
assert sms.verify_code(phone, code) is False # 超限作废
def test_sms_real_balance_error_keeps_cooldown(monkeypatch) -> None:
"""极光余额不足(50014)→ SmsError(503);保留冷却(失败也限速,防重试狂打),只清未发出的码。"""
phone = "13433134000"
_reset(phone)
class _ErrResp:
status_code = 403
def json(self):
return {"error": {"code": 50014, "message": "no money"}}
monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
monkeypatch.setattr(sms.httpx, "post", lambda *a, **k: _ErrResp())
with pytest.raises(sms.SmsError) as ei:
sms.send_code(phone)
assert ei.value.status_code == 503
assert phone not in sms._codes # 没发出去的码已清
assert phone in sms._last_sent # 冷却保留:失败也限速
# 立即重试 → 被冷却挡下(429),不会再打极光
with pytest.raises(sms.SmsError) as ei2:
sms.send_code(phone)
assert ei2.value.status_code == 429
def test_sms_gc_purges_stale_only(monkeypatch) -> None:
"""GC 清过期码 / 旧冷却 / 隔日计数,但不动今天有效的(阈值设 0 强制每次扫)。"""
monkeypatch.setattr(sms, "_GC_THRESHOLD", 0)
sms._codes.clear()
sms._last_sent.clear()
sms._daily_count.clear()
now = time.time()
sms._codes["stale"] = sms._CodeRecord(code="111111", expires_at=now - 1)
sms._codes["fresh"] = sms._CodeRecord(code="222222", expires_at=now + 999)
sms._last_sent["old"] = now - 99999
sms._last_sent["recent"] = now
sms._daily_count["yesterday"] = ("2000-01-01", 3)
sms._daily_count["today"] = (sms._today(), 1)
sms._gc(now)
assert "stale" not in sms._codes and "fresh" in sms._codes
assert "old" not in sms._last_sent and "recent" in sms._last_sent
assert "yesterday" not in sms._daily_count and "today" in sms._daily_count