test_b7_audit_statistics.py 18 KB

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