test_openapi_storage.py 7.7 KB

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