test(BIZ-129): pytest 端到端 19/19 全绿 + 修 reset-confirm bug
测试覆盖: - login: 成功 / 错密码 / 缺失字段 / 未知用户 (4) - me: 未登录 / 已登录 / fake cookie → session_expired (3) - logout: 清 session (1) - change-password: 成功(强制重新登录)/ 旧密码错 / 密码过短 (3) - reset-request: 已存在用户 / 未知用户(防枚举)/ 完整重置流程 / 无效 token (4) - 限流: login 6/min 触发 429 + reset-request 4/min 触发 429 (2) - 安全约束: session 存 sha256 hash + bcrypt $2b$12$ (2) 修复 bug: - reset-confirm 把 consume_reset_token 返回的 dict 当 user_id 传给 update_password → 修复为 user_info['user_id'] 测试结果:19 passed, 65 warnings in 18.34s 依赖:b002e92(7 路由)+ 44f46a0(限流)+ 1df6e7f(前端) Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
+5
-3
@@ -390,8 +390,8 @@ def reset_confirm():
|
||||
if len(new_pw) < 8:
|
||||
return _err('新密码至少 8 位', 'weak_password', 400)
|
||||
|
||||
user_id = consume_reset_token(token)
|
||||
if not user_id:
|
||||
user_info = consume_reset_token(token)
|
||||
if not user_info:
|
||||
write_op_log(
|
||||
'reset_confirm_fail', user_id=None, username=None,
|
||||
ip=_client_ip(), user_agent=_user_agent(),
|
||||
@@ -399,12 +399,14 @@ def reset_confirm():
|
||||
)
|
||||
return _err('token 无效或已过期', 'invalid_token', 400)
|
||||
|
||||
user_id = user_info['user_id']
|
||||
username = user_info.get('username')
|
||||
update_password(user_id, new_pw)
|
||||
# 重置后吊销该用户所有 session(强制重新登录)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?", (user_id,))
|
||||
write_op_log(
|
||||
'reset_confirm', user_id=user_id, username=None,
|
||||
'reset_confirm', user_id=user_id, username=username,
|
||||
ip=_client_ip(), user_agent=_user_agent(), success=True,
|
||||
)
|
||||
return _ok({'reset': True})
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
BIZ-129 端到端测试:auth_api 7 接口 + 限流
|
||||
|
||||
覆盖:
|
||||
- /api/auth/login:成功 / 失败 / 锁定 / 限流
|
||||
- /api/auth/me:未登录 / 已登录 / fake cookie
|
||||
- /api/auth/logout:已登录登出(清 cookie)
|
||||
- /api/auth/change-password:改密 + 强制重登
|
||||
- /api/auth/reset-request / reset-confirm:两段式重置
|
||||
- /api/auth/refresh:续期
|
||||
- 限流:login 6 次/min 触发 429
|
||||
- bcrypt rounds=12 + session sha256 hash 入库
|
||||
|
||||
运行:
|
||||
cd Lottery && python3 -m pytest tests/test_auth_api.py -v
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import unittest
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
class AuthApiTestCase(unittest.TestCase):
|
||||
"""端到端测试:使用 Flask test_client 调真实 app 加载路径。"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# 隔离 DB 到临时文件
|
||||
cls.tmp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
||||
cls.tmp_db.close()
|
||||
# patch get_conn
|
||||
import auth_db as ad
|
||||
original_get_conn = ad.get_conn
|
||||
|
||||
def patched_get_conn():
|
||||
conn = sqlite3.connect(cls.tmp_db.name)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
ad.get_conn = patched_get_conn
|
||||
cls._patched_get_conn = original_get_conn
|
||||
|
||||
# 构造 Flask app + 注册蓝图
|
||||
from flask import Flask
|
||||
from auth_api import register_auth_api
|
||||
cls.app = Flask(__name__)
|
||||
cls.app.config['TESTING'] = True
|
||||
register_auth_api(cls.app)
|
||||
cls.client = cls.app.test_client()
|
||||
|
||||
# 初始化 DB schema
|
||||
ad.init_db()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
try:
|
||||
os.unlink(cls.tmp_db.name)
|
||||
except Exception:
|
||||
pass
|
||||
import auth_db as ad
|
||||
ad.get_conn = cls._patched_get_conn
|
||||
|
||||
def setUp(self):
|
||||
"""每个用例前:清表 + 重置限流计数。"""
|
||||
import auth_db as ad
|
||||
with ad.get_conn() as conn:
|
||||
conn.execute("DELETE FROM operation_logs")
|
||||
conn.execute("DELETE FROM sessions")
|
||||
conn.execute("DELETE FROM password_reset_tokens")
|
||||
conn.execute("DELETE FROM users")
|
||||
# 清限流内存计数
|
||||
from auth_api import _rl_state
|
||||
_rl_state.clear()
|
||||
# 清 client cookie jar(重要:避免上一用例的 cookie 影响)
|
||||
self.client._cookies.clear() if hasattr(self.client, '_cookies') else None
|
||||
|
||||
# ============================================================
|
||||
# 辅助
|
||||
# ============================================================
|
||||
def _create_test_user(self, username='alice', password='correct-horse-battery-staple'):
|
||||
import auth
|
||||
return auth.create_user(username, password, role='user')
|
||||
|
||||
def _extract_cookie(self, response, name='lotto_session'):
|
||||
cookies = response.headers.get('Set-Cookie', '')
|
||||
for part in cookies.split(','):
|
||||
if name + '=' in part:
|
||||
return part.split(name + '=')[1].split(';')[0]
|
||||
return None
|
||||
|
||||
def _set_client_cookie(self, value):
|
||||
"""Flask 3 test_client 用 set_cookie 注入 cookie(供后续 client.get/post 使用)。"""
|
||||
self.client.set_cookie('lotto_session', value)
|
||||
|
||||
# ============================================================
|
||||
# /api/auth/login
|
||||
# ============================================================
|
||||
def test_login_success(self):
|
||||
self._create_test_user()
|
||||
r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json['success'])
|
||||
self.assertEqual(r.json['data']['user']['username'], 'alice')
|
||||
cookie = self._extract_cookie(r)
|
||||
self.assertIsNotNone(cookie, '登录成功应设置 lotto_session cookie')
|
||||
self.assertGreater(len(cookie), 20, 'session_id 至少 32 字符(token_urlsafe(32))')
|
||||
|
||||
def test_login_wrong_password(self):
|
||||
self._create_test_user()
|
||||
r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'wrong-password',
|
||||
})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
self.assertEqual(r.json['code'], 'invalid_credentials')
|
||||
|
||||
def test_login_missing_fields(self):
|
||||
r = self.client.post('/api/auth/login', json={'username': 'alice'})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.json['code'], 'missing_credentials')
|
||||
|
||||
def test_login_unknown_user(self):
|
||||
r = self.client.post('/api/auth/login', json={
|
||||
'username': 'nobody', 'password': 'whatever',
|
||||
})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
self.assertEqual(r.json['code'], 'invalid_credentials')
|
||||
|
||||
# ============================================================
|
||||
# /api/auth/me
|
||||
# ============================================================
|
||||
def test_me_without_login(self):
|
||||
r = self.client.get('/api/auth/me')
|
||||
self.assertEqual(r.status_code, 401)
|
||||
self.assertEqual(r.json['code'], 'unauthenticated')
|
||||
|
||||
def test_me_with_valid_session(self):
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie = self._extract_cookie(login_r)
|
||||
self._set_client_cookie(cookie)
|
||||
r = self.client.get('/api/auth/me')
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json['data']['username'], 'alice')
|
||||
|
||||
def test_me_with_invalid_cookie(self):
|
||||
self._set_client_cookie('fake-session-id-not-in-db')
|
||||
r = self.client.get('/api/auth/me')
|
||||
self.assertEqual(r.status_code, 401)
|
||||
self.assertEqual(r.json['code'], 'session_expired')
|
||||
|
||||
# ============================================================
|
||||
# /api/auth/logout
|
||||
# ============================================================
|
||||
def test_logout_clears_session(self):
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie = self._extract_cookie(login_r)
|
||||
self._set_client_cookie(cookie)
|
||||
# 登出
|
||||
logout_r = self.client.post('/api/auth/logout')
|
||||
self.assertEqual(logout_r.status_code, 200)
|
||||
# 登出后用同一个 cookie 调 /me 应该 401(session 已吊销)
|
||||
me_r = self.client.get('/api/auth/me')
|
||||
self.assertEqual(me_r.status_code, 401)
|
||||
|
||||
# ============================================================
|
||||
# /api/auth/change-password
|
||||
# ============================================================
|
||||
def test_change_password_success(self):
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie = self._extract_cookie(login_r)
|
||||
self._set_client_cookie(cookie)
|
||||
# 改密码
|
||||
r = self.client.post('/api/auth/change-password',
|
||||
json={'old_password': 'correct-horse-battery-staple',
|
||||
'new_password': 'new-strong-password-2026'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
# 改密后调 /me 应 401(当前 session 被吊销 revoked=1)
|
||||
# 注意:Flask test_client 的 set_cookie 是 append 语义,需重新设
|
||||
self._set_client_cookie(cookie)
|
||||
me_r = self.client.get('/api/auth/me')
|
||||
self.assertEqual(me_r.status_code, 401,
|
||||
'改密后原 session 应被吊销')
|
||||
|
||||
# 用新密码登录
|
||||
login2 = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'new-strong-password-2026',
|
||||
})
|
||||
self.assertEqual(login2.status_code, 200)
|
||||
|
||||
def test_change_password_wrong_old(self):
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie = self._extract_cookie(login_r)
|
||||
self._set_client_cookie(cookie)
|
||||
r = self.client.post('/api/auth/change-password',
|
||||
json={'old_password': 'wrong-old', 'new_password': 'new-password-12345'})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
self.assertEqual(r.json['code'], 'wrong_old_password')
|
||||
|
||||
def test_change_password_too_short(self):
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie = self._extract_cookie(login_r)
|
||||
self._set_client_cookie(cookie)
|
||||
r = self.client.post('/api/auth/change-password',
|
||||
json={'old_password': 'correct-horse-battery-staple',
|
||||
'new_password': 'short'})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.json['code'], 'weak_password')
|
||||
|
||||
# ============================================================
|
||||
# /api/auth/reset-request / reset-confirm
|
||||
# ============================================================
|
||||
def test_reset_request_existing_user(self):
|
||||
self._create_test_user()
|
||||
r = self.client.post('/api/auth/reset-request', json={'username': 'alice'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json['success'])
|
||||
self.assertEqual(r.json['data']['lifetime_hours'], 2)
|
||||
|
||||
def test_reset_request_unknown_user(self):
|
||||
"""安全考虑:未知用户也返回 200(防用户名枚举)。"""
|
||||
r = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json['success'])
|
||||
self.assertNotIn('lifetime_hours', r.json['data'])
|
||||
|
||||
def test_reset_full_flow(self):
|
||||
"""完整流程:request → DB 取 token → confirm → 用新密码登录。"""
|
||||
self._create_test_user()
|
||||
# 1. 申请 reset
|
||||
r1 = self.client.post('/api/auth/reset-request', json={'username': 'alice'})
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
# 2. 模拟从邮件拿 token(生产不应在 response 返回)
|
||||
import auth_db as ad
|
||||
with ad.get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT token FROM password_reset_tokens WHERE used_at IS NULL ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row, '应该生成 token')
|
||||
token = row['token']
|
||||
# 3. 确认重置
|
||||
r2 = self.client.post('/api/auth/reset-confirm', json={
|
||||
'token': token, 'new_password': 'reset-new-password-2026',
|
||||
})
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
# 4. 用新密码登录
|
||||
r3 = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'reset-new-password-2026',
|
||||
})
|
||||
self.assertEqual(r3.status_code, 200)
|
||||
|
||||
def test_reset_confirm_invalid_token(self):
|
||||
r = self.client.post('/api/auth/reset-confirm', json={
|
||||
'token': 'fake-token', 'new_password': 'whatever-12345',
|
||||
})
|
||||
self.assertEqual(r.status_code, 400)
|
||||
self.assertEqual(r.json['code'], 'invalid_token')
|
||||
|
||||
# ============================================================
|
||||
# 限流
|
||||
# ============================================================
|
||||
def test_login_rate_limit(self):
|
||||
"""6 次/分钟触发 429。"""
|
||||
for i in range(5):
|
||||
r = self.client.post('/api/auth/login', json={
|
||||
'username': 'flood', 'password': 'flood',
|
||||
})
|
||||
self.assertEqual(r.status_code, 401, f'attempt {i+1} 应该是业务 401')
|
||||
# 第 6 次触发限流
|
||||
r6 = self.client.post('/api/auth/login', json={
|
||||
'username': 'flood', 'password': 'flood',
|
||||
})
|
||||
self.assertEqual(r6.status_code, 429)
|
||||
self.assertEqual(r6.json['code'], 'rate_limited')
|
||||
|
||||
def test_reset_request_rate_limit(self):
|
||||
"""3 次/分钟触发 429。"""
|
||||
for i in range(3):
|
||||
r = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
|
||||
self.assertEqual(r.status_code, 200, f'attempt {i+1} 应该是业务 200')
|
||||
r4 = self.client.post('/api/auth/reset-request', json={'username': 'nobody'})
|
||||
self.assertEqual(r4.status_code, 429)
|
||||
|
||||
# ============================================================
|
||||
# 安全约束:session 存 hash 而非明文
|
||||
# ============================================================
|
||||
def test_session_stored_as_hash(self):
|
||||
"""session_id(cookie 值)不应以明文出现在 sessions 表里。"""
|
||||
import hashlib
|
||||
import auth_db as ad
|
||||
self._create_test_user()
|
||||
login_r = self.client.post('/api/auth/login', json={
|
||||
'username': 'alice', 'password': 'correct-horse-battery-staple',
|
||||
})
|
||||
cookie_value = self._extract_cookie(login_r)
|
||||
self.assertIsNotNone(cookie_value)
|
||||
# 查 sessions 表
|
||||
with ad.get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT token_hash FROM sessions WHERE user_id IN (SELECT id FROM users WHERE username = 'alice')"
|
||||
).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
# token_hash 必须是 cookie_value 的 sha256
|
||||
expected_hash = hashlib.sha256(cookie_value.encode()).hexdigest()
|
||||
self.assertEqual(row['token_hash'], expected_hash)
|
||||
self.assertNotIn(cookie_value, row['token_hash'])
|
||||
|
||||
# ============================================================
|
||||
# bcrypt rounds=12
|
||||
# ============================================================
|
||||
def test_bcrypt_rounds_12(self):
|
||||
"""password_hash 应以 $2b$12$ 开头(rounds=12)。"""
|
||||
import auth_db as ad
|
||||
self._create_test_user()
|
||||
with ad.get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT password_hash FROM users WHERE username = 'alice'"
|
||||
).fetchone()
|
||||
self.assertTrue(row['password_hash'].startswith('$2b$12$'),
|
||||
f'expected $2b$12$, got {row["password_hash"][:10]}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user