Archived
为什么:注册是站点唯一的建号入口,84 行里五条「建号前失败」路径各自 手写一遍 restore_verify_code(有的还带失败日志),防刷双闸与三种建号 异常的口径全埋在分支里,改任何一条都要通读全函数核对还原时序。 拆出(顺序 = 原执行顺序,逐条语义对应): - _validate_registration_input:入参四道闸(协议/用户名/密码/邮箱), 按序返回第一条错误文案 - _reject_registration:统一拒绝出口——还原已消费验证码(可选失败 日志);五处 restore 收敛为一处,docstring 写明「为何必须还原」 - _register_credits:赠送闸(同设备重复注册归零;顺带删除 credits<0 死分支——_safe_register_credits 已保证非负) - _issue_registration_session:成功收尾(login_seq 递增 + 日志 + Cookie) 先写测试后动代码:新增 tests/test_register_flow.py 9 例,在未改动 实现上一次跑绿。验证码由 check_verify_code DELETE 原子消费,测试用 「失败路径后 email_codes 行仍在」做 restore 实证断言(不 mock 还原); 其中用户名重名路径用端到端方式验证——同一验证码不重发即可二次注册。 覆盖此前零测试的路径:IP 闸、设备赠送闸、IntegrityError/ValueError/ 读回 None 三种建号异常映射、非预期异常 500 泛化文案。 门禁:ruff 全过 · pyright 0/0 · pytest 339 passed(330→339)。
189 lines
7.5 KiB
Python
189 lines
7.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""register 端点失败路径契约(restore_verify_code / 防刷闸 / create_user 异常映射)。
|
|
|
|
test_register.py 覆盖成功主路径;本文件钉住五条「还原验证码」失败路径与
|
|
create_user 的三种异常映射,作为 register 拆解的等价性验证器。
|
|
|
|
验证码由 check_verify_code 以 DELETE 原子消费:失败路径后 email_codes 行
|
|
仍然存在 ⇔ restore_verify_code 确实被调用。以此做实证断言,不 mock 还原。
|
|
"""
|
|
import sqlite3
|
|
import time
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
import auth_system as auth
|
|
import core.db as db
|
|
|
|
SECRET = 'register-flow-test-secret'
|
|
|
|
|
|
@pytest.fixture()
|
|
def env(tmp_path, monkeypatch):
|
|
db_path = tmp_path / 'site.db'
|
|
monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path)
|
|
monkeypatch.setattr(auth.db, 'DB_PATH', db_path)
|
|
monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups')
|
|
monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key')
|
|
monkeypatch.setattr(db, 'DATA_DIR', tmp_path)
|
|
monkeypatch.setattr(db, 'DB_PATH', db_path)
|
|
auth.init_db()
|
|
monkeypatch.setattr('web.deps.secret_key', lambda: SECRET)
|
|
import web.auth_state as auth_state
|
|
auth_state._SEND_CODE_IP.clear()
|
|
from web.app import create_app
|
|
return TestClient(create_app())
|
|
|
|
|
|
def _seed_code(email: str, code: str, purpose: str = 'register'):
|
|
conn = auth.get_conn()
|
|
conn.execute('DELETE FROM email_codes WHERE email = ? AND purpose = ?', (email, purpose))
|
|
conn.execute('INSERT INTO email_codes (email, code, expires_at, created_at, purpose) '
|
|
'VALUES (?, ?, ?, ?, ?)',
|
|
(email, code, int(time.time()) + 600, int(time.time()), purpose))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def _code_rows(email: str, purpose: str = 'register') -> list[str]:
|
|
conn = auth.get_conn()
|
|
rows = conn.execute('SELECT code FROM email_codes WHERE email = ? AND purpose = ?',
|
|
(email, purpose)).fetchall()
|
|
conn.close()
|
|
return [r['code'] for r in rows]
|
|
|
|
|
|
def _register_logs() -> list[tuple[str, str]]:
|
|
conn = auth.get_conn()
|
|
rows = conn.execute("SELECT type, detail FROM logs WHERE action = '注册'").fetchall()
|
|
conn.close()
|
|
return [(r['type'], r['detail']) for r in rows]
|
|
|
|
|
|
def _post(env, username: str, email: str, code: str, **extra):
|
|
return env.post('/auth/register', json={
|
|
'username': username, 'password': 'Passw0rd12', 'email': email,
|
|
'code': code, 'agree': '1', **extra})
|
|
|
|
|
|
def test_register_input_validation_order(env):
|
|
"""四项入参校验按序:协议 → 用户名 → 密码 → 邮箱长度。"""
|
|
payload = {'username': 'okuser', 'password': 'Passw0rd12', 'email': 'v@example.com',
|
|
'code': '000000', 'agree': '1'}
|
|
r = env.post('/auth/register', json={**payload, 'agree': '0'})
|
|
assert r.json()['message'].startswith('请先阅读并同意')
|
|
|
|
r = env.post('/auth/register', json={**payload, 'username': 'ab'})
|
|
assert '用户名需 3-20 位' in r.json()['message']
|
|
|
|
r = env.post('/auth/register', json={**payload, 'password': 'short'})
|
|
assert '密码长度需在 8-128 位之间' in r.json()['message']
|
|
|
|
r = env.post('/auth/register', json={**payload, 'email': 'e' * 300})
|
|
assert r.json()['message'] == '邮箱格式不正确'
|
|
|
|
|
|
def test_dup_username_restores_code_end_to_end(env):
|
|
"""用户名重名拒绝后,同一验证码无需重发即可再次使用(restore 实证)。"""
|
|
auth.create_user('taken', 'Passw0rd12')
|
|
_seed_code('x@example.com', '123456')
|
|
|
|
r = _post(env, 'taken', 'x@example.com', '123456')
|
|
assert r.json()['message'] == '用户名已被注册'
|
|
|
|
r2 = _post(env, 'freshname', 'x@example.com', '123456')
|
|
assert r2.json()['success'] is True
|
|
|
|
|
|
def test_dup_email_keeps_code_row(env):
|
|
"""邮箱重名拒绝后验证码行仍在(被 check_verify_code 消费后又还原)。"""
|
|
auth.create_user('holder', 'Passw0rd12', email='y@example.com')
|
|
_seed_code('y@example.com', '111111')
|
|
|
|
r = _post(env, 'maildup', 'y@example.com', '111111')
|
|
assert '该邮箱已被注册' in r.json()['message']
|
|
assert _code_rows('y@example.com') == ['111111']
|
|
|
|
|
|
def test_ip_exceeded_restores_code_and_logs(env, monkeypatch):
|
|
"""IP 闸触发:还原验证码 + 写失败日志 + 专属文案。"""
|
|
monkeypatch.setattr(auth, 'register_ip_exceeded', lambda ip, **k: True)
|
|
_seed_code('ip@example.com', '222222')
|
|
|
|
r = _post(env, 'ipuser', 'ip@example.com', '222222')
|
|
assert r.status_code == 400
|
|
assert '注册过于频繁' in r.json()['message']
|
|
assert _code_rows('ip@example.com') == ['222222']
|
|
assert ('fail', '同 IP 24h 注册次数超限') in _register_logs()
|
|
|
|
|
|
def test_device_used_grants_zero_credits(env):
|
|
"""同设备重复注册:账号照建、赠送归零;首注设备正常赠送。"""
|
|
_seed_code('d1@example.com', '333333')
|
|
r1 = _post(env, 'firstdev', 'd1@example.com', '333333', device_id='dev-9')
|
|
assert r1.json()['success'] is True
|
|
assert '已赠送' in r1.json()['message']
|
|
|
|
_seed_code('d2@example.com', '444444')
|
|
r2 = _post(env, 'seconddev', 'd2@example.com', '444444', device_id='dev-9')
|
|
assert r2.json()['success'] is True
|
|
assert r2.json()['message'] == '注册成功'
|
|
|
|
conn = auth.get_conn()
|
|
row = conn.execute("SELECT duration_seconds FROM users WHERE username = 'seconddev'").fetchone()
|
|
conn.close()
|
|
assert row['duration_seconds'] == 0
|
|
|
|
|
|
def test_integrityerror_maps_and_restores(env, monkeypatch):
|
|
"""并发撞唯一索引(建号 IntegrityError):统一文案 + 还原验证码。"""
|
|
def _boom(*a, **k):
|
|
raise sqlite3.IntegrityError('UNIQUE constraint failed: users.username')
|
|
|
|
monkeypatch.setattr(auth, 'create_user', _boom)
|
|
_seed_code('ie@example.com', '555555')
|
|
|
|
r = _post(env, 'ieuser', 'ie@example.com', '555555')
|
|
assert r.status_code == 400
|
|
assert r.json()['message'] == '用户名或邮箱已被注册,请换一个再试'
|
|
assert _code_rows('ie@example.com') == ['555555']
|
|
|
|
|
|
def test_valueerror_maps_and_restores(env, monkeypatch):
|
|
"""create_user 业务拒绝(ValueError):原文案透出 + 失败日志 + 还原验证码。"""
|
|
def _boom(*a, **k):
|
|
raise ValueError('用户名含敏感词')
|
|
|
|
monkeypatch.setattr(auth, 'create_user', _boom)
|
|
_seed_code('ve@example.com', '666666')
|
|
|
|
r = _post(env, 'veuser', 've@example.com', '666666')
|
|
assert r.status_code == 400
|
|
assert r.json()['message'] == '用户名含敏感词'
|
|
assert _code_rows('ve@example.com') == ['666666']
|
|
assert ('fail', '用户名含敏感词') in _register_logs()
|
|
|
|
|
|
def test_user_none_maps_and_restores(env, monkeypatch):
|
|
"""建号事务提交却读不回:按注册失败处理 + 还原验证码 + 失败日志。"""
|
|
monkeypatch.setattr(auth, 'create_user', lambda *a, **k: None)
|
|
_seed_code('none@example.com', '777777')
|
|
|
|
r = _post(env, 'noneuser', 'none@example.com', '777777')
|
|
assert r.status_code == 400
|
|
assert r.json()['message'] == '注册失败,请稍后重试'
|
|
assert _code_rows('none@example.com') == ['777777']
|
|
assert ('fail', '建号后读取用户失败') in _register_logs()
|
|
|
|
|
|
def test_unexpected_error_is_500(env, monkeypatch):
|
|
"""非预期异常:泛化文案 + HTTP 500(不透出内部信息)。"""
|
|
def _boom(*a, **k):
|
|
raise RuntimeError('db busy')
|
|
|
|
monkeypatch.setattr(auth, 'check_verify_code', _boom)
|
|
r = _post(env, 'boomuser', 'boom@example.com', '888888')
|
|
assert r.status_code == 500
|
|
assert r.json()['message'] == '注册失败,请稍后重试'
|