test_enterprise_connector_metadata_frontend_contract.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. assert pattern_match.group(1) == "/^[a-z][a-z0-9_-]{2,63}$/"
  83. connector_validator = _validator_expression(connector_ids)
  84. mode_validator = _validator_expression(mode)
  85. mode_values_match = re.fullmatch(
  86. r"value\s*=>\s*\[\s*(.*?)\s*\]\.includes\(value\)", mode_validator
  87. )
  88. assert mode_values_match, "mode validator must be an explicit allowlist"
  89. assert re.findall(r"['\"]([^'\"]+)['\"]", mode_values_match.group(1)) == [
  90. "database",
  91. "rest-catalog",
  92. ]
  93. assert re.search(r"\btype\s*:\s*Array\b", connector_ids)
  94. assert re.search(r"\brequired\s*:\s*true\b", connector_ids)
  95. assert re.search(r"\btype\s*:\s*String\b", mode)
  96. assert re.search(r"\brequired\s*:\s*true\b", mode)
  97. assert "value.length > 0" in connector_validator
  98. assert re.search(r"value\.every\s*\(", connector_validator)
  99. assert "CONNECTOR_ID_PATTERN.test(id)" in connector_validator
  100. results = _run_node(
  101. f"""
  102. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  103. const connectorIdsValidator = ({connector_validator});
  104. const modeValidator = ({mode_validator});
  105. process.stdout.write(JSON.stringify({{
  106. connectorIds: {{
  107. valid: connectorIdsValidator(['oracle', 'postgresql', 'sqlserver']),
  108. empty: connectorIdsValidator([]),
  109. nonString: connectorIdsValidator(['oracle', 7]),
  110. tooShort: connectorIdsValidator(['ab']),
  111. uppercase: connectorIdsValidator(['Oracle']),
  112. dot: connectorIdsValidator(['oracle.prod']),
  113. unsafeCharacter: connectorIdsValidator(['oracle%prod']),
  114. maxLength: connectorIdsValidator(['a' + 'b'.repeat(63)]),
  115. tooLong: connectorIdsValidator(['a'.repeat(65)])
  116. }},
  117. modes: {{
  118. database: modeValidator('database'),
  119. restCatalog: modeValidator('rest-catalog'),
  120. metadata: modeValidator('metadata'),
  121. thirdMode: modeValidator('catalog-v2'),
  122. empty: modeValidator(''),
  123. typo: modeValidator('rest_catalog')
  124. }}
  125. }}));
  126. """
  127. )
  128. assert results["connectorIds"] == {
  129. "valid": True,
  130. "empty": False,
  131. "nonString": False,
  132. "tooShort": False,
  133. "uppercase": False,
  134. "dot": False,
  135. "unsafeCharacter": False,
  136. "maxLength": True,
  137. "tooLong": False,
  138. }
  139. assert results["modes"] == {
  140. "database": True,
  141. "restCatalog": True,
  142. "metadata": False,
  143. "thirdMode": False,
  144. "empty": False,
  145. "typo": False,
  146. }
  147. def test_normalized_connector_ids_deduplicates_valid_values_and_fails_closed():
  148. source = _component_source()
  149. pattern_match = re.search(
  150. r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
  151. )
  152. assert pattern_match, "connector ID regex declaration is missing"
  153. normalized = _function_body(source, "normalizedConnectorIds")
  154. assert "Array.isArray(this.connectorIds)" in normalized
  155. assert "CONNECTOR_ID_PATTERN.test(id)" in normalized
  156. assert "return []" in normalized
  157. assert "new Set(this.connectorIds)" in normalized
  158. results = _run_node(
  159. f"""
  160. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  161. const normalize = new Function(
  162. 'CONNECTOR_ID_PATTERN', `return function normalizedConnectorIds () {{{normalized}}}`
  163. )(CONNECTOR_ID_PATTERN);
  164. const evaluate = connectorIds => normalize.call({{ connectorIds }});
  165. process.stdout.write(JSON.stringify({{
  166. deduplicated: evaluate(['oracle', 'oracle', 'sqlserver']),
  167. malformed: evaluate(['oracle', 'Oracle']),
  168. nonArray: evaluate(null)
  169. }}));
  170. """
  171. )
  172. assert results == {
  173. "deduplicated": ["oracle", "sqlserver"],
  174. "malformed": [],
  175. "nonArray": [],
  176. }
  177. def test_load_all_executes_fail_closed_filtering_and_clears_error_state():
  178. source = _component_source()
  179. load_all = _function_body(source, "loadAll")
  180. clear_state = _function_body(source, "clearConnectorState")
  181. guard = load_all.index("if (!this.normalizedConnectorIds.length)")
  182. api_calls = (
  183. "getConnectorManifests()",
  184. "getConnectorRuns()",
  185. "getDatasourceGraph()",
  186. )
  187. assert "this.clearConnectorState()" in load_all[:guard]
  188. assert "return" in load_all[guard : min(load_all.index(call) for call in api_calls)]
  189. assert all(guard < load_all.index(call) for call in api_calls)
  190. assert "this.manifests = []" in clear_state
  191. assert "this.runs = []" in clear_state
  192. assert "this.graph = {}" in clear_state
  193. assert load_all.count("new Set(this.normalizedConnectorIds)") == 1
  194. assert re.search(
  195. r"this\.manifests\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  196. load_all,
  197. )
  198. assert re.search(
  199. r"this\.runs\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  200. load_all,
  201. )
  202. assert "await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()])" in load_all
  203. catch_block = re.search(r"catch\s*\([^)]*\)\s*\{(.*?)\}", load_all, re.S)
  204. assert catch_block and "this.clearConnectorState()" in catch_block.group(1)
  205. results = _run_node(
  206. f"""
  207. const buildLoadAll = new Function(
  208. 'getConnectorManifests', 'getConnectorRuns', 'getDatasourceGraph',
  209. `return async function loadAll () {{{load_all}}}`
  210. );
  211. async function invoke(ids, failRuns) {{
  212. const calls = [];
  213. const errors = [];
  214. const context = {{
  215. normalizedConnectorIds: ids,
  216. loading: false,
  217. manifests: [{{ connector_id: 'stale' }}],
  218. runs: [{{ connector_id: 'stale' }}],
  219. graph: {{ node_count: 99 }},
  220. clearConnectorState () {{ this.manifests = []; this.runs = []; this.graph = {{}}; }},
  221. $snackbar: {{ error: error => errors.push(String(error)) }}
  222. }};
  223. const response = data => Promise.resolve({{ data }});
  224. const loadAll = buildLoadAll(
  225. () => {{ calls.push('manifests'); return response({{ manifests: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  226. () => {{ calls.push('runs'); return failRuns ? Promise.reject(new Error('runs unavailable')) : response({{ runs: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  227. () => {{ calls.push('graph'); return response({{ summary: {{ node_count: 1, edge_count: 2 }} }}); }}
  228. );
  229. await loadAll.call(context);
  230. return {{ calls, errors, manifests: context.manifests, runs: context.runs, graph: context.graph, loading: context.loading }};
  231. }}
  232. const empty = await invoke([], false);
  233. const filtered = await invoke(['oracle'], false);
  234. const failed = await invoke(['oracle'], true);
  235. process.stdout.write(JSON.stringify({{ empty, filtered, failed }}));
  236. """
  237. )
  238. assert results["empty"] == {
  239. "calls": [],
  240. "errors": [],
  241. "manifests": [],
  242. "runs": [],
  243. "graph": {},
  244. "loading": False,
  245. }
  246. assert results["filtered"]["calls"] == ["manifests", "runs", "graph"]
  247. assert results["filtered"]["manifests"] == [{"connector_id": "oracle"}]
  248. assert results["filtered"]["runs"] == [{"connector_id": "oracle"}]
  249. assert results["filtered"]["graph"] == {"node_count": 1, "edge_count": 2}
  250. assert results["failed"]["manifests"] == []
  251. assert results["failed"]["runs"] == []
  252. assert results["failed"]["graph"] == {}
  253. assert len(results["failed"]["errors"]) == 1
  254. def test_operations_await_refresh_and_rest_configuration_is_mode_gated():
  255. source = _component_source()
  256. markup = source.split("<script>", 1)[0]
  257. dry_run = _function_body(source, "dryRun")
  258. for field in ("base_url", "allowed_host"):
  259. fields = re.findall(
  260. rf'<v-text-field\b[^>]*v-model\.trim="form\.{field}"[^>]*/>', markup
  261. )
  262. assert len(fields) == 1
  263. assert "v-if=\"mode === 'rest-catalog'\"" in fields[0]
  264. assert re.search(
  265. 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*\)",
  266. dry_run,
  267. )
  268. assert '@click="loadAll"' in markup
  269. for name, connector_call in (
  270. ("dryRun", "executeConnectorRun"),
  271. ("cancel", "cancelConnectorRun"),
  272. ("resume", "resumeConnectorRun"),
  273. ):
  274. body = _function_body(source, name)
  275. assert body.index(f"await {connector_call}") < body.index("await this.loadAll()")
  276. results = _run_node(
  277. f"""
  278. const buildDryRun = new Function(
  279. 'executeConnectorRun', `return async function dryRun () {{{dry_run}}}`
  280. );
  281. async function invoke(mode) {{
  282. const payloads = [];
  283. const events = [];
  284. const context = {{
  285. mode,
  286. selected: {{ connector_id: 'oracle', version: '1.0.0' }},
  287. form: {{ source_uid: 'source-1', credential_ref: 'env:TEST', base_url: 'https://catalog.example', allowed_host: 'catalog.example' }},
  288. dialog: true,
  289. loadAll: async () => events.push('loadAll'),
  290. $snackbar: {{ error: error => {{ throw error; }} }}
  291. }};
  292. const dryRun = buildDryRun(async payload => {{ events.push('execute'); payloads.push(payload); }});
  293. await dryRun.call(context);
  294. return {{ payload: payloads[0], events, dialog: context.dialog }};
  295. }}
  296. const database = await invoke('database');
  297. const restCatalog = await invoke('rest-catalog');
  298. process.stdout.write(JSON.stringify({{ database, restCatalog }}));
  299. """
  300. )
  301. assert results["database"]["payload"]["config"] == {"credential_ref": "env:TEST"}
  302. assert results["restCatalog"]["payload"]["config"] == {
  303. "credential_ref": "env:TEST",
  304. "base_url": "https://catalog.example",
  305. "allowed_host": "catalog.example",
  306. }
  307. assert results["database"]["events"] == ["execute", "loadAll"]
  308. assert results["restCatalog"]["events"] == ["execute", "loadAll"]
  309. assert results["database"]["dialog"] is False
  310. def test_database_wrapper_is_database_only_and_keeps_scope_warning():
  311. source = DATABASE_PAGE.read_text(encoding="utf-8")
  312. assert re.search(r"<h1\b[^>]*>\s*企业连接器\s*</h1>", source)
  313. assert "rest-catalog" not in source
  314. assert "当前开放范围仅限数据库访问。文件目录、对象存储、API 与消息系统等来源将在后续版本扩展开发。" in source
  315. assert re.search(
  316. r"<connector-operations\s+:connector-ids=\"\['oracle', 'postgresql', 'sqlserver'\]\"\s+mode=\"database\"\s*/>",
  317. source,
  318. )