test_openapi_storage.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. from __future__ import annotations
  2. from pathlib import Path
  3. import re
  4. import pytest
  5. import yaml
  6. from dms.storage.paths import (
  7. STORAGE_SUBDIRECTORIES,
  8. UnsafeStoragePathError,
  9. ensure_storage_directories,
  10. resolve_storage_path,
  11. )
  12. BACKEND_ROOT = Path(__file__).resolve().parents[2]
  13. OPENAPI_ROOT = BACKEND_ROOT / "openapi"
  14. def test_all_openapi_yaml_files_parse():
  15. for path in OPENAPI_ROOT.rglob("*.yaml"):
  16. with path.open("r", encoding="utf-8") as stream:
  17. assert isinstance(yaml.safe_load(stream), dict)
  18. def test_all_openapi_external_refs_resolve():
  19. def walk(value, source_path: Path):
  20. if isinstance(value, dict):
  21. reference = value.get("$ref")
  22. if isinstance(reference, str) and not reference.startswith("#"):
  23. target, _, fragment = reference.partition("#")
  24. target_path = (source_path.parent / target).resolve()
  25. assert target_path.is_file(), reference
  26. target_document = yaml.safe_load(
  27. target_path.read_text(encoding="utf-8")
  28. )
  29. current = target_document
  30. if fragment:
  31. for part in fragment.lstrip("/").split("/"):
  32. assert part in current, reference
  33. current = current[part]
  34. for child in value.values():
  35. walk(child, source_path)
  36. elif isinstance(value, list):
  37. for child in value:
  38. walk(child, source_path)
  39. for path in OPENAPI_ROOT.rglob("*.yaml"):
  40. walk(yaml.safe_load(path.read_text(encoding="utf-8")), path)
  41. def test_flask_methods_match_openapi_exactly(flask_app):
  42. document = yaml.safe_load(
  43. (OPENAPI_ROOT / "openapi.yaml").read_text(encoding="utf-8")
  44. )
  45. # 契约1.25:proxy-verify 仅Java版实现(Python版请求返回404、前端静默回落),不计入Flask路由精确对齐
  46. # 契约1.37:借阅申请审批流仅Java版实现(Python版自1.36后废弃不再演进)
  47. java_only_paths = {
  48. "/api/v1/auth/portal/proxy-verify",
  49. "/api/v1/documents/{id}/borrow-requests",
  50. "/api/v1/borrow-requests/{id}",
  51. "/api/v1/borrow-requests/{id}/voucher",
  52. }
  53. openapi_methods = set()
  54. operation_ids = []
  55. for path, item in document["paths"].items():
  56. if path in java_only_paths:
  57. continue
  58. if "$ref" in item:
  59. target, _, fragment = item["$ref"].partition("#")
  60. referenced = yaml.safe_load(
  61. (OPENAPI_ROOT / target).read_text(encoding="utf-8")
  62. )
  63. item = referenced[fragment.lstrip("/")]
  64. for method, operation in item.items():
  65. if method.lower() in {"get", "post", "put", "delete", "patch"}:
  66. openapi_methods.add((method.upper(), path))
  67. operation_ids.append(operation["operationId"])
  68. flask_methods = set()
  69. for rule in flask_app.url_map.iter_rules():
  70. if not rule.rule.startswith("/api/v1"):
  71. continue
  72. path = re.sub(r"<(?:[^:>]+:)?([^>]+)>", r"{\1}", rule.rule)
  73. for method in rule.methods - {"HEAD", "OPTIONS"}:
  74. flask_methods.add((method, path))
  75. assert flask_methods == openapi_methods
  76. assert len(operation_ids) == len(set(operation_ids))
  77. def test_openapi_contains_only_q2b_paths():
  78. document = yaml.safe_load(
  79. (OPENAPI_ROOT / "openapi.yaml").read_text(encoding="utf-8")
  80. )
  81. assert set(document["paths"]) == {
  82. "/api/v1/health",
  83. "/api/v1/auth/captcha",
  84. "/api/v1/auth/login",
  85. "/api/v1/auth/logout",
  86. "/api/v1/auth/reauth",
  87. "/api/v1/auth/portal/verify",
  88. "/api/v1/auth/portal/proxy-verify",
  89. "/api/v1/auth/portal/setup-password",
  90. "/api/v1/users/me",
  91. "/api/v1/users/me/ui-theme",
  92. "/api/v1/organizations/tree",
  93. "/api/v1/organizations",
  94. "/api/v1/organizations/{id}",
  95. "/api/v1/organizations/{id}/ip-assignments",
  96. "/api/v1/organizations/reorder",
  97. "/api/v1/ip-entries",
  98. "/api/v1/ip-entries/{id}",
  99. "/api/v1/users",
  100. "/api/v1/users/{id}",
  101. "/api/v1/users/{id}/reset-password",
  102. "/api/v1/users/reorder",
  103. "/api/v1/roles",
  104. "/api/v1/roles/{id}",
  105. "/api/v1/roles/{id}/permissions",
  106. "/api/v1/roles/{id}/users",
  107. "/api/v1/roles/reorder",
  108. "/api/v1/notifications",
  109. "/api/v1/notifications/unread-count",
  110. "/api/v1/notifications/read-all",
  111. "/api/v1/notifications/{id}/handle",
  112. "/api/v1/documents/{id}/borrow-requests",
  113. "/api/v1/borrow-requests/{id}",
  114. "/api/v1/borrow-requests/{id}/voucher",
  115. "/api/v1/config/ui-dictionaries",
  116. "/api/v1/config/login-background",
  117. "/api/v1/categories/tree",
  118. "/api/v1/categories",
  119. "/api/v1/categories/reorder",
  120. "/api/v1/categories/{id}",
  121. "/api/v1/documents",
  122. "/api/v1/documents/tags",
  123. "/api/v1/documents/reorder",
  124. "/api/v1/documents/{id}",
  125. "/api/v1/documents/batch-import",
  126. "/api/v1/documents/{id}/preview",
  127. "/api/v1/documents/{id}/re-extract",
  128. "/api/v1/documents/{id}/move",
  129. "/api/v1/documents/{id}/download",
  130. "/api/v1/main-plans/{id}/sub-plans",
  131. "/api/v1/main-plans/{id}/sub-plans/bind",
  132. "/api/v1/main-plans/{id}/sub-plans/{subPlanId}",
  133. "/api/v1/attachments",
  134. "/api/v1/attachments/{id}",
  135. "/api/v1/attachments/batch-import",
  136. "/api/v1/attachments/{id}/main-plans",
  137. "/api/v1/main-plans/{id}/attachments",
  138. "/api/v1/main-plans/{id}/attachments/bind",
  139. "/api/v1/main-plans/{id}/attachments/reorder",
  140. "/api/v1/main-plans/{id}/attachments/{attachmentId}",
  141. "/api/v1/main-plans/{id}/attachments/{bindingId}/move",
  142. "/api/v1/documents/{id}/description",
  143. "/api/v1/documents/{id}/permissions",
  144. "/api/v1/documents/{id}/permissions/entries/{entryId}/voucher",
  145. "/api/v1/documents/{id}/password",
  146. "/api/v1/documents/relation-presets",
  147. "/api/v1/documents/{id}/relations",
  148. "/api/v1/audit/logs",
  149. "/api/v1/statistics/documents",
  150. "/api/v1/audit/statistics/trend",
  151. "/api/v1/audit/statistics/actions",
  152. "/api/v1/audit/statistics/users",
  153. "/api/v1/recycle-bin/documents",
  154. "/api/v1/recycle-bin/documents/{id}/restore",
  155. "/api/v1/backup/config",
  156. "/api/v1/backup/jobs",
  157. "/api/v1/backup/jobs/current",
  158. "/api/v1/backup/jobs/current/cancel",
  159. "/api/v1/backup/test-target",
  160. "/api/v1/system/metrics/current",
  161. "/api/v1/system/metrics/history",
  162. "/api/v1/system/process",
  163. "/api/v1/system/storage-usage",
  164. "/api/v1/system/converter",
  165. "/api/v1/system/health",
  166. "/api/v1/system/info",
  167. "/api/v1/system/runtime-config",
  168. "/api/v1/system/logs",
  169. "/api/v1/system/logs/download",
  170. "/api/v1/system/maintenance/preview-cache",
  171. "/api/v1/system/users/{user_id}/force-offline",
  172. }
  173. assert document["info"]["version"] == "1.38.0"
  174. assert "/api/v1/documents/{id}/restore" not in document["paths"]
  175. assert document["paths"]["/api/v1/health"]["get"]["security"] == []
  176. login_path = yaml.safe_load(
  177. (OPENAPI_ROOT / "paths" / "auth.yaml").read_text(encoding="utf-8")
  178. )
  179. assert login_path["Login"]["post"]["security"] == []
  180. def test_openapi_string_id_component():
  181. schemas = yaml.safe_load(
  182. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  183. )
  184. assert schemas["StringId"] == {
  185. "type": "string",
  186. "pattern": "^[0-9]+$",
  187. "description": "数据库BIGINT在JSON中的安全十进制字符串表达。",
  188. }
  189. def test_openapi_health_response_matches_contract():
  190. schemas = yaml.safe_load(
  191. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  192. )
  193. health = schemas["HealthResponse"]
  194. assert set(health["required"]) == {"code", "message", "data", "requestId"}
  195. assert "timestamp" not in health["properties"]
  196. def test_openapi_has_all_fixed_enum_groups():
  197. schemas = yaml.safe_load(
  198. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  199. )
  200. expected = {
  201. "RoleCode",
  202. "AllowedModule",
  203. "DocumentType",
  204. "DocumentStatus",
  205. "SecurityLevel",
  206. "AttachmentType",
  207. "AllowedAction",
  208. "CategoryType",
  209. "EnabledStatus",
  210. "AuditResult",
  211. "AuditAction",
  212. "AuditTarget",
  213. }
  214. assert expected <= set(schemas)
  215. def test_storage_directory_skeleton(tmp_path: Path):
  216. root = ensure_storage_directories(tmp_path / "storage")
  217. assert {path.name for path in root.iterdir()} == set(STORAGE_SUBDIRECTORIES)
  218. @pytest.mark.parametrize(
  219. "unsafe_path",
  220. [
  221. "../outside.pdf",
  222. "original/../../outside.pdf",
  223. "/absolute/file.pdf",
  224. "C:\\Windows\\system.ini",
  225. "",
  226. ],
  227. )
  228. def test_storage_path_cannot_escape_root(tmp_path: Path, unsafe_path: str):
  229. with pytest.raises(UnsafeStoragePathError):
  230. resolve_storage_path(unsafe_path, tmp_path)
  231. def test_storage_path_resolves_safe_relative_path(tmp_path: Path):
  232. resolved = resolve_storage_path(
  233. "original/2026/07/1/file.pdf",
  234. tmp_path,
  235. )
  236. assert resolved == (tmp_path / "original/2026/07/1/file.pdf").resolve()