# -*- 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 asyncio 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' async def _fake_secret_key() -> str: return SECRET def _connect() -> sqlite3.Connection: """直连沙箱库做只读/播种(auth.get_conn 已异步化,测试用裸连接更直接)。""" conn = sqlite3.connect(str(auth.db.DB_PATH)) conn.row_factory = sqlite3.Row return conn @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) asyncio.run(auth.init_db()) monkeypatch.setattr('web.deps.secret_key', _fake_secret_key) 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 = _connect() 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 = _connect() 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 = _connect() 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 实证)。""" asyncio.run(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 消费后又还原)。""" asyncio.run(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 闸触发:还原验证码 + 写失败日志 + 专属文案。""" async def _exceeded(ip, **k): return True monkeypatch.setattr(auth, 'register_ip_exceeded', _exceeded) _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 = _connect() 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): """建号事务提交却读不回:按注册失败处理 + 还原验证码 + 失败日志。""" async def _none(*a, **k): return None monkeypatch.setattr(auth, 'create_user', _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'] == '注册失败,请稍后重试'