test_q1b_ui_dictionaries.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. from __future__ import annotations
  2. import copy
  3. import json
  4. import traceback
  5. from pathlib import Path
  6. from types import MappingProxyType
  7. import pytest
  8. from sqlalchemy import event, func, select
  9. from dms.config import (
  10. DEFAULT_UI_DICTIONARY_CONFIG_PATH,
  11. load_dms_config,
  12. )
  13. from dms.services.ui_dictionary_service import (
  14. DICTIONARY_ENUMS,
  15. UiDictionaryConfigError,
  16. load_ui_dictionary_config,
  17. )
  18. def _default_payload() -> dict:
  19. return json.loads(
  20. DEFAULT_UI_DICTIONARY_CONFIG_PATH.read_text(encoding="utf-8")
  21. )
  22. def _write_payload(path: Path, payload: dict) -> Path:
  23. path.write_text(
  24. json.dumps(payload, ensure_ascii=False),
  25. encoding="utf-8",
  26. )
  27. return path
  28. def test_default_ui_dictionary_loads_all_enum_codes_and_chinese_labels():
  29. config = load_ui_dictionary_config(DEFAULT_UI_DICTIONARY_CONFIG_PATH)
  30. assert config.version == "2026.1"
  31. assert config.locale == "zh-CN"
  32. assert isinstance(config.dictionaries, MappingProxyType)
  33. assert set(config.dictionaries) == set(DICTIONARY_ENUMS)
  34. for dictionary_name, enum_type in DICTIONARY_ENUMS.items():
  35. options = config.dictionaries[dictionary_name]
  36. assert {option.code for option in options} == {
  37. member.value for member in enum_type
  38. }
  39. assert list(options) == sorted(
  40. options, key=lambda option: (option.sort_no, option.code)
  41. )
  42. assert all(option.label for option in options)
  43. labels = {
  44. option.code: option.label
  45. for option in config.dictionaries["securityLevels"]
  46. }
  47. assert labels["INTERNAL"] == "内部"
  48. assert {
  49. option.code: option.label
  50. for option in config.dictionaries["visibilityTypes"]
  51. }["ALL_AUTHENTICATED"] == "全体已登录用户"
  52. assert {
  53. option.code: option.label
  54. for option in config.dictionaries["documentStatuses"]
  55. }["PUBLISHED"] == "已发布"
  56. def test_external_ui_dictionary_path_is_loaded_once(
  57. monkeypatch, tmp_path: Path
  58. ):
  59. payload = _default_payload()
  60. payload["dictionaries"]["securityLevels"][1]["label"] = "内部资料"
  61. path = _write_payload(tmp_path / "external.json", payload)
  62. monkeypatch.setenv("DMS_UI_DICTIONARY_CONFIG_PATH", str(path))
  63. environment_config = load_dms_config()
  64. loaded = load_ui_dictionary_config(
  65. environment_config["DMS_UI_DICTIONARY_CONFIG_PATH"]
  66. )
  67. path.write_text("{broken after startup", encoding="utf-8")
  68. response_data = loaded.to_dict()
  69. labels = {
  70. item["code"]: item["label"]
  71. for item in response_data["dictionaries"]["securityLevels"]
  72. }
  73. assert labels["INTERNAL"] == "内部资料"
  74. assert "DMS_UI_DICTIONARY_CONFIG_PATH" not in response_data
  75. def test_loaded_configuration_is_immutable():
  76. config = load_ui_dictionary_config(DEFAULT_UI_DICTIONARY_CONFIG_PATH)
  77. with pytest.raises(TypeError):
  78. config.dictionaries["extra"] = () # type: ignore[index]
  79. with pytest.raises(Exception):
  80. config.dictionaries["roleCodes"][0].label = "变化" # type: ignore[misc]
  81. @pytest.mark.parametrize(
  82. "mutation",
  83. [
  84. "missing_dictionary",
  85. "duplicate_code",
  86. "missing_code",
  87. "unknown_code",
  88. "empty_label",
  89. "negative_sort",
  90. "boolean_sort",
  91. "invalid_version",
  92. "invalid_locale",
  93. ],
  94. )
  95. def test_invalid_ui_dictionary_config_is_rejected_without_path_leak(
  96. mutation: str, tmp_path: Path
  97. ):
  98. payload = copy.deepcopy(_default_payload())
  99. roles = payload["dictionaries"]["roleCodes"]
  100. if mutation == "missing_dictionary":
  101. del payload["dictionaries"]["auditTargets"]
  102. elif mutation == "duplicate_code":
  103. roles.append(copy.deepcopy(roles[0]))
  104. elif mutation == "missing_code":
  105. roles.pop()
  106. elif mutation == "unknown_code":
  107. roles[0]["code"] = "UNKNOWN_ROLE"
  108. elif mutation == "empty_label":
  109. roles[0]["label"] = " "
  110. elif mutation == "negative_sort":
  111. roles[0]["sortNo"] = -1
  112. elif mutation == "boolean_sort":
  113. roles[0]["sortNo"] = True
  114. elif mutation == "invalid_version":
  115. payload["version"] = "latest"
  116. elif mutation == "invalid_locale":
  117. payload["locale"] = "../zh-CN"
  118. path = _write_payload(tmp_path / "sensitive-name.json", payload)
  119. with pytest.raises(UiDictionaryConfigError) as captured:
  120. load_ui_dictionary_config(path)
  121. assert str(path) not in str(captured.value)
  122. assert str(tmp_path) not in str(captured.value)
  123. def test_missing_malformed_and_non_utf8_config_are_rejected(tmp_path: Path):
  124. paths = [
  125. tmp_path / "missing.json",
  126. tmp_path / "malformed.json",
  127. tmp_path / "non-utf8.json",
  128. ]
  129. paths[1].write_text("{", encoding="utf-8")
  130. paths[2].write_bytes(b"\xff\xfe\x00")
  131. for path in paths:
  132. with pytest.raises(UiDictionaryConfigError) as captured:
  133. load_ui_dictionary_config(path)
  134. assert str(tmp_path) not in str(captured.value)
  135. rendered = "".join(
  136. traceback.format_exception(
  137. type(captured.value),
  138. captured.value,
  139. captured.value.__traceback__,
  140. )
  141. )
  142. assert str(tmp_path) not in rendered
  143. def test_application_startup_fails_for_invalid_external_config(
  144. monkeypatch, tmp_path: Path
  145. ):
  146. from flask import Flask
  147. from dms import init_dms
  148. path = tmp_path / "invalid.json"
  149. path.write_text("{", encoding="utf-8")
  150. monkeypatch.setenv("DMS_UI_DICTIONARY_CONFIG_PATH", str(path))
  151. with pytest.raises(UiDictionaryConfigError):
  152. init_dms(Flask("invalid-ui-dictionary"))
  153. def test_ui_dictionary_requires_valid_token(b2_client):
  154. missing = b2_client.get("/api/v1/config/ui-dictionaries")
  155. invalid = b2_client.get(
  156. "/api/v1/config/ui-dictionaries",
  157. headers={"Authorization": "Bearer invalid-token"},
  158. )
  159. assert missing.status_code == 401
  160. assert invalid.status_code == 401
  161. for response, expected_code in (
  162. (missing, "TOKEN_INVALID"),
  163. (invalid, "TOKEN_INVALID"),
  164. ):
  165. payload = response.get_json()
  166. assert payload["code"] == expected_code
  167. assert payload["requestId"] == response.headers["X-Request-Id"]
  168. def test_all_roles_receive_stable_dictionary_without_audit_or_business_query(
  169. b2_app, b2_client, token_for
  170. ):
  171. from dms.extensions import db
  172. from dms.models import AuditLog
  173. tokens = {
  174. username: token_for(username)
  175. for username in ("admin", "user")
  176. }
  177. with b2_app.app_context():
  178. audit_count_before = db.session.scalar(
  179. select(func.count()).select_from(AuditLog)
  180. )
  181. statements: list[str] = []
  182. def record_statement(
  183. _connection,
  184. _cursor,
  185. statement,
  186. _parameters,
  187. _context,
  188. _executemany,
  189. ):
  190. statements.append(statement.lower())
  191. event.listen(db.engine, "before_cursor_execute", record_statement)
  192. try:
  193. responses = [
  194. b2_client.get(
  195. "/api/v1/config/ui-dictionaries",
  196. headers={
  197. "Authorization": f"Bearer {token}",
  198. "Origin": "http://127.0.0.1:9346",
  199. },
  200. )
  201. for token in tokens.values()
  202. ]
  203. finally:
  204. event.remove(
  205. db.engine, "before_cursor_execute", record_statement
  206. )
  207. audit_count_after = db.session.scalar(
  208. select(func.count()).select_from(AuditLog)
  209. )
  210. assert all(response.status_code == 200 for response in responses)
  211. payloads = [response.get_json() for response in responses]
  212. assert all(
  213. payload["data"] == payloads[0]["data"] for payload in payloads[1:]
  214. )
  215. for response, payload in zip(responses, payloads):
  216. assert payload["requestId"] == response.headers["X-Request-Id"]
  217. assert "X-Request-Id" in response.headers[
  218. "Access-Control-Expose-Headers"
  219. ]
  220. assert response.headers["Cache-Control"] == "private, no-store"
  221. assert "configPath" not in payload["data"]
  222. assert "loadedAt" not in payload["data"]
  223. assert audit_count_after == audit_count_before
  224. assert len(statements) == len(tokens)
  225. assert all(
  226. statement.lstrip().startswith("select") and "sys_user" in statement
  227. for statement in statements
  228. )
  229. def test_openapi_ui_dictionary_contract_and_no_write_operation():
  230. import yaml
  231. backend_root = Path(__file__).resolve().parents[2]
  232. openapi_root = backend_root / "openapi"
  233. document = yaml.safe_load(
  234. (openapi_root / "openapi.yaml").read_text(encoding="utf-8")
  235. )
  236. path_item = yaml.safe_load(
  237. (openapi_root / "paths" / "configuration.yaml").read_text(
  238. encoding="utf-8"
  239. )
  240. )["UiDictionaries"]
  241. schemas = yaml.safe_load(
  242. (openapi_root / "components" / "schemas.yaml").read_text(
  243. encoding="utf-8"
  244. )
  245. )
  246. assert document["info"]["version"] == "1.5.0"
  247. assert set(path_item) == {"get"}
  248. assert path_item["get"]["operationId"] == "getUiDictionaries"
  249. assert {"200", "401", "500"} <= set(path_item["get"]["responses"])
  250. assert set(schemas["UiDictionaryGroups"]["required"]) == set(
  251. DICTIONARY_ENUMS
  252. )