5ca64898b7
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 22s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m27s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m9s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m53s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m23s
AI Code Review / AI Code Review (pull_request) Successful in 6m32s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m34s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 18m5s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 6s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m30s
- WechatBindUseCase:绑定到当前登录账号不建新号;openid/unionid 已绑其他账号 409;已绑同一微信幂等 - WechatUnbindUseCase:解绑前守卫——必须有已验证手机或真实已验证邮箱(随机密码 hash/占位邮箱不算兜底,口径同 binding_complete) - /auth/me 增加 wechat_bound 字段,供设置页判断绑定状态 - state 复用 RedisStateStore CSRF 校验;22 个新单测,diff coverage 100%
252 lines
7.8 KiB
Python
252 lines
7.8 KiB
Python
"""#1719:已登录用户微信绑定/解绑 Use Case 测试。
|
|
|
|
覆盖:
|
|
- bind:幂等重复绑定、未绑定成功、当前账号已绑其他微信、openid/unionid 冲突 409、用户不存在
|
|
- unbind:成功清 openid+unionid、未绑定拒绝、无其他登录方式拒绝、密码/手机/真实邮箱各兜底放行、用户不存在
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from packages.application.auth.wechat_bind_use_case import (
|
|
WechatBindRequest,
|
|
WechatBindUseCase,
|
|
WechatUnbindUseCase,
|
|
)
|
|
|
|
|
|
def _user(
|
|
user_id="u-1",
|
|
wechat_openid=None,
|
|
wechat_unionid=None,
|
|
password_hash="hashed-pw",
|
|
phone=None,
|
|
phone_verified=False,
|
|
email="user@example.com",
|
|
email_verified=True,
|
|
):
|
|
return SimpleNamespace(
|
|
id=user_id,
|
|
wechat_openid=wechat_openid,
|
|
wechat_unionid=wechat_unionid,
|
|
password_hash=password_hash,
|
|
phone=phone,
|
|
phone_verified=phone_verified,
|
|
email=email,
|
|
email_verified=email_verified,
|
|
)
|
|
|
|
|
|
class _FakeRepo:
|
|
"""内存仓储:按 id/openid/unionid 建索引,save 原地更新。"""
|
|
|
|
def __init__(self, users):
|
|
self.users = {u.id: u for u in users}
|
|
self.saved = []
|
|
|
|
def find_by_id(self, user_id):
|
|
return self.users.get(user_id)
|
|
|
|
def find_by_wechat_openid(self, openid):
|
|
for u in self.users.values():
|
|
if u.wechat_openid == openid:
|
|
return u
|
|
return None
|
|
|
|
def find_by_wechat_unionid(self, unionid):
|
|
if not unionid:
|
|
return None
|
|
for u in self.users.values():
|
|
if u.wechat_unionid == unionid:
|
|
return u
|
|
return None
|
|
|
|
def save(self, user):
|
|
self.saved.append(user)
|
|
|
|
|
|
# ==================== bind ====================
|
|
|
|
|
|
def test_bind_success_when_not_bound():
|
|
user = _user()
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatBindUseCase(repo).bind(
|
|
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
|
)
|
|
assert err is None
|
|
assert status == 200
|
|
assert result.user.wechat_openid == "wx-openid-1"
|
|
assert result.user.wechat_unionid == "wx-union-1"
|
|
assert repo.saved == [user]
|
|
|
|
|
|
def test_bind_idempotent_same_openid():
|
|
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatBindUseCase(repo).bind(
|
|
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-1")
|
|
)
|
|
assert err is None
|
|
assert status == 200
|
|
assert result.user is user
|
|
assert repo.saved == [] # 幂等不写库
|
|
|
|
|
|
def test_bind_conflict_user_already_bound_other_wechat():
|
|
user = _user(wechat_openid="wx-old")
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-new"))
|
|
assert result is None
|
|
assert status == 409
|
|
assert "已绑定微信" in err
|
|
|
|
|
|
def test_bind_conflict_openid_used_by_other_user():
|
|
user = _user(user_id="u-1")
|
|
other = _user(user_id="u-2", wechat_openid="wx-openid-1")
|
|
repo = _FakeRepo([user, other])
|
|
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid="wx-openid-1"))
|
|
assert result is None
|
|
assert status == 409
|
|
assert "已绑定其他账号" in err
|
|
assert user.wechat_openid is None # 未写库
|
|
|
|
|
|
def test_bind_conflict_unionid_used_by_other_user():
|
|
user = _user(user_id="u-1")
|
|
# openid 不同,但 unionid 指向同一微信主体
|
|
other = _user(user_id="u-2", wechat_openid="wx-other", wechat_unionid="wx-union-x")
|
|
repo = _FakeRepo([user, other])
|
|
result, err, status = WechatBindUseCase(repo).bind(
|
|
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-x")
|
|
)
|
|
assert result is None
|
|
assert status == 409
|
|
assert "微信主体" in err
|
|
|
|
|
|
def test_bind_missing_openid_returns_400():
|
|
repo = _FakeRepo([_user()])
|
|
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="u-1", openid=""))
|
|
assert result is None
|
|
assert status == 400
|
|
assert "openid" in err
|
|
|
|
|
|
def test_bind_user_not_found_returns_404():
|
|
repo = _FakeRepo([])
|
|
result, err, status = WechatBindUseCase(repo).bind(WechatBindRequest(user_id="ghost", openid="wx-openid-1"))
|
|
assert result is None
|
|
assert status == 404
|
|
|
|
|
|
def test_bind_fills_unionid_when_existing_user_has_none():
|
|
# 用户历史上只绑了 openid(unionid 为空),再次绑定时补齐 unionid 不冲突
|
|
user = _user(wechat_openid="wx-openid-1", wechat_unionid=None)
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatBindUseCase(repo).bind(
|
|
WechatBindRequest(user_id="u-1", openid="wx-openid-1", unionid="wx-union-new")
|
|
)
|
|
# openid 相同 → 幂等成功(不覆盖 unionid,保持数据稳定)
|
|
assert err is None
|
|
assert status == 200
|
|
|
|
|
|
# ==================== unbind ====================
|
|
|
|
|
|
def test_unbind_success_with_real_verified_email():
|
|
# 默认 _user 即 real@example.com 且 email_verified=True
|
|
user = _user(wechat_openid="wx-openid-1", wechat_unionid="wx-union-1")
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert err is None
|
|
assert status == 200
|
|
assert result.user.wechat_openid is None
|
|
assert result.user.wechat_unionid is None
|
|
assert repo.saved == [user]
|
|
|
|
|
|
def test_unbind_rejected_when_only_random_password_hash():
|
|
# 微信注册用户:随机密码 hash 存在、邮箱是 @wechat.local 占位、无手机 → 不允许解绑
|
|
user = _user(
|
|
wechat_openid="wx-openid-1",
|
|
password_hash="random-secret-hash",
|
|
email="abc@wechat.local",
|
|
email_verified=True,
|
|
)
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert result is None
|
|
assert status == 400
|
|
assert "登录方式" in err
|
|
assert user.wechat_openid == "wx-openid-1" # 未写库
|
|
|
|
|
|
def test_unbind_allowed_with_verified_phone_even_without_password():
|
|
user = _user(
|
|
wechat_openid="wx-openid-1",
|
|
password_hash="",
|
|
phone="13800000000",
|
|
phone_verified=True,
|
|
email="wx@wechat.local",
|
|
email_verified=True,
|
|
)
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert err is None
|
|
assert status == 200
|
|
assert result.user.wechat_openid is None
|
|
|
|
|
|
def test_unbind_rejected_when_no_other_login_method():
|
|
# 无手机、邮箱占位 → 唯一登录方式就是微信,禁止解绑
|
|
user = _user(
|
|
wechat_openid="wx-openid-1",
|
|
password_hash="",
|
|
email="abc@wechat.local",
|
|
email_verified=True,
|
|
)
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert result is None
|
|
assert status == 400
|
|
assert "登录方式" in err
|
|
assert user.wechat_openid == "wx-openid-1" # 未写库
|
|
|
|
|
|
def test_unbind_not_bound_returns_400():
|
|
user = _user() # 未绑定
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert result is None
|
|
assert status == 400
|
|
assert "未绑定" in err
|
|
|
|
|
|
def test_unbind_user_not_found_returns_404():
|
|
repo = _FakeRepo([])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("ghost")
|
|
assert result is None
|
|
assert status == 404
|
|
|
|
|
|
def test_unbind_unverified_phone_does_not_count():
|
|
# 手机未验证不算有效登录方式
|
|
user = _user(
|
|
wechat_openid="wx-openid-1",
|
|
password_hash="",
|
|
phone="13800000000",
|
|
phone_verified=False,
|
|
email="abc@wechat.local",
|
|
email_verified=True,
|
|
)
|
|
repo = _FakeRepo([user])
|
|
result, err, status = WechatUnbindUseCase(repo).unbind("u-1")
|
|
assert result is None
|
|
assert status == 400
|