test_q1b_ui_dictionaries.py 9.1 KB

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