test_enterprise_connector_metadata_frontend_contract.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. import json
  2. import re
  3. import subprocess
  4. from pathlib import Path
  5. ROOT = Path(__file__).resolve().parents[1]
  6. COMPONENT = ROOT / "frontend/src/components/connectors/ConnectorOperations.vue"
  7. DATABASE_PAGE = ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
  8. def _balanced_block(source: str, start: int) -> str:
  9. """Return a JavaScript object/function block while ignoring strings/comments."""
  10. assert source[start] == "{"
  11. depth = 0
  12. quote = None
  13. escaped = False
  14. index = start
  15. while index < len(source):
  16. char = source[index]
  17. following = source[index + 1] if index + 1 < len(source) else ""
  18. if quote:
  19. if escaped:
  20. escaped = False
  21. elif char == "\\":
  22. escaped = True
  23. elif char == quote:
  24. quote = None
  25. index += 1
  26. continue
  27. if char in "'\"`":
  28. quote = char
  29. index += 1
  30. continue
  31. if char == "/" and following == "/":
  32. newline = source.find("\n", index)
  33. index = len(source) if newline < 0 else newline + 1
  34. continue
  35. if char == "/" and following == "*":
  36. end = source.find("*/", index + 2)
  37. assert end >= 0, "unterminated JavaScript comment"
  38. index = end + 2
  39. continue
  40. if char == "{":
  41. depth += 1
  42. elif char == "}":
  43. depth -= 1
  44. if depth == 0:
  45. return source[start : index + 1]
  46. index += 1
  47. raise AssertionError("unterminated JavaScript block")
  48. def _named_object(source: str, name: str) -> str:
  49. match = re.search(rf"\b{re.escape(name)}\s*:\s*\{{", source)
  50. assert match, f"{name} object was not found"
  51. return _balanced_block(source, source.index("{", match.start()))
  52. def _function_body(source: str, name: str) -> str:
  53. match = re.search(rf"\b(?:async\s+)?{re.escape(name)}\s*\([^)]*\)\s*\{{", source)
  54. assert match, f"{name} method was not found"
  55. block = _balanced_block(source, match.end() - 1)
  56. return block[1:-1]
  57. def _validator_expression(prop: str) -> str:
  58. match = re.search(r"\bvalidator\s*:\s*(.+?)(?=\n\s*\})", prop, re.S)
  59. assert match, "prop validator was not found"
  60. return match.group(1).strip().rstrip(",")
  61. def _run_node(script: str) -> dict:
  62. result = subprocess.run(
  63. ["node", "--input-type=module", "-e", script],
  64. check=False,
  65. capture_output=True,
  66. text=True,
  67. cwd=ROOT / "frontend",
  68. )
  69. assert result.returncode == 0, result.stderr
  70. return json.loads(result.stdout)
  71. def _component_source() -> str:
  72. assert COMPONENT.is_file(), "ConnectorOperations shared surface is missing"
  73. return COMPONENT.read_text(encoding="utf-8")
  74. def test_connector_prop_validators_execute_strict_database_and_rest_contracts():
  75. source = _component_source()
  76. connector_ids = _named_object(_named_object(source, "props"), "connectorIds")
  77. mode = _named_object(_named_object(source, "props"), "mode")
  78. pattern_match = re.search(
  79. r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
  80. )
  81. assert pattern_match, "connector ID regex declaration is missing"
  82. connector_validator = _validator_expression(connector_ids)
  83. mode_validator = _validator_expression(mode)
  84. assert re.search(r"\btype\s*:\s*Array\b", connector_ids)
  85. assert re.search(r"\brequired\s*:\s*true\b", connector_ids)
  86. assert re.search(r"\btype\s*:\s*String\b", mode)
  87. assert re.search(r"\brequired\s*:\s*true\b", mode)
  88. assert "value.length > 0" in connector_validator
  89. assert re.search(r"value\.every\s*\(", connector_validator)
  90. assert "CONNECTOR_ID_PATTERN.test(id)" in connector_validator
  91. results = _run_node(
  92. f"""
  93. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  94. const connectorIdsValidator = ({connector_validator});
  95. const modeValidator = ({mode_validator});
  96. process.stdout.write(JSON.stringify({{
  97. connectorIds: {{
  98. valid: connectorIdsValidator(['oracle', 'postgresql', 'sqlserver']),
  99. empty: connectorIdsValidator([]),
  100. nonString: connectorIdsValidator(['oracle', 7]),
  101. tooShort: connectorIdsValidator(['ab']),
  102. uppercase: connectorIdsValidator(['Oracle']),
  103. tooLong: connectorIdsValidator(['a'.repeat(65)])
  104. }},
  105. modes: {{
  106. database: modeValidator('database'),
  107. restCatalog: modeValidator('rest-catalog'),
  108. metadata: modeValidator('metadata'),
  109. empty: modeValidator(''),
  110. typo: modeValidator('rest_catalog')
  111. }}
  112. }}));
  113. """
  114. )
  115. assert results["connectorIds"] == {
  116. "valid": True,
  117. "empty": False,
  118. "nonString": False,
  119. "tooShort": False,
  120. "uppercase": False,
  121. "tooLong": False,
  122. }
  123. assert results["modes"] == {
  124. "database": True,
  125. "restCatalog": True,
  126. "metadata": False,
  127. "empty": False,
  128. "typo": False,
  129. }
  130. def test_normalized_connector_ids_deduplicates_valid_values_and_fails_closed():
  131. source = _component_source()
  132. pattern_match = re.search(
  133. r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
  134. )
  135. assert pattern_match, "connector ID regex declaration is missing"
  136. normalized = _function_body(source, "normalizedConnectorIds")
  137. assert "Array.isArray(this.connectorIds)" in normalized
  138. assert "CONNECTOR_ID_PATTERN.test(id)" in normalized
  139. assert "return []" in normalized
  140. assert "new Set(this.connectorIds)" in normalized
  141. results = _run_node(
  142. f"""
  143. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  144. const normalize = new Function(
  145. 'CONNECTOR_ID_PATTERN', `return function normalizedConnectorIds () {{{normalized}}}`
  146. )(CONNECTOR_ID_PATTERN);
  147. const evaluate = connectorIds => normalize.call({{ connectorIds }});
  148. process.stdout.write(JSON.stringify({{
  149. deduplicated: evaluate(['oracle', 'oracle', 'sqlserver']),
  150. malformed: evaluate(['oracle', 'Oracle']),
  151. nonArray: evaluate(null)
  152. }}));
  153. """
  154. )
  155. assert results == {
  156. "deduplicated": ["oracle", "sqlserver"],
  157. "malformed": [],
  158. "nonArray": [],
  159. }
  160. def test_load_all_executes_fail_closed_filtering_and_clears_error_state():
  161. source = _component_source()
  162. load_all = _function_body(source, "loadAll")
  163. clear_state = _function_body(source, "clearConnectorState")
  164. guard = load_all.index("if (!this.normalizedConnectorIds.length)")
  165. api_calls = (
  166. "getConnectorManifests()",
  167. "getConnectorRuns()",
  168. "getDatasourceGraph()",
  169. )
  170. assert "this.clearConnectorState()" in load_all[:guard]
  171. assert "return" in load_all[guard : min(load_all.index(call) for call in api_calls)]
  172. assert all(guard < load_all.index(call) for call in api_calls)
  173. assert "this.manifests = []" in clear_state
  174. assert "this.runs = []" in clear_state
  175. assert "this.graph = {}" in clear_state
  176. assert load_all.count("new Set(this.normalizedConnectorIds)") == 1
  177. assert re.search(
  178. r"this\.manifests\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  179. load_all,
  180. )
  181. assert re.search(
  182. r"this\.runs\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  183. load_all,
  184. )
  185. assert "await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()])" in load_all
  186. catch_block = re.search(r"catch\s*\([^)]*\)\s*\{(.*?)\}", load_all, re.S)
  187. assert catch_block and "this.clearConnectorState()" in catch_block.group(1)
  188. results = _run_node(
  189. f"""
  190. const buildLoadAll = new Function(
  191. 'getConnectorManifests', 'getConnectorRuns', 'getDatasourceGraph',
  192. `return async function loadAll () {{{load_all}}}`
  193. );
  194. async function invoke(ids, failRuns) {{
  195. const calls = [];
  196. const errors = [];
  197. const context = {{
  198. normalizedConnectorIds: ids,
  199. loading: false,
  200. manifests: [{{ connector_id: 'stale' }}],
  201. runs: [{{ connector_id: 'stale' }}],
  202. graph: {{ node_count: 99 }},
  203. clearConnectorState () {{ this.manifests = []; this.runs = []; this.graph = {{}}; }},
  204. $snackbar: {{ error: error => errors.push(String(error)) }}
  205. }};
  206. const response = data => Promise.resolve({{ data }});
  207. const loadAll = buildLoadAll(
  208. () => {{ calls.push('manifests'); return response({{ manifests: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  209. () => {{ calls.push('runs'); return failRuns ? Promise.reject(new Error('runs unavailable')) : response({{ runs: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  210. () => {{ calls.push('graph'); return response({{ summary: {{ node_count: 1, edge_count: 2 }} }}); }}
  211. );
  212. await loadAll.call(context);
  213. return {{ calls, errors, manifests: context.manifests, runs: context.runs, graph: context.graph, loading: context.loading }};
  214. }}
  215. const empty = await invoke([], false);
  216. const filtered = await invoke(['oracle'], false);
  217. const failed = await invoke(['oracle'], true);
  218. process.stdout.write(JSON.stringify({{ empty, filtered, failed }}));
  219. """
  220. )
  221. assert results["empty"] == {
  222. "calls": [],
  223. "errors": [],
  224. "manifests": [],
  225. "runs": [],
  226. "graph": {},
  227. "loading": False,
  228. }
  229. assert results["filtered"]["calls"] == ["manifests", "runs", "graph"]
  230. assert results["filtered"]["manifests"] == [{"connector_id": "oracle"}]
  231. assert results["filtered"]["runs"] == [{"connector_id": "oracle"}]
  232. assert results["filtered"]["graph"] == {"node_count": 1, "edge_count": 2}
  233. assert results["failed"]["manifests"] == []
  234. assert results["failed"]["runs"] == []
  235. assert results["failed"]["graph"] == {}
  236. assert len(results["failed"]["errors"]) == 1
  237. def test_operations_await_refresh_and_rest_configuration_is_mode_gated():
  238. source = _component_source()
  239. markup = source.split("<script>", 1)[0]
  240. dry_run = _function_body(source, "dryRun")
  241. for field in ("base_url", "allowed_host"):
  242. fields = re.findall(
  243. rf'<v-text-field\b[^>]*v-model\.trim="form\.{field}"[^>]*/>', markup
  244. )
  245. assert len(fields) == 1
  246. assert "v-if=\"mode === 'rest-catalog'\"" in fields[0]
  247. assert re.search(
  248. r"if\s*\(this\.mode\s*===\s*'rest-catalog'\)\s*Object\.assign\(config,\s*\{\s*base_url:\s*this\.form\.base_url,\s*allowed_host:\s*this\.form\.allowed_host\s*\}\s*\)",
  249. dry_run,
  250. )
  251. assert '@click="loadAll"' in markup
  252. for name, connector_call in (
  253. ("dryRun", "executeConnectorRun"),
  254. ("cancel", "cancelConnectorRun"),
  255. ("resume", "resumeConnectorRun"),
  256. ):
  257. body = _function_body(source, name)
  258. assert body.index(f"await {connector_call}") < body.index("await this.loadAll()")
  259. results = _run_node(
  260. f"""
  261. const buildDryRun = new Function(
  262. 'executeConnectorRun', `return async function dryRun () {{{dry_run}}}`
  263. );
  264. async function invoke(mode) {{
  265. const payloads = [];
  266. const events = [];
  267. const context = {{
  268. mode,
  269. selected: {{ connector_id: 'oracle', version: '1.0.0' }},
  270. form: {{ source_uid: 'source-1', credential_ref: 'env:TEST', base_url: 'https://catalog.example', allowed_host: 'catalog.example' }},
  271. dialog: true,
  272. loadAll: async () => events.push('loadAll'),
  273. $snackbar: {{ error: error => {{ throw error; }} }}
  274. }};
  275. const dryRun = buildDryRun(async payload => {{ events.push('execute'); payloads.push(payload); }});
  276. await dryRun.call(context);
  277. return {{ payload: payloads[0], events, dialog: context.dialog }};
  278. }}
  279. const database = await invoke('database');
  280. const restCatalog = await invoke('rest-catalog');
  281. process.stdout.write(JSON.stringify({{ database, restCatalog }}));
  282. """
  283. )
  284. assert results["database"]["payload"]["config"] == {"credential_ref": "env:TEST"}
  285. assert results["restCatalog"]["payload"]["config"] == {
  286. "credential_ref": "env:TEST",
  287. "base_url": "https://catalog.example",
  288. "allowed_host": "catalog.example",
  289. }
  290. assert results["database"]["events"] == ["execute", "loadAll"]
  291. assert results["restCatalog"]["events"] == ["execute", "loadAll"]
  292. assert results["database"]["dialog"] is False
  293. def test_database_wrapper_is_database_only_and_keeps_scope_warning():
  294. source = DATABASE_PAGE.read_text(encoding="utf-8")
  295. assert "rest-catalog" not in source
  296. assert "当前开放范围仅限数据库访问。文件目录、对象存储、API 与消息系统等来源将在后续版本扩展开发。" in source
  297. assert re.search(
  298. r"<connector-operations\s+:connector-ids=\"\['oracle', 'postgresql', 'sqlserver'\]\"\s+mode=\"database\"\s*/>",
  299. source,
  300. )