test_openapi_storage.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  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. java_only_paths = {"/api/v1/auth/portal/proxy-verify"}
  47. openapi_methods = set()
  48. operation_ids = []
  49. for path, item in document["paths"].items():
  50. if path in java_only_paths:
  51. continue
  52. if "$ref" in item:
  53. target, _, fragment = item["$ref"].partition("#")
  54. referenced = yaml.safe_load(
  55. (OPENAPI_ROOT / target).read_text(encoding="utf-8")
  56. )
  57. item = referenced[fragment.lstrip("/")]
  58. for method, operation in item.items():
  59. if method.lower() in {"get", "post", "put", "delete", "patch"}:
  60. openapi_methods.add((method.upper(), path))
  61. operation_ids.append(operation["operationId"])
  62. flask_methods = set()
  63. for rule in flask_app.url_map.iter_rules():
  64. if not rule.rule.startswith("/api/v1"):
  65. continue
  66. path = re.sub(r"<(?:[^:>]+:)?([^>]+)>", r"{\1}", rule.rule)
  67. for method in rule.methods - {"HEAD", "OPTIONS"}:
  68. flask_methods.add((method, path))
  69. assert flask_methods == openapi_methods
  70. assert len(operation_ids) == len(set(operation_ids))
  71. def test_openapi_contains_only_q2b_paths():
  72. document = yaml.safe_load(
  73. (OPENAPI_ROOT / "openapi.yaml").read_text(encoding="utf-8")
  74. )
  75. assert set(document["paths"]) == {
  76. "/api/v1/health",
  77. "/api/v1/auth/captcha",
  78. "/api/v1/auth/login",
  79. "/api/v1/auth/logout",
  80. "/api/v1/auth/reauth",
  81. "/api/v1/auth/portal/verify",
  82. "/api/v1/auth/portal/proxy-verify",
  83. "/api/v1/auth/portal/setup-password",
  84. "/api/v1/users/me",
  85. "/api/v1/users/me/ui-theme",
  86. "/api/v1/organizations/tree",
  87. "/api/v1/organizations",
  88. "/api/v1/organizations/{id}",
  89. "/api/v1/organizations/{id}/ip-assignments",
  90. "/api/v1/organizations/reorder",
  91. "/api/v1/ip-entries",
  92. "/api/v1/ip-entries/{id}",
  93. "/api/v1/users",
  94. "/api/v1/users/{id}",
  95. "/api/v1/users/{id}/reset-password",
  96. "/api/v1/users/reorder",
  97. "/api/v1/roles",
  98. "/api/v1/roles/{id}",
  99. "/api/v1/roles/{id}/permissions",
  100. "/api/v1/roles/{id}/users",
  101. "/api/v1/roles/reorder",
  102. "/api/v1/config/ui-dictionaries",
  103. "/api/v1/config/login-background",
  104. "/api/v1/categories/tree",
  105. "/api/v1/categories",
  106. "/api/v1/categories/reorder",
  107. "/api/v1/categories/{id}",
  108. "/api/v1/documents",
  109. "/api/v1/documents/tags",
  110. "/api/v1/documents/reorder",
  111. "/api/v1/documents/{id}",
  112. "/api/v1/documents/batch-import",
  113. "/api/v1/documents/{id}/preview",
  114. "/api/v1/documents/{id}/re-extract",
  115. "/api/v1/documents/{id}/move",
  116. "/api/v1/documents/{id}/download",
  117. "/api/v1/main-plans/{id}/sub-plans",
  118. "/api/v1/main-plans/{id}/sub-plans/bind",
  119. "/api/v1/main-plans/{id}/sub-plans/{subPlanId}",
  120. "/api/v1/attachments",
  121. "/api/v1/attachments/{id}",
  122. "/api/v1/attachments/batch-import",
  123. "/api/v1/attachments/{id}/main-plans",
  124. "/api/v1/main-plans/{id}/attachments",
  125. "/api/v1/main-plans/{id}/attachments/bind",
  126. "/api/v1/main-plans/{id}/attachments/reorder",
  127. "/api/v1/main-plans/{id}/attachments/{attachmentId}",
  128. "/api/v1/main-plans/{id}/attachments/{bindingId}/move",
  129. "/api/v1/documents/{id}/description",
  130. "/api/v1/documents/{id}/permissions",
  131. "/api/v1/documents/{id}/permissions/entries/{entryId}/voucher",
  132. "/api/v1/documents/{id}/password",
  133. "/api/v1/documents/relation-presets",
  134. "/api/v1/documents/{id}/relations",
  135. "/api/v1/audit/logs",
  136. "/api/v1/statistics/documents",
  137. "/api/v1/audit/statistics/trend",
  138. "/api/v1/audit/statistics/actions",
  139. "/api/v1/audit/statistics/users",
  140. "/api/v1/recycle-bin/documents",
  141. "/api/v1/recycle-bin/documents/{id}/restore",
  142. "/api/v1/backup/config",
  143. "/api/v1/backup/jobs",
  144. "/api/v1/backup/jobs/current",
  145. "/api/v1/backup/jobs/current/cancel",
  146. "/api/v1/backup/test-target",
  147. "/api/v1/system/metrics/current",
  148. "/api/v1/system/metrics/history",
  149. "/api/v1/system/process",
  150. "/api/v1/system/storage-usage",
  151. "/api/v1/system/converter",
  152. "/api/v1/system/health",
  153. "/api/v1/system/info",
  154. "/api/v1/system/runtime-config",
  155. "/api/v1/system/logs",
  156. "/api/v1/system/logs/download",
  157. "/api/v1/system/maintenance/preview-cache",
  158. "/api/v1/system/users/{user_id}/force-offline",
  159. }
  160. assert document["info"]["version"] == "1.33.0"
  161. assert "/api/v1/documents/{id}/restore" not in document["paths"]
  162. assert document["paths"]["/api/v1/health"]["get"]["security"] == []
  163. login_path = yaml.safe_load(
  164. (OPENAPI_ROOT / "paths" / "auth.yaml").read_text(encoding="utf-8")
  165. )
  166. assert login_path["Login"]["post"]["security"] == []
  167. def test_openapi_string_id_component():
  168. schemas = yaml.safe_load(
  169. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  170. )
  171. assert schemas["StringId"] == {
  172. "type": "string",
  173. "pattern": "^[0-9]+$",
  174. "description": "数据库BIGINT在JSON中的安全十进制字符串表达。",
  175. }
  176. def test_openapi_health_response_matches_contract():
  177. schemas = yaml.safe_load(
  178. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  179. )
  180. health = schemas["HealthResponse"]
  181. assert set(health["required"]) == {"code", "message", "data", "requestId"}
  182. assert "timestamp" not in health["properties"]
  183. def test_openapi_has_all_fixed_enum_groups():
  184. schemas = yaml.safe_load(
  185. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  186. )
  187. expected = {
  188. "RoleCode",
  189. "AllowedModule",
  190. "DocumentType",
  191. "DocumentStatus",
  192. "SecurityLevel",
  193. "AttachmentType",
  194. "AllowedAction",
  195. "CategoryType",
  196. "EnabledStatus",
  197. "AuditResult",
  198. "AuditAction",
  199. "AuditTarget",
  200. }
  201. assert expected <= set(schemas)
  202. def test_storage_directory_skeleton(tmp_path: Path):
  203. root = ensure_storage_directories(tmp_path / "storage")
  204. assert {path.name for path in root.iterdir()} == set(STORAGE_SUBDIRECTORIES)
  205. @pytest.mark.parametrize(
  206. "unsafe_path",
  207. [
  208. "../outside.pdf",
  209. "original/../../outside.pdf",
  210. "/absolute/file.pdf",
  211. "C:\\Windows\\system.ini",
  212. "",
  213. ],
  214. )
  215. def test_storage_path_cannot_escape_root(tmp_path: Path, unsafe_path: str):
  216. with pytest.raises(UnsafeStoragePathError):
  217. resolve_storage_path(unsafe_path, tmp_path)
  218. def test_storage_path_resolves_safe_relative_path(tmp_path: Path):
  219. resolved = resolve_storage_path(
  220. "original/2026/07/1/file.pdf",
  221. tmp_path,
  222. )
  223. assert resolved == (tmp_path / "original/2026/07/1/file.pdf").resolve()