test_system_management.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. """系统管理功能(契约1.23):运行日志、存储占用、预览缓存清理、管理位与接口。
  2. 引擎级测试不依赖数据库,始终执行;接口级测试沿用 B2 惯例,
  3. 提供 DMS_TEST_* 环境变量时执行,否则安全跳过。
  4. """
  5. from __future__ import annotations
  6. from datetime import datetime, timedelta, timezone
  7. from pathlib import Path
  8. import pytest
  9. from sqlalchemy import func, select
  10. from dms.common.errors import MetricUnavailableError
  11. from dms.extensions import db
  12. from dms.models import AuditLog, MetricSample, Role, User
  13. from dms.security.passwords import hash_password
  14. from dms.services.system_management_service import (
  15. API_CONTRACT_VERSION,
  16. BACKEND_KIND,
  17. MetricSampler,
  18. PSUTIL_AVAILABLE,
  19. clear_preview_cache,
  20. log_file_path,
  21. read_log_tail,
  22. runtime_config,
  23. storage_usage,
  24. system_info,
  25. )
  26. from dms.storage.paths import STORAGE_SUBDIRECTORIES
  27. SYSTEM_READONLY_URLS = [
  28. "/api/v1/system/metrics/current",
  29. "/api/v1/system/process",
  30. "/api/v1/system/storage-usage",
  31. "/api/v1/system/converter",
  32. "/api/v1/system/health",
  33. "/api/v1/system/info",
  34. "/api/v1/system/runtime-config",
  35. "/api/v1/system/logs",
  36. ]
  37. def _auth(token: str) -> dict[str, str]:
  38. return {"Authorization": f"Bearer {token}"}
  39. # ===== 引擎级(不依赖数据库)==================================================
  40. def test_log_file_path_disabled_values(flask_app, monkeypatch):
  41. with flask_app.app_context():
  42. for disabled in ("OFF", "NONE", ""):
  43. monkeypatch.setitem(flask_app.config, "DMS_LOG_FILE", disabled)
  44. assert log_file_path() is None
  45. def test_read_log_tail_lines_and_level(flask_app, monkeypatch, tmp_path: Path):
  46. log = tmp_path / "dms.log"
  47. log.write_text(
  48. "2026-01-01 [INFO] one\n"
  49. "2026-01-01 [ERROR] two\n"
  50. "2026-01-01 [INFO] three\n"
  51. "2026-01-01 [ERROR] four\n",
  52. encoding="utf-8",
  53. )
  54. with flask_app.app_context():
  55. monkeypatch.setitem(flask_app.config, "DMS_LOG_FILE", str(log))
  56. tail = read_log_tail(lines=2)
  57. assert tail["lines"] == [
  58. "2026-01-01 [INFO] three",
  59. "2026-01-01 [ERROR] four",
  60. ]
  61. assert tail["truncated"] is False
  62. errors = read_log_tail(lines=10, level="error")
  63. assert errors["lines"] == [
  64. "2026-01-01 [ERROR] two",
  65. "2026-01-01 [ERROR] four",
  66. ]
  67. assert errors["truncated"] is True
  68. # 未知级别按无过滤处理
  69. bogus = read_log_tail(lines=10, level="NOPE")
  70. assert len(bogus["lines"]) == 4
  71. def test_read_log_tail_missing_file(flask_app, monkeypatch, tmp_path: Path):
  72. with flask_app.app_context():
  73. monkeypatch.setitem(
  74. flask_app.config, "DMS_LOG_FILE", str(tmp_path / "absent.log")
  75. )
  76. payload = read_log_tail(lines=100)
  77. assert payload["lines"] == []
  78. assert payload["truncated"] is False
  79. def test_clear_preview_cache_only_touches_preview(
  80. flask_app, monkeypatch, tmp_path: Path
  81. ):
  82. root = tmp_path / "storage"
  83. nested = root / "preview" / "aa"
  84. nested.mkdir(parents=True)
  85. (nested / "1.pdf").write_bytes(b"12345")
  86. (root / "preview" / "2.pdf").write_bytes(b"123456")
  87. original = root / "original"
  88. original.mkdir(parents=True)
  89. (original / "keep.pdf").write_bytes(b"x")
  90. with flask_app.app_context():
  91. monkeypatch.setitem(flask_app.config, "DMS_STORAGE_ROOT", str(root))
  92. assert clear_preview_cache() == {"removedFiles": 2, "removedBytes": 11}
  93. assert clear_preview_cache() == {"removedFiles": 0, "removedBytes": 0}
  94. assert (original / "keep.pdf").exists()
  95. assert not (root / "preview" / "aa").exists()
  96. def test_storage_usage_covers_all_directories(
  97. flask_app, monkeypatch, tmp_path: Path
  98. ):
  99. root = tmp_path / "storage2"
  100. year_dir = root / "original" / "2026"
  101. year_dir.mkdir(parents=True)
  102. (year_dir / "a.pdf").write_bytes(b"12345")
  103. with flask_app.app_context():
  104. monkeypatch.setitem(flask_app.config, "DMS_STORAGE_ROOT", str(root))
  105. entries = {item["directory"]: item for item in storage_usage()}
  106. assert set(entries) == set(STORAGE_SUBDIRECTORIES)
  107. assert entries["original"] == {
  108. "directory": "original",
  109. "totalBytes": 5,
  110. "fileCount": 1,
  111. }
  112. assert entries["preview"]["totalBytes"] == 0
  113. assert entries["preview"]["fileCount"] == 0
  114. def test_runtime_config_masks_secrets(flask_app):
  115. with flask_app.app_context():
  116. entries = {
  117. (item["category"], item["key"]): item["value"]
  118. for item in runtime_config()
  119. }
  120. assert entries[("认证", "JWT密钥")] in {"已配置", "未配置"}
  121. secret = str(flask_app.config.get("DMS_JWT_SECRET", ""))
  122. if secret:
  123. rendered = " ".join(str(value) for value in entries.values())
  124. assert secret not in rendered
  125. def test_system_info_reports_python_backend(flask_app):
  126. with flask_app.app_context():
  127. info = system_info()
  128. assert BACKEND_KIND == "PYTHON"
  129. assert API_CONTRACT_VERSION == "1.30.0"
  130. assert info["backendKind"] == "PYTHON"
  131. assert info["contractVersion"] == "1.30.0"
  132. assert isinstance(info["storageRoot"], str)
  133. assert isinstance(info["workingDirectory"], str)
  134. assert info["monitoringEnabled"] is False
  135. def test_metric_sampler_config_floors():
  136. sampler = MetricSampler(None, interval_seconds=5, retention_days=0)
  137. assert sampler._interval_seconds == 10
  138. assert sampler._retention_days == 1
  139. assert sampler.running is False
  140. def test_monitoring_unavailable_without_psutil(flask_app, monkeypatch):
  141. import dms.services.system_management_service as service
  142. monkeypatch.setattr(service, "PSUTIL_AVAILABLE", False)
  143. with flask_app.app_context():
  144. with pytest.raises(MetricUnavailableError):
  145. service.current_metrics()
  146. with pytest.raises(MetricUnavailableError):
  147. service.process_metrics()
  148. # ===== 接口级(B2 惯例:需 DMS_TEST_* 环境变量)===============================
  149. def _expected_monitor_status() -> int:
  150. """psutil 缺失环境下监控接口按契约返回 503 METRIC_UNAVAILABLE。"""
  151. return 200 if PSUTIL_AVAILABLE else 503
  152. @pytest.mark.parametrize("url", SYSTEM_READONLY_URLS)
  153. def test_plain_user_forbidden_on_system_routes(b2_client, token_for, url):
  154. response = b2_client.get(url, headers=_auth(token_for("user")))
  155. assert response.status_code == 403
  156. def test_plain_user_forbidden_on_maintenance_routes(
  157. b2_app, b2_client, token_for
  158. ):
  159. token = _auth(token_for("user"))
  160. assert (
  161. b2_client.post(
  162. "/api/v1/system/maintenance/preview-cache", headers=token
  163. ).status_code
  164. == 403
  165. )
  166. with b2_app.app_context():
  167. admin_id = db.session.scalar(
  168. select(User.id).where(User.username == "admin")
  169. )
  170. assert (
  171. b2_client.post(
  172. f"/api/v1/system/users/{admin_id}/force-offline", headers=token
  173. ).status_code
  174. == 403
  175. )
  176. assert (
  177. b2_client.post("/api/v1/backup/test-target", headers=token).status_code
  178. == 403
  179. )
  180. assert (
  181. b2_client.get(
  182. "/api/v1/system/logs/download", headers=token
  183. ).status_code
  184. == 403
  185. )
  186. assert (
  187. b2_client.get("/api/v1/audit/logs", headers=token).status_code == 403
  188. )
  189. def test_admin_reads_system_routes(b2_client, login_token):
  190. headers = _auth(login_token)
  191. for url in SYSTEM_READONLY_URLS:
  192. response = b2_client.get(url, headers=headers)
  193. assert response.status_code == _expected_monitor_status(), url
  194. # 健康体检固定五项
  195. health = b2_client.get("/api/v1/system/health", headers=headers)
  196. items = health.get_json()["data"]["items"]
  197. assert {item["item"] for item in items} == {
  198. "DATABASE",
  199. "STORAGE_WRITABLE",
  200. "LIBREOFFICE",
  201. "BACKUP_TARGET",
  202. "STORAGE_ENCRYPTION",
  203. }
  204. @pytest.mark.parametrize(
  205. "query",
  206. ["hours=0", "hours=721", "hours=abc", "hours=-1"],
  207. )
  208. def test_metrics_history_validates_hours(b2_client, login_token, query):
  209. response = b2_client.get(
  210. f"/api/v1/system/metrics/history?{query}",
  211. headers=_auth(login_token),
  212. )
  213. assert response.status_code == 400
  214. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  215. @pytest.mark.parametrize("query", ["lines=0", "lines=5001", "lines=abc"])
  216. def test_system_logs_validates_lines(b2_client, login_token, query):
  217. response = b2_client.get(
  218. f"/api/v1/system/logs?{query}", headers=_auth(login_token)
  219. )
  220. assert response.status_code == 400
  221. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  222. def test_metric_history_filters_by_hours(b2_app, b2_client, login_token):
  223. now = datetime.now(timezone.utc).replace(tzinfo=None)
  224. with b2_app.app_context():
  225. db.session.add_all(
  226. [
  227. MetricSample(
  228. sampled_at=now - timedelta(minutes=5),
  229. cpu_percent=11.0,
  230. mem_used_bytes=1,
  231. mem_total_bytes=2,
  232. ),
  233. MetricSample(
  234. sampled_at=now - timedelta(hours=3),
  235. cpu_percent=22.0,
  236. mem_used_bytes=1,
  237. mem_total_bytes=2,
  238. ),
  239. MetricSample(
  240. sampled_at=now - timedelta(hours=48),
  241. cpu_percent=99.0,
  242. mem_used_bytes=1,
  243. mem_total_bytes=2,
  244. ),
  245. ]
  246. )
  247. db.session.commit()
  248. response = b2_client.get(
  249. "/api/v1/system/metrics/history?hours=24", headers=_auth(login_token)
  250. )
  251. assert response.status_code == 200
  252. data = response.get_json()["data"]
  253. assert data["hours"] == 24
  254. cpu_values = [entry["cpuPercent"] for entry in data["entries"]]
  255. assert 11.0 in cpu_values
  256. assert 22.0 in cpu_values
  257. assert 99.0 not in cpu_values
  258. def test_metric_sampler_tick_writes_and_prunes(b2_app):
  259. if not PSUTIL_AVAILABLE:
  260. pytest.skip("psutil 未安装")
  261. now = datetime.now(timezone.utc).replace(tzinfo=None)
  262. with b2_app.app_context():
  263. db.session.add(
  264. MetricSample(
  265. sampled_at=now - timedelta(days=3),
  266. cpu_percent=88.0,
  267. mem_used_bytes=1,
  268. mem_total_bytes=2,
  269. )
  270. )
  271. db.session.commit()
  272. sampler = MetricSampler(b2_app, interval_seconds=10, retention_days=1)
  273. sampler._tick_once()
  274. with b2_app.app_context():
  275. cpu_values = db.session.scalars(select(MetricSample.cpu_percent)).all()
  276. assert 88.0 not in cpu_values
  277. assert len(cpu_values) == 1
  278. def test_logs_download_404_when_disabled(b2_app, b2_client, login_token):
  279. b2_app.config["DMS_LOG_FILE"] = "OFF"
  280. response = b2_client.get(
  281. "/api/v1/system/logs/download", headers=_auth(login_token)
  282. )
  283. assert response.status_code == 404
  284. assert response.get_json()["code"] == "RESOURCE_NOT_FOUND"
  285. def test_logs_download_serves_file(
  286. b2_app, b2_client, login_token, tmp_path: Path
  287. ):
  288. log = tmp_path / "dms-server.log"
  289. log.write_text("2026-01-01 [INFO] hello\n", encoding="utf-8")
  290. b2_app.config["DMS_LOG_FILE"] = str(log)
  291. response = b2_client.get(
  292. "/api/v1/system/logs/download", headers=_auth(login_token)
  293. )
  294. assert response.status_code == 200
  295. assert "attachment" in response.headers["Content-Disposition"]
  296. assert log.name in response.headers["Content-Disposition"]
  297. b2_app.config["DMS_LOG_FILE"] = "OFF"
  298. def test_clear_preview_cache_endpoint_audits(b2_app, b2_client, login_token):
  299. response = b2_client.post(
  300. "/api/v1/system/maintenance/preview-cache", headers=_auth(login_token)
  301. )
  302. assert response.status_code == 200
  303. assert response.get_json()["data"] == {
  304. "removedFiles": 0,
  305. "removedBytes": 0,
  306. }
  307. with b2_app.app_context():
  308. audit = db.session.scalar(
  309. select(AuditLog).where(
  310. AuditLog.action_type == "PREVIEW_CACHE_CLEAR"
  311. )
  312. )
  313. assert audit is not None
  314. assert audit.operation_result == "SUCCESS"
  315. def test_force_offline_invalidates_target_token(
  316. b2_app, b2_client, login_token, b2_password
  317. ):
  318. victim_token = b2_client.post(
  319. "/api/v1/auth/login",
  320. json={
  321. "username": "user",
  322. "password": b2_password,
  323. "keepSignedIn": False,
  324. },
  325. ).get_json()["data"]["accessToken"]
  326. assert (
  327. b2_client.get(
  328. "/api/v1/users/me", headers=_auth(victim_token)
  329. ).status_code
  330. == 200
  331. )
  332. with b2_app.app_context():
  333. victim_id = db.session.scalar(
  334. select(User.id).where(User.username == "user")
  335. )
  336. before = db.session.scalar(
  337. select(User.auth_version).where(User.id == victim_id)
  338. )
  339. # 非法/不存在/自我保护
  340. assert (
  341. b2_client.post(
  342. "/api/v1/system/users/abc/force-offline", headers=_auth(login_token)
  343. ).status_code
  344. == 400
  345. )
  346. assert (
  347. b2_client.post(
  348. "/api/v1/system/users/999999/force-offline",
  349. headers=_auth(login_token),
  350. ).status_code
  351. == 404
  352. )
  353. with b2_app.app_context():
  354. admin_id = db.session.scalar(
  355. select(User.id).where(User.username == "admin")
  356. )
  357. self_offline = b2_client.post(
  358. f"/api/v1/system/users/{admin_id}/force-offline",
  359. headers=_auth(login_token),
  360. )
  361. assert self_offline.status_code == 403
  362. assert self_offline.get_json()["code"] == "USER_SELF_PROTECTION"
  363. response = b2_client.post(
  364. f"/api/v1/system/users/{victim_id}/force-offline",
  365. headers=_auth(login_token),
  366. )
  367. assert response.status_code == 200
  368. payload = response.get_json()["data"]
  369. assert payload["username"] == "user"
  370. assert payload["authVersion"] == before + 1
  371. # 旧令牌立即失效,且管理端令牌不受影响
  372. rejected = b2_client.get(
  373. "/api/v1/users/me", headers=_auth(victim_token)
  374. )
  375. assert rejected.status_code == 401
  376. assert rejected.get_json()["code"] == "AUTH_VERSION_MISMATCH"
  377. assert (
  378. b2_client.get("/api/v1/users/me", headers=_auth(login_token)).status_code
  379. == 200
  380. )
  381. with b2_app.app_context():
  382. audit = db.session.scalar(
  383. select(AuditLog).where(AuditLog.action_type == "FORCE_OFFLINE")
  384. )
  385. assert audit is not None
  386. assert audit.target_name == "user"
  387. def test_backup_test_target_reports_and_audits(
  388. b2_app, b2_client, login_token, tmp_path: Path
  389. ):
  390. from dms.services.backup_service import BackupConfig, BackupEngine
  391. original = b2_app.extensions["dms_backup_engine"]
  392. original.stop_scheduler()
  393. engine = BackupEngine(
  394. storage_root=original.storage_root,
  395. business_timezone=b2_app.config["DMS_BUSINESS_TIMEZONE"],
  396. database_url=b2_app.config["SQLALCHEMY_DATABASE_URI"],
  397. backup_config_path=tmp_path / "dms-backup-config.json",
  398. env_path=tmp_path / ".env",
  399. )
  400. b2_app.extensions["dms_backup_engine"] = engine
  401. BackupConfig(local_path=str(tmp_path / "backup-root")).save(
  402. engine.backup_config_path
  403. )
  404. response = b2_client.post(
  405. "/api/v1/backup/test-target", headers=_auth(login_token)
  406. )
  407. assert response.status_code == 200
  408. assert response.get_json()["data"]["ok"] is True
  409. with b2_app.app_context():
  410. audit = db.session.scalar(
  411. select(AuditLog).where(
  412. AuditLog.action_type == "BACKUP_TEST_TARGET"
  413. )
  414. )
  415. assert audit is not None
  416. assert audit.operation_result == "SUCCESS"
  417. def _make_role_with_bits(b2_app, name: str, **bits) -> Role:
  418. role = Role(role_name=name, sort_no=10, status="ENABLED", **bits)
  419. db.session.add(role)
  420. db.session.flush()
  421. return role
  422. def _make_admin_user(b2_app, username: str, role: Role | None) -> User:
  423. org_name = "机关"
  424. user = User(
  425. username=username,
  426. password_hash=hash_password("Placeholder-Password-1"),
  427. real_name=username,
  428. organization_name=org_name,
  429. role_code="ADMIN",
  430. security_level="TOP_SECRET",
  431. status="ENABLED",
  432. role_id=role.id if role else None,
  433. )
  434. db.session.add(user)
  435. db.session.flush()
  436. return user
  437. def test_audit_and_system_modules_follow_manage_bits(b2_app, b2_client):
  438. """管理位决定页签可见性与对应接口访问(契约1.23/1.35)。
  439. - 无管理位角色(矩阵与管理位全空):兜底方案浏览,审计/系统接口均 403;
  440. - manage_audit_log:渲染 AUDIT_LOG 页签与审计接口;
  441. - manage_system:渲染 SYSTEM_MANAGEMENT 页签与系统接口,审计仍 403。
  442. """
  443. from dms.services.user_service import allowed_modules_for_user
  444. with b2_app.app_context():
  445. none_role = _make_role_with_bits(b2_app, "无管理位角色")
  446. audit_role = _make_role_with_bits(
  447. b2_app, "审计管理位角色", manage_audit_log=True
  448. )
  449. system_role = _make_role_with_bits(
  450. b2_app, "系统管理位角色", manage_system=True
  451. )
  452. _make_admin_user(b2_app, "sys_none", none_role)
  453. _make_admin_user(b2_app, "sys_audit", audit_role)
  454. _make_admin_user(b2_app, "sys_system", system_role)
  455. db.session.commit()
  456. for username, expected in (
  457. ("sys_none", ["DOCUMENT_BROWSER"]),
  458. ("sys_audit", ["AUDIT_LOG"]),
  459. ("sys_system", ["SYSTEM_MANAGEMENT"]),
  460. ):
  461. user = db.session.scalar(
  462. select(User).where(User.username == username)
  463. )
  464. assert allowed_modules_for_user(user) == expected
  465. from dms.security.jwt_tokens import issue_access_token
  466. def token_of(username: str) -> dict[str, str]:
  467. with b2_app.app_context():
  468. user = db.session.scalar(
  469. select(User).where(User.username == username)
  470. )
  471. token, _ = issue_access_token(user, keep_signed_in=False)
  472. return _auth(token)
  473. audit_url = "/api/v1/audit/logs?page=1&pageSize=10"
  474. system_url = "/api/v1/system/info"
  475. logs_download_url = "/api/v1/system/logs/download"
  476. assert (
  477. b2_client.get(audit_url, headers=token_of("sys_none")).status_code
  478. == 403
  479. )
  480. assert (
  481. b2_client.get(system_url, headers=token_of("sys_none")).status_code
  482. == 403
  483. )
  484. assert (
  485. b2_client.get(audit_url, headers=token_of("sys_audit")).status_code
  486. == 200
  487. )
  488. # 日志下载并入日志审计权限:审计位可访问(未启用日志文件时 404 而非 403)
  489. download = b2_client.get(
  490. logs_download_url, headers=token_of("sys_audit")
  491. )
  492. assert download.status_code in {200, 404}
  493. assert (
  494. b2_client.get(system_url, headers=token_of("sys_audit")).status_code
  495. == 403
  496. )
  497. assert (
  498. b2_client.get(system_url, headers=token_of("sys_system")).status_code
  499. == 200
  500. )
  501. assert (
  502. b2_client.get(audit_url, headers=token_of("sys_system")).status_code
  503. == 403
  504. )
  505. def test_role_crud_roundtrip_manage_bits(b2_client, login_token):
  506. payload = {
  507. "roleName": "契约123管理位角色",
  508. "sortNo": 10,
  509. "status": "ENABLED",
  510. "remark": None,
  511. "isDefault": False,
  512. "manageCategoryEdit": False,
  513. "manageOrgEdit": False,
  514. "manageUserEdit": False,
  515. "manageSystemManage": True,
  516. "manageAuditLog": False,
  517. "manageRoleEdit": False,
  518. "managedOrgId": None,
  519. }
  520. created = b2_client.post(
  521. "/api/v1/roles", json=payload, headers=_auth(login_token)
  522. )
  523. assert created.status_code == 200
  524. role_id = created.get_json()["data"]["id"]
  525. detail = b2_client.get(
  526. f"/api/v1/roles/{role_id}", headers=_auth(login_token)
  527. )
  528. role = detail.get_json()["data"]
  529. assert role["manageSystemManage"] is True
  530. assert role["manageAuditLog"] is False
  531. assert role["manageRoleEdit"] is False
  532. updated = b2_client.put(
  533. f"/api/v1/roles/{role_id}",
  534. json={
  535. **payload,
  536. "roleName": "契约123管理位角色改",
  537. "manageAuditLog": True,
  538. "manageRoleEdit": True,
  539. },
  540. headers=_auth(login_token),
  541. )
  542. assert updated.status_code == 200
  543. assert updated.get_json()["data"]["manageAuditLog"] is True
  544. assert updated.get_json()["data"]["manageRoleEdit"] is True