test_openapi_storage.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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/users/me",
  76. "/api/v1/organizations/tree",
  77. "/api/v1/users",
  78. "/api/v1/config/ui-dictionaries",
  79. "/api/v1/categories/tree",
  80. "/api/v1/categories",
  81. "/api/v1/categories/{id}",
  82. "/api/v1/documents",
  83. "/api/v1/documents/{id}",
  84. "/api/v1/documents/batch-import",
  85. "/api/v1/documents/{id}/preview",
  86. "/api/v1/documents/{id}/download",
  87. "/api/v1/main-plans/{id}/sub-plans",
  88. "/api/v1/attachments",
  89. "/api/v1/attachments/{id}",
  90. "/api/v1/attachments/batch-import",
  91. "/api/v1/attachments/{id}/main-plans",
  92. "/api/v1/main-plans/{id}/attachments",
  93. "/api/v1/main-plans/{id}/attachments/bind",
  94. "/api/v1/main-plans/{id}/attachments/{attachmentId}",
  95. "/api/v1/documents/{id}/permissions",
  96. "/api/v1/audit/logs",
  97. "/api/v1/statistics/documents",
  98. "/api/v1/audit/statistics/trend",
  99. "/api/v1/audit/statistics/actions",
  100. "/api/v1/audit/statistics/users",
  101. "/api/v1/recycle-bin/documents",
  102. "/api/v1/recycle-bin/documents/{id}/restore",
  103. }
  104. assert document["info"]["version"] == "1.6.0"
  105. assert "/api/v1/documents/{id}/restore" not in document["paths"]
  106. assert document["paths"]["/api/v1/health"]["get"]["security"] == []
  107. login_path = yaml.safe_load(
  108. (OPENAPI_ROOT / "paths" / "auth.yaml").read_text(encoding="utf-8")
  109. )
  110. assert login_path["Login"]["post"]["security"] == []
  111. def test_openapi_string_id_component():
  112. schemas = yaml.safe_load(
  113. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  114. )
  115. assert schemas["StringId"] == {
  116. "type": "string",
  117. "pattern": "^[0-9]+$",
  118. "description": "数据库BIGINT在JSON中的安全十进制字符串表达。",
  119. }
  120. def test_openapi_health_response_matches_contract():
  121. schemas = yaml.safe_load(
  122. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  123. )
  124. health = schemas["HealthResponse"]
  125. assert set(health["required"]) == {"code", "message", "data", "requestId"}
  126. assert "timestamp" not in health["properties"]
  127. def test_openapi_has_all_fixed_enum_groups():
  128. schemas = yaml.safe_load(
  129. (OPENAPI_ROOT / "components" / "schemas.yaml").read_text(encoding="utf-8")
  130. )
  131. expected = {
  132. "RoleCode",
  133. "AllowedModule",
  134. "DocumentType",
  135. "DocumentStatus",
  136. "SecurityLevel",
  137. "VisibilityType",
  138. "AttachmentType",
  139. "SubjectType",
  140. "AllowedAction",
  141. "CategoryType",
  142. "EnabledStatus",
  143. "AuditResult",
  144. "AuditAction",
  145. "AuditTarget",
  146. }
  147. assert expected <= set(schemas)
  148. def test_storage_directory_skeleton(tmp_path: Path):
  149. root = ensure_storage_directories(tmp_path / "storage")
  150. assert {path.name for path in root.iterdir()} == set(STORAGE_SUBDIRECTORIES)
  151. @pytest.mark.parametrize(
  152. "unsafe_path",
  153. [
  154. "../outside.pdf",
  155. "original/../../outside.pdf",
  156. "/absolute/file.pdf",
  157. "C:\\Windows\\system.ini",
  158. "",
  159. ],
  160. )
  161. def test_storage_path_cannot_escape_root(tmp_path: Path, unsafe_path: str):
  162. with pytest.raises(UnsafeStoragePathError):
  163. resolve_storage_path(unsafe_path, tmp_path)
  164. def test_storage_path_resolves_safe_relative_path(tmp_path: Path):
  165. resolved = resolve_storage_path(
  166. "original/2026/07/1/file.pdf",
  167. tmp_path,
  168. )
  169. assert resolved == (tmp_path / "original/2026/07/1/file.pdf").resolve()