dy-dashboard/tests/test_audit.py
2026-10-28 11:14:17 +08:00

298 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""集中式用户数据变更审计模块测试(tests/test_audit.py)
不依赖真实 MySQL:使用 Flask test app + unittest.mock 替换 services.db.query。
运行方式:python3 -m unittest tests.test_audit -v(pytest 亦可运行本文件)。
"""
import io
import json
import unittest
from unittest import mock
from flask import Flask, jsonify
import services.db as db
from services.audit import (
init_audit, mask_sensitive, _client_ip, _is_sensitive_key,
EXCLUDED_PATHS, ACTION_DATA_CHANGE,
)
def _make_app():
"""构建最小 Flask 测试应用,注册集中式审计钩子。"""
app = Flask(__name__)
app.secret_key = 'test-secret'
@app.route('/api/simple', methods=['POST'])
def simple():
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/no_code', methods=['POST'])
def no_code():
# 无 code 字段但 HTTP 成功 → 视为成功并记录
return jsonify({"success": True})
@app.route('/api/biz_fail', methods=['POST'])
def biz_fail():
return jsonify({"code": 500, "msg": "业务失败"})
@app.route('/api/http_fail', methods=['POST'])
def http_fail():
return jsonify({"code": 0, "msg": "ok"}), 400
@app.route('/api/with_log', methods=['POST'])
def with_log():
db.add_operation_log('业务日志', '用户修改了产品')
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/multi_log', methods=['POST'])
def multi_log():
db.add_operation_log('日志A', '第一条业务日志')
db.add_operation_log('日志B', '第二条业务日志')
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/with_log_fail', methods=['POST'])
def with_log_fail():
db.add_operation_log('业务日志', '执行了但响应失败')
return jsonify({"code": 1, "msg": "失败"}), 200
@app.route('/api/with_password', methods=['POST'])
def with_password():
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/upload', methods=['POST'])
def upload():
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/put_style', methods=['PUT'])
def put_style():
return jsonify({"code": 0, "msg": "ok"})
@app.route('/api/get_only', methods=['GET'])
def get_only():
return jsonify({"code": 0, "msg": "ok"})
# 排除接口(本应返回成功,验证不记录)
for path in EXCLUDED_PATHS:
def _excluded_route():
return jsonify({"code": 0, "msg": "ok"})
app.add_url_rule(path, view_func=_excluded_route,
methods=['POST'], endpoint='excluded_' + path.replace('/', '_'))
init_audit(app)
return app
class AuditTestCase(unittest.TestCase):
def setUp(self):
self.app = _make_app()
self.app.config['TESTING'] = True
self.calls = [] # 记录所有 (sql, params)
patcher = mock.patch.object(db, 'query', side_effect=self._fake_query)
patcher.start()
self.addCleanup(patcher.stop)
def _fake_query(self, sql, params=None):
self.calls.append((sql, params))
return 123 # 模拟 INSERT 返回的 lastrowid
# ── 工具方法 ──
def _client(self):
client = self.app.test_client()
with client.session_transaction() as sess:
sess['user_id'] = 7
sess['username'] = 'tester'
return client
def _inserts(self):
"""返回所有 INSERT dashboard_logs 调用。"""
return [c for c in self.calls if 'INSERT INTO dashboard_logs' in c[0]]
# ── 用例 ──
def test_success_without_existing_log_auto_records(self):
client = self._client()
resp = client.post('/api/simple', json={"name": "产品A"})
self.assertEqual(resp.status_code, 200)
inserts = self._inserts()
self.assertEqual(len(inserts), 1, f"应有 1 条审计日志,实际 {len(inserts)}")
sql, params = inserts[0]
self.assertEqual(params[0], 7) # user_id
self.assertEqual(params[1], ACTION_DATA_CHANGE) # action
detail = params[2]
self.assertIn('POST /api/simple', detail)
self.assertIn('name:产品A', detail)
self.assertNotIn('{"', detail)
self.assertFalse(detail.endswith('}'))
self.assertEqual(len(detail) <= 12000, True)
def test_nested_json_is_summarized_not_dumped(self):
client = self._client()
client.post('/api/simple', json={
"product_code": "ABC-001",
"materials": [{"material_code": "M1"}, {"material_code": "M2"}],
})
detail = self._inserts()[0][1][2]
self.assertIn('product_code:ABC-001', detail)
self.assertIn('materials:2项', detail)
self.assertNotIn('material_code', detail)
def test_response_without_code_field_still_recorded(self):
client = self._client()
client.post('/api/no_code', json={})
self.assertEqual(len(self._inserts()), 1)
def test_business_failure_not_recorded(self):
client = self._client()
client.post('/api/biz_fail', json={})
self.assertEqual(self._inserts(), [], '业务 code 非 0/200 不应记录')
def test_http_failure_not_recorded(self):
client = self._client()
client.post('/api/http_fail', json={})
self.assertEqual(self._inserts(), [], 'HTTP >= 400 不应记录')
def test_existing_business_log_is_kept_readable_without_technical_suffix(self):
client = self._client()
client.post('/api/with_log', json={"qty": 10})
inserts = self._inserts()
# 只有 add_operation_log 产生的业务日志,不再插入第二条
self.assertEqual(len(inserts), 1)
self.assertEqual(inserts[0][1][1], '业务日志')
self.assertFalse(
any('UPDATE dashboard_logs' in sql for sql, _ in self.calls),
'已有可读业务日志时不应追加技术路径或请求体'
)
def test_multiple_business_logs_all_appended_no_new_log(self):
client = self._client()
client.post('/api/multi_log', json={})
inserts = self._inserts()
self.assertEqual(len(inserts), 2, '两条业务日志,不应再插入审计日志')
self.assertFalse(
any('UPDATE dashboard_logs' in sql for sql, _ in self.calls),
'多条业务日志也应保持原样'
)
def test_failure_with_existing_log_does_not_append_context(self):
client = self._client()
client.post('/api/with_log_fail', json={})
self.assertEqual(len(self._inserts()), 1, '业务日志本身存在')
self.assertFalse(any('UPDATE dashboard_logs' in sql for sql, _ in self.calls))
def test_password_masked_in_detail(self):
client = self._client()
client.post('/api/with_password', json={
"username": "admin",
"password": "TopSecret123",
"new_password": "NewSecret456",
"confirm_password": "NewSecret456",
"access_token": "tok-abc",
"api_key": "key-xyz",
"remark": "普通内容",
})
inserts = self._inserts()
self.assertEqual(len(inserts), 1)
detail = inserts[0][1][2]
self.assertIn('[已隐藏]', detail)
self.assertNotIn('TopSecret123', detail)
self.assertNotIn('NewSecret456', detail)
self.assertNotIn('tok-abc', detail)
self.assertNotIn('key-xyz', detail)
self.assertIn('普通内容', detail)
def test_upload_filename_recorded(self):
client = self._client()
data = {
'file': (io.BytesIO(b'%PDF-1.4 fake'), '报价单.pdf'),
'product_code': 'ABC-001',
}
client.post('/api/upload', data=data, content_type='multipart/form-data')
inserts = self._inserts()
self.assertEqual(len(inserts), 1)
detail = inserts[0][1][2]
self.assertIn('文件名: 报价单.pdf', detail)
self.assertIn('ABC-001', detail)
def test_put_method_recorded_get_not(self):
client = self._client()
client.put('/api/put_style', json={"a": 1})
self.assertEqual(len(self._inserts()), 1)
self.calls.clear()
client.get('/api/get_only')
self.assertEqual(self._inserts(), [], 'GET 不记录')
def test_excluded_paths_not_recorded(self):
client = self._client()
for path in EXCLUDED_PATHS:
self.calls.clear()
resp = client.post(path, json={"x": 1})
self.assertEqual(resp.status_code, 200, f'{path} 应返回成功')
self.assertEqual(self._inserts(), [], f'排除接口不应记录: {path}')
def test_anonymous_user_not_recorded(self):
client = self.app.test_client() # 未登录
client.post('/api/simple', json={})
self.assertEqual(self._inserts(), [], '未登录用户不记录')
# ── 单元级:脱敏 ──
def test_mask_sensitive_nested(self):
data = {
"login": {"password": "p1", "token": "t1", "note": "ok"},
"items": [{"secret": "s1"}, {"cookie": "c1"}],
"old_password": "op",
"user_password_reset": "x", # 非 *_password 结尾、非精确匹配 → 不脱敏
}
out = mask_sensitive(data)
self.assertEqual(out['login']['password'], '[已隐藏]')
self.assertEqual(out['login']['token'], '[已隐藏]')
self.assertEqual(out['login']['note'], 'ok')
self.assertEqual(out['items'][0]['secret'], '[已隐藏]')
self.assertEqual(out['items'][1]['cookie'], '[已隐藏]')
self.assertEqual(out['old_password'], '[已隐藏]')
self.assertEqual(out['user_password_reset'], 'x')
def test_is_sensitive_key(self):
for k in ['password', 'new_password', 'confirm_password', 'token',
'access_token', 'refresh_token', 'secret', 'api_key',
'authorization', 'cookie', 'my_password']:
self.assertTrue(_is_sensitive_key(k), k)
for k in ['username', 'remark', 'password_reset', 'width', 'page']:
self.assertFalse(_is_sensitive_key(k), k)
# ── 单元级:IP 获取 ──
def test_client_ip_prefers_xff_first_address(self):
with self.app.test_request_context(
'/api/simple', headers={'X-Forwarded-For': '1.2.3.4, 5.6.7.8'}):
self.assertEqual(_client_ip(), '1.2.3.4')
def test_client_ip_falls_back_to_remote_addr(self):
with self.app.test_request_context('/api/simple', environ_base={'REMOTE_ADDR': '9.9.9.9'}):
self.assertEqual(_client_ip(), '9.9.9.9')
def test_client_ip_empty_xff_falls_back(self):
with self.app.test_request_context(
'/api/simple', headers={'X-Forwarded-For': ' '},
environ_base={'REMOTE_ADDR': '8.8.8.8'}):
self.assertEqual(_client_ip(), '8.8.8.8')
# ── 集成:add_operation_log 返回 id 并注册到 g ──
def test_add_operation_log_returns_id_and_registers_in_g(self):
with self.app.test_request_context('/api/simple', method='POST'):
from flask import g
log_id = db.add_operation_log('测试动作', '测试详情', user_id=1)
self.assertEqual(log_id, 123)
self.assertEqual(g.audit_log_ids, [123])
def test_audit_insensitive_to_db_failure(self):
"""DB 异常时审计静默失败,不影响业务响应。"""
with mock.patch.object(db, 'query', side_effect=RuntimeError('db down')):
client = self._client()
resp = client.post('/api/simple', json={"a": 1})
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.get_json()['code'], 0)
if __name__ == '__main__':
unittest.main()