test_b7_audit_statistics.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. from __future__ import annotations
  2. from datetime import UTC, datetime
  3. import pytest
  4. RANGE = "createdFrom=2026-01-01T00:00:00.000Z&createdTo=2026-02-01T00:00:00.000Z"
  5. def _headers(token: str) -> dict[str, str]:
  6. return {"Authorization": f"Bearer {token}"}
  7. def _get(client, path: str, token: str):
  8. return client.get(path, headers=_headers(token))
  9. @pytest.mark.parametrize(
  10. "path",
  11. [
  12. "/api/v1/audit/logs",
  13. "/api/v1/audit/statistics/trend?granularity=DAY",
  14. "/api/v1/audit/statistics/actions",
  15. "/api/v1/audit/statistics/users",
  16. "/api/v1/statistics/documents",
  17. ],
  18. )
  19. def test_all_b7_endpoints_require_authentication(b7_client, path):
  20. assert b7_client.get(path).status_code == 401
  21. @pytest.mark.parametrize(
  22. "path",
  23. [
  24. "/api/v1/audit/logs",
  25. "/api/v1/audit/statistics/trend?granularity=DAY",
  26. "/api/v1/audit/statistics/actions",
  27. "/api/v1/audit/statistics/users",
  28. ],
  29. )
  30. def test_audit_endpoints_are_auditor_only(b7_client, token_for, path):
  31. assert _get(b7_client, path, token_for("auditor")).status_code == 200
  32. assert _get(b7_client, path, token_for("admin")).status_code == 403
  33. assert _get(b7_client, path, token_for("user")).status_code == 403
  34. def test_document_statistics_is_admin_only(b7_client, token_for):
  35. assert _get(
  36. b7_client, "/api/v1/statistics/documents", token_for("admin")
  37. ).status_code == 200
  38. assert _get(
  39. b7_client, "/api/v1/statistics/documents", token_for("auditor")
  40. ).status_code == 403
  41. assert _get(
  42. b7_client, "/api/v1/statistics/documents", token_for("user")
  43. ).status_code == 403
  44. def test_audit_list_dto_paging_and_stable_sort(b7_client, token_for):
  45. token = token_for("auditor")
  46. response = _get(
  47. b7_client,
  48. f"/api/v1/audit/logs?{RANGE}&page=1&pageSize=2&sortDirection=asc",
  49. token,
  50. )
  51. assert response.status_code == 200
  52. data = response.get_json()["data"]
  53. assert (data["page"], data["pageSize"], data["total"], data["totalPages"]) == (
  54. 1,
  55. 2,
  56. 8,
  57. 4,
  58. )
  59. expected_fields = {
  60. "id",
  61. "username",
  62. "realName",
  63. "organizationName",
  64. "actionType",
  65. "targetType",
  66. "targetId",
  67. "targetName",
  68. "operationResult",
  69. "failureReason",
  70. "clientIp",
  71. "requestId",
  72. "createdAt",
  73. }
  74. assert set(data["items"][0]) == expected_fields
  75. assert data["items"][0]["createdAt"] == "2026-01-05T00:00:00.000Z"
  76. assert isinstance(data["items"][0]["id"], str)
  77. assert isinstance(data["items"][0]["targetId"], str)
  78. assert "operationDetail" not in data["items"][0]
  79. assert "userAgent" not in data["items"][0]
  80. tied = _get(
  81. b7_client,
  82. "/api/v1/audit/logs?"
  83. "createdFrom=2026-01-07T01:00:00.000Z&"
  84. "createdTo=2026-01-07T01:00:00.001Z&sortDirection=asc",
  85. token,
  86. ).get_json()["data"]["items"]
  87. assert [int(item["id"]) for item in tied] == sorted(
  88. int(item["id"]) for item in tied
  89. )
  90. @pytest.mark.parametrize(
  91. ("keyword", "expected_request"),
  92. [
  93. ("admin", "b7-request-alpha"),
  94. ("管理员旧名", "b7-request-alpha"),
  95. ("旧组织", "b7-request-alpha"),
  96. ("关键目标", "b7-request-alpha"),
  97. ("关键失败原因", "b7-request-beta"),
  98. ("b7-request-gamma", "b7-request-gamma"),
  99. ],
  100. )
  101. def test_keyword_searches_only_contract_fields(
  102. b7_client, token_for, keyword, expected_request
  103. ):
  104. response = _get(
  105. b7_client,
  106. f"/api/v1/audit/logs?{RANGE}&keyword={keyword}",
  107. token_for("auditor"),
  108. )
  109. request_ids = {
  110. item["requestId"] for item in response.get_json()["data"]["items"]
  111. }
  112. assert expected_request in request_ids
  113. def test_keyword_escapes_sql_wildcards(b7_client, token_for):
  114. response = _get(
  115. b7_client,
  116. f"/api/v1/audit/logs?{RANGE}&keyword=%25",
  117. token_for("auditor"),
  118. )
  119. assert response.get_json()["data"]["total"] == 0
  120. @pytest.mark.parametrize(
  121. ("query", "request_id"),
  122. [
  123. ("userId={admin}", "b7-request-alpha"),
  124. ("organizationId={admin_org}", "b7-request-alpha"),
  125. ("actionType=DOWNLOAD_DOCUMENT", "b7-request-beta"),
  126. ("operationResult=FAILURE", "b7-request-beta"),
  127. ("targetType=AUTH", "b7-request-gamma"),
  128. ("targetId=103", "b7-request-epsilon"),
  129. ],
  130. )
  131. def test_audit_list_contract_filters(
  132. b7_app, b7_client, token_for, query, request_id
  133. ):
  134. with b7_app.app_context():
  135. from dms.extensions import db
  136. from dms.models import User
  137. admin = db.session.scalar(
  138. db.select(User).where(User.username == "admin")
  139. )
  140. query = query.format(
  141. admin=admin.id, admin_org=admin.organization_id
  142. )
  143. response = _get(
  144. b7_client,
  145. f"/api/v1/audit/logs?{RANGE}&{query}",
  146. token_for("auditor"),
  147. )
  148. assert request_id in {
  149. item["requestId"] for item in response.get_json()["data"]["items"]
  150. }
  151. def test_time_range_is_half_open_and_each_bound_is_optional(
  152. b7_client, token_for
  153. ):
  154. token = token_for("auditor")
  155. inclusive = _get(
  156. b7_client,
  157. "/api/v1/audit/logs?"
  158. "createdFrom=2026-01-05T00:00:00.000Z&"
  159. "createdTo=2026-01-05T01:00:00.000Z",
  160. token,
  161. ).get_json()["data"]["items"]
  162. assert [item["requestId"] for item in inclusive] == ["b7-request-alpha"]
  163. only_from = _get(
  164. b7_client,
  165. "/api/v1/audit/logs?createdFrom=2026-02-01T00:00:00.000Z",
  166. token,
  167. ).get_json()["data"]
  168. assert any(item["requestId"] == "b7-request-iota" for item in only_from["items"])
  169. only_to = _get(
  170. b7_client,
  171. "/api/v1/audit/logs?createdTo=2026-01-05T00:00:00.001Z",
  172. token,
  173. ).get_json()["data"]
  174. assert only_to["total"] == 1
  175. @pytest.mark.parametrize(
  176. "query",
  177. [
  178. "createdFrom=2026-01-01T00:00:00",
  179. "createdFrom=2026-01-01T00:00:00%2B08:00",
  180. "createdFrom=not-a-date",
  181. "createdFrom=2026-01-02T00:00:00Z&createdTo=2026-01-01T00:00:00Z",
  182. "actionType=UNKNOWN",
  183. "operationResult=UNKNOWN",
  184. "targetType=UNKNOWN",
  185. "sortDirection=sideways",
  186. "page=0",
  187. "pageSize=101",
  188. "userId=abc",
  189. "unknown=value",
  190. "page=1&page=2",
  191. ],
  192. )
  193. def test_invalid_audit_list_parameters_return_400(
  194. b7_client, token_for, query
  195. ):
  196. response = _get(
  197. b7_client, f"/api/v1/audit/logs?{query}", token_for("auditor")
  198. )
  199. assert response.status_code == 400
  200. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  201. def test_document_statistics_and_business_timezone_day(
  202. b7_app, b7_client, token_for, monkeypatch
  203. ):
  204. from dms.extensions import db
  205. from dms.models import Document
  206. from dms.services import audit_query_service
  207. monkeypatch.setattr(
  208. audit_query_service,
  209. "_utc_now",
  210. lambda: datetime(2026, 1, 7, 12, 0, tzinfo=UTC),
  211. )
  212. response = _get(
  213. b7_client, "/api/v1/statistics/documents", token_for("admin")
  214. )
  215. data = response.get_json()["data"]
  216. with b7_app.app_context():
  217. counts = {
  218. kind: db.session.scalar(
  219. db.select(db.func.count(Document.id)).where(
  220. Document.document_type == kind,
  221. Document.is_deleted.is_(False),
  222. )
  223. )
  224. for kind in ("MAIN", "SUB_PLAN", "ATTACHMENT")
  225. }
  226. assert data["mainPlanCount"] == counts["MAIN"]
  227. assert data["subPlanCount"] == counts["SUB_PLAN"]
  228. assert data["attachmentCount"] == counts["ATTACHMENT"]
  229. assert data["planDocumentCount"] == counts["MAIN"] + counts["SUB_PLAN"]
  230. assert data["todayViewerCount"] == 2
  231. @pytest.mark.parametrize(
  232. ("granularity", "expected_periods"),
  233. [
  234. ("DAY", ["2026-01-05", "2026-01-06", "2026-01-07", "2026-01-08"]),
  235. ("WEEK", ["2026-01-05"]),
  236. ("MONTH", ["2026-01"]),
  237. ],
  238. )
  239. def test_trend_database_buckets(
  240. b7_client, token_for, granularity, expected_periods
  241. ):
  242. response = _get(
  243. b7_client,
  244. f"/api/v1/audit/statistics/trend?{RANGE}&granularity={granularity}",
  245. token_for("auditor"),
  246. )
  247. assert response.status_code == 200
  248. items = response.get_json()["data"]["items"]
  249. assert [item["period"] for item in items] == expected_periods
  250. assert sum(item["operationCount"] for item in items) == 8
  251. def test_trend_requires_valid_granularity_and_supports_empty_result(
  252. b7_client, token_for
  253. ):
  254. token = token_for("auditor")
  255. assert _get(
  256. b7_client, "/api/v1/audit/statistics/trend", token
  257. ).status_code == 400
  258. assert _get(
  259. b7_client,
  260. "/api/v1/audit/statistics/trend?granularity=YEAR",
  261. token,
  262. ).status_code == 400
  263. data = _get(
  264. b7_client,
  265. "/api/v1/audit/statistics/trend?"
  266. "granularity=DAY&createdFrom=2025-01-01T00:00:00Z&"
  267. "createdTo=2025-01-02T00:00:00Z",
  268. token,
  269. ).get_json()["data"]
  270. assert data == {"items": []}
  271. def test_action_distribution_order_and_all_results(b7_client, token_for):
  272. items = _get(
  273. b7_client,
  274. f"/api/v1/audit/statistics/actions?{RANGE}",
  275. token_for("auditor"),
  276. ).get_json()["data"]["items"]
  277. assert items[0] == {"actionType": "VIEW_DOCUMENT", "count": 5}
  278. assert sum(item["count"] for item in items) == 8
  279. tied = [item["actionType"] for item in items if item["count"] == 1]
  280. assert tied == sorted(tied)
  281. def test_active_users_latest_audit_snapshot_and_order(b7_client, token_for):
  282. items = _get(
  283. b7_client,
  284. f"/api/v1/audit/statistics/users?{RANGE}&limit=2",
  285. token_for("auditor"),
  286. ).get_json()["data"]["items"]
  287. assert [item["operationCount"] for item in items] == [4, 2]
  288. assert items[0]["realName"] == "管理员新名"
  289. assert items[0]["organizationName"] == "新组织"
  290. assert all(isinstance(item["userId"], str) for item in items)
  291. @pytest.mark.parametrize("limit", ["0", "101", "abc"])
  292. def test_active_user_limit_validation(b7_client, token_for, limit):
  293. assert _get(
  294. b7_client,
  295. f"/api/v1/audit/statistics/users?limit={limit}",
  296. token_for("auditor"),
  297. ).status_code == 400
  298. def test_audit_queries_do_not_write_audit_log(b7_app, b7_client, token_for):
  299. from dms.extensions import db
  300. from dms.models import AuditLog
  301. token = token_for("auditor")
  302. with b7_app.app_context():
  303. before = db.session.scalar(db.select(db.func.count(AuditLog.id)))
  304. for path in (
  305. f"/api/v1/audit/logs?{RANGE}",
  306. f"/api/v1/audit/statistics/trend?{RANGE}&granularity=DAY",
  307. f"/api/v1/audit/statistics/actions?{RANGE}",
  308. f"/api/v1/audit/statistics/users?{RANGE}",
  309. ):
  310. assert _get(b7_client, path, token).status_code == 200
  311. with b7_app.app_context():
  312. after = db.session.scalar(db.select(db.func.count(AuditLog.id)))
  313. assert after == before
  314. def test_b7_real_http_on_8755_uses_only_formal_apis_for_audit_writes(
  315. b4_app, b2_password
  316. ):
  317. """单进程内启停真实TCP服务,避免后台子进程遗留或占住测试通道。"""
  318. import json
  319. import threading
  320. from datetime import timedelta
  321. from urllib.error import HTTPError
  322. from urllib.parse import urlencode
  323. from urllib.request import Request, urlopen
  324. from werkzeug.serving import make_server
  325. import requests
  326. from dms.extensions import db
  327. from dms.models import User
  328. origin = "http://127.0.0.1:9346"
  329. base_url = "http://127.0.0.1:8755"
  330. def call(method, path, *, token=None, body=None):
  331. headers = {"Origin": origin}
  332. if token:
  333. headers["Authorization"] = f"Bearer {token}"
  334. data = None
  335. if body is not None:
  336. data = json.dumps(body, ensure_ascii=False).encode("utf-8")
  337. headers["Content-Type"] = "application/json"
  338. request = Request(
  339. f"{base_url}{path}",
  340. data=data,
  341. headers=headers,
  342. method=method,
  343. )
  344. try:
  345. response = urlopen(request, timeout=5)
  346. except HTTPError as error:
  347. response = error
  348. content = response.read()
  349. payload = (
  350. json.loads(content.decode("utf-8"))
  351. if response.headers.get_content_type() == "application/json"
  352. else content
  353. )
  354. return response.status, response.headers, payload
  355. def login(username, password=b2_password):
  356. status, _, payload = call(
  357. "POST",
  358. "/api/v1/auth/login",
  359. body={
  360. "username": username,
  361. "password": password,
  362. "keepSignedIn": False,
  363. },
  364. )
  365. assert status == 200
  366. return payload["data"]["accessToken"]
  367. with b4_app.app_context():
  368. ids = b4_app.config["B4_IDS"]
  369. admin = db.session.scalar(
  370. db.select(User).where(User.username == "admin")
  371. )
  372. admin_id = admin.id
  373. organization_id = admin.organization_id
  374. server = make_server("127.0.0.1", 8755, b4_app, threaded=True)
  375. thread = threading.Thread(target=server.serve_forever, daemon=True)
  376. thread.start()
  377. try:
  378. admin_token = login("admin")
  379. user_token = login("user")
  380. assert call(
  381. "POST",
  382. "/api/v1/auth/login",
  383. body={
  384. "username": "missing-user",
  385. "password": "wrong-password",
  386. "keepSignedIn": False,
  387. },
  388. )[0] == 401
  389. auditor_token = login("auditor")
  390. assert call(
  391. "GET", f"/api/v1/documents/{ids['main_all']}", token=admin_token
  392. )[0] == 200
  393. assert call(
  394. "GET", f"/api/v1/documents/{ids['main_all']}", token=user_token
  395. )[0] == 200
  396. upload_response = requests.post(
  397. f"{base_url}/api/v1/attachments",
  398. files={
  399. "file": (
  400. "B7真实HTTP附件.pdf",
  401. b"%PDF-1.4\nB7-REAL-HTTP-DOWNLOAD",
  402. "application/pdf",
  403. )
  404. },
  405. data={
  406. "metadata": json.dumps(
  407. {
  408. "documentName": "B7真实HTTP附件",
  409. "attachmentType": "POLICY",
  410. "summary": "B7真实HTTP验证",
  411. "tags": ["B7"],
  412. },
  413. ensure_ascii=False,
  414. )
  415. },
  416. headers={
  417. "Authorization": f"Bearer {admin_token}",
  418. "Origin": origin,
  419. },
  420. timeout=5,
  421. )
  422. assert upload_response.status_code == 201, upload_response.text
  423. attachment_id = upload_response.json()["data"]["id"]
  424. download_result = call(
  425. "GET",
  426. f"/api/v1/documents/{attachment_id}/download",
  427. token=admin_token,
  428. )
  429. assert download_result[0] == 200, download_result[2]
  430. status, _, _ = call(
  431. "POST",
  432. "/api/v1/categories",
  433. token=admin_token,
  434. body={
  435. "categoryCode": "B7_HTTP_CATEGORY",
  436. "categoryName": "B7真实HTTP分类",
  437. "categoryType": "OTHER",
  438. "parentId": None,
  439. "sortNo": 99,
  440. },
  441. )
  442. assert status == 201
  443. now = datetime.now(UTC)
  444. time_query = {
  445. "createdFrom": (now - timedelta(minutes=5))
  446. .isoformat(timespec="milliseconds")
  447. .replace("+00:00", "Z"),
  448. "createdTo": (now + timedelta(minutes=5))
  449. .isoformat(timespec="milliseconds")
  450. .replace("+00:00", "Z"),
  451. }
  452. base_query = urlencode(time_query)
  453. status, headers, payload = call(
  454. "GET",
  455. f"/api/v1/audit/logs?{base_query}&page=1&pageSize=100&"
  456. "sortDirection=asc",
  457. token=auditor_token,
  458. )
  459. assert status == 200
  460. assert headers["Access-Control-Allow-Origin"] == origin
  461. assert "X-Request-Id" in headers["Access-Control-Expose-Headers"]
  462. before_total = payload["data"]["total"]
  463. assert before_total >= 9
  464. checks = [
  465. f"/api/v1/audit/logs?{base_query}&"
  466. f"{urlencode({'keyword': 'B7真实HTTP分类'})}",
  467. f"/api/v1/audit/logs?{base_query}&userId={admin_id}",
  468. f"/api/v1/audit/logs?{base_query}&organizationId={organization_id}",
  469. f"/api/v1/audit/logs?{base_query}&actionType=VIEW_DOCUMENT",
  470. f"/api/v1/audit/logs?{base_query}&operationResult=FAILURE",
  471. f"/api/v1/audit/logs?{base_query}&targetType=DOCUMENT&"
  472. f"targetId={ids['main_all']}",
  473. f"/api/v1/audit/statistics/trend?{base_query}&granularity=DAY",
  474. f"/api/v1/audit/statistics/trend?{base_query}&granularity=WEEK",
  475. f"/api/v1/audit/statistics/trend?{base_query}&granularity=MONTH",
  476. f"/api/v1/audit/statistics/actions?{base_query}",
  477. f"/api/v1/audit/statistics/users?{base_query}&limit=10",
  478. ]
  479. for path in checks:
  480. assert call("GET", path, token=auditor_token)[0] == 200
  481. status, _, statistics = call(
  482. "GET", "/api/v1/statistics/documents", token=admin_token
  483. )
  484. assert status == 200
  485. assert statistics["data"]["todayViewerCount"] == 2
  486. assert call(
  487. "GET", "/api/v1/audit/logs", token=admin_token
  488. )[0] == 403
  489. assert call(
  490. "GET", "/api/v1/statistics/documents", token=auditor_token
  491. )[0] == 403
  492. assert call(
  493. "GET", "/api/v1/audit/export", token=auditor_token
  494. )[0] == 404
  495. after_total = call(
  496. "GET",
  497. f"/api/v1/audit/logs?{base_query}&pageSize=100",
  498. token=auditor_token,
  499. )[2]["data"]["total"]
  500. assert after_total == before_total
  501. finally:
  502. server.shutdown()
  503. server.server_close()
  504. thread.join(timeout=5)
  505. assert not thread.is_alive()