test_enterprise_connector_metadata_frontend_contract.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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. METADATA_INDEX = ROOT / "frontend/src/views/dataGovernance/metadata/index.vue"
  9. METADATA_MANAGEMENT = ROOT / "frontend/src/views/dataGovernance/metadata/components/metadataManagement.vue"
  10. EXTERNAL_CATALOG_ACCESS = ROOT / "frontend/src/views/dataGovernance/metadata/components/externalCatalogAccess.vue"
  11. def _balanced_block(source: str, start: int) -> str:
  12. """Return a JavaScript object/function block while ignoring strings/comments."""
  13. assert source[start] == "{"
  14. depth = 0
  15. quote = None
  16. escaped = False
  17. index = start
  18. while index < len(source):
  19. char = source[index]
  20. following = source[index + 1] if index + 1 < len(source) else ""
  21. if quote:
  22. if escaped:
  23. escaped = False
  24. elif char == "\\":
  25. escaped = True
  26. elif char == quote:
  27. quote = None
  28. index += 1
  29. continue
  30. if char in "'\"`":
  31. quote = char
  32. index += 1
  33. continue
  34. if char == "/" and following == "/":
  35. newline = source.find("\n", index)
  36. index = len(source) if newline < 0 else newline + 1
  37. continue
  38. if char == "/" and following == "*":
  39. end = source.find("*/", index + 2)
  40. assert end >= 0, "unterminated JavaScript comment"
  41. index = end + 2
  42. continue
  43. if char == "{":
  44. depth += 1
  45. elif char == "}":
  46. depth -= 1
  47. if depth == 0:
  48. return source[start : index + 1]
  49. index += 1
  50. raise AssertionError("unterminated JavaScript block")
  51. def _named_object(source: str, name: str) -> str:
  52. match = re.search(rf"\b{re.escape(name)}\s*:\s*\{{", source)
  53. assert match, f"{name} object was not found"
  54. return _balanced_block(source, source.index("{", match.start()))
  55. def _function_body(source: str, name: str) -> str:
  56. match = re.search(rf"\b(?:async\s+)?{re.escape(name)}\s*\([^)]*\)\s*\{{", source)
  57. assert match, f"{name} method was not found"
  58. block = _balanced_block(source, match.end() - 1)
  59. return block[1:-1]
  60. def _validator_expression(prop: str) -> str:
  61. match = re.search(r"\bvalidator\s*:\s*(.+?)(?=\n\s*\})", prop, re.S)
  62. assert match, "prop validator was not found"
  63. return match.group(1).strip().rstrip(",")
  64. def _run_node(script: str) -> dict:
  65. result = subprocess.run(
  66. ["node", "--input-type=module", "-e", script],
  67. check=False,
  68. capture_output=True,
  69. text=True,
  70. cwd=ROOT / "frontend",
  71. )
  72. assert result.returncode == 0, result.stderr
  73. return json.loads(result.stdout)
  74. def _component_source() -> str:
  75. assert COMPONENT.is_file(), "ConnectorOperations shared surface is missing"
  76. return COMPONENT.read_text(encoding="utf-8")
  77. def test_connector_prop_validators_execute_strict_database_and_rest_contracts():
  78. source = _component_source()
  79. connector_ids = _named_object(_named_object(source, "props"), "connectorIds")
  80. mode = _named_object(_named_object(source, "props"), "mode")
  81. pattern_match = re.search(
  82. r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
  83. )
  84. assert pattern_match, "connector ID regex declaration is missing"
  85. assert pattern_match.group(1) == "/^[a-z][a-z0-9_-]{2,63}$/"
  86. connector_validator = _validator_expression(connector_ids)
  87. mode_validator = _validator_expression(mode)
  88. mode_values_match = re.fullmatch(
  89. r"value\s*=>\s*\[\s*(.*?)\s*\]\.includes\(value\)", mode_validator
  90. )
  91. assert mode_values_match, "mode validator must be an explicit allowlist"
  92. assert re.findall(r"['\"]([^'\"]+)['\"]", mode_values_match.group(1)) == [
  93. "database",
  94. "rest-catalog",
  95. ]
  96. assert re.search(r"\btype\s*:\s*Array\b", connector_ids)
  97. assert re.search(r"\brequired\s*:\s*true\b", connector_ids)
  98. assert re.search(r"\btype\s*:\s*String\b", mode)
  99. assert re.search(r"\brequired\s*:\s*true\b", mode)
  100. assert "value.length > 0" in connector_validator
  101. assert re.search(r"value\.every\s*\(", connector_validator)
  102. assert "CONNECTOR_ID_PATTERN.test(id)" in connector_validator
  103. results = _run_node(
  104. f"""
  105. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  106. const connectorIdsValidator = ({connector_validator});
  107. const modeValidator = ({mode_validator});
  108. process.stdout.write(JSON.stringify({{
  109. connectorIds: {{
  110. valid: connectorIdsValidator(['oracle', 'postgresql', 'sqlserver']),
  111. empty: connectorIdsValidator([]),
  112. nonString: connectorIdsValidator(['oracle', 7]),
  113. tooShort: connectorIdsValidator(['ab']),
  114. uppercase: connectorIdsValidator(['Oracle']),
  115. dot: connectorIdsValidator(['oracle.prod']),
  116. unsafeCharacter: connectorIdsValidator(['oracle%prod']),
  117. maxLength: connectorIdsValidator(['a' + 'b'.repeat(63)]),
  118. tooLong: connectorIdsValidator(['a'.repeat(65)])
  119. }},
  120. modes: {{
  121. database: modeValidator('database'),
  122. restCatalog: modeValidator('rest-catalog'),
  123. metadata: modeValidator('metadata'),
  124. thirdMode: modeValidator('catalog-v2'),
  125. empty: modeValidator(''),
  126. typo: modeValidator('rest_catalog')
  127. }}
  128. }}));
  129. """
  130. )
  131. assert results["connectorIds"] == {
  132. "valid": True,
  133. "empty": False,
  134. "nonString": False,
  135. "tooShort": False,
  136. "uppercase": False,
  137. "dot": False,
  138. "unsafeCharacter": False,
  139. "maxLength": True,
  140. "tooLong": False,
  141. }
  142. assert results["modes"] == {
  143. "database": True,
  144. "restCatalog": True,
  145. "metadata": False,
  146. "thirdMode": False,
  147. "empty": False,
  148. "typo": False,
  149. }
  150. def test_normalized_connector_ids_deduplicates_valid_values_and_fails_closed():
  151. source = _component_source()
  152. pattern_match = re.search(
  153. r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
  154. )
  155. assert pattern_match, "connector ID regex declaration is missing"
  156. normalized = _function_body(source, "normalizedConnectorIds")
  157. assert "Array.isArray(this.connectorIds)" in normalized
  158. assert "CONNECTOR_ID_PATTERN.test(id)" in normalized
  159. assert "return []" in normalized
  160. assert "new Set(this.connectorIds)" in normalized
  161. results = _run_node(
  162. f"""
  163. const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
  164. const normalize = new Function(
  165. 'CONNECTOR_ID_PATTERN', `return function normalizedConnectorIds () {{{normalized}}}`
  166. )(CONNECTOR_ID_PATTERN);
  167. const evaluate = connectorIds => normalize.call({{ connectorIds }});
  168. process.stdout.write(JSON.stringify({{
  169. deduplicated: evaluate(['oracle', 'oracle', 'sqlserver']),
  170. malformed: evaluate(['oracle', 'Oracle']),
  171. nonArray: evaluate(null)
  172. }}));
  173. """
  174. )
  175. assert results == {
  176. "deduplicated": ["oracle", "sqlserver"],
  177. "malformed": [],
  178. "nonArray": [],
  179. }
  180. def test_load_all_executes_fail_closed_filtering_and_clears_error_state():
  181. source = _component_source()
  182. load_all = _function_body(source, "loadAll")
  183. clear_state = _function_body(source, "clearConnectorState")
  184. guard = load_all.index("if (!this.normalizedConnectorIds.length)")
  185. api_calls = (
  186. "getConnectorManifests()",
  187. "getConnectorRuns()",
  188. "getDatasourceGraph()",
  189. )
  190. assert "this.clearConnectorState()" in load_all[:guard]
  191. assert "return" in load_all[guard : min(load_all.index(call) for call in api_calls)]
  192. assert all(guard < load_all.index(call) for call in api_calls)
  193. assert "this.manifests = []" in clear_state
  194. assert "this.runs = []" in clear_state
  195. assert "this.graph = {}" in clear_state
  196. assert load_all.count("new Set(this.normalizedConnectorIds)") == 1
  197. assert re.search(
  198. r"this\.manifests\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  199. load_all,
  200. )
  201. assert re.search(
  202. r"this\.runs\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
  203. load_all,
  204. )
  205. assert "await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()])" in load_all
  206. catch_block = re.search(r"catch\s*\([^)]*\)\s*\{(.*?)\}", load_all, re.S)
  207. assert catch_block and "this.clearConnectorState()" in catch_block.group(1)
  208. results = _run_node(
  209. f"""
  210. const buildLoadAll = new Function(
  211. 'getConnectorManifests', 'getConnectorRuns', 'getDatasourceGraph',
  212. `return async function loadAll () {{{load_all}}}`
  213. );
  214. async function invoke(ids, failRuns) {{
  215. const calls = [];
  216. const errors = [];
  217. const context = {{
  218. normalizedConnectorIds: ids,
  219. loading: false,
  220. manifests: [{{ connector_id: 'stale' }}],
  221. runs: [{{ connector_id: 'stale' }}],
  222. graph: {{ node_count: 99 }},
  223. clearConnectorState () {{ this.manifests = []; this.runs = []; this.graph = {{}}; }},
  224. $snackbar: {{ error: error => errors.push(String(error)) }}
  225. }};
  226. const response = data => Promise.resolve({{ data }});
  227. const loadAll = buildLoadAll(
  228. () => {{ calls.push('manifests'); return response({{ manifests: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  229. () => {{ calls.push('runs'); return failRuns ? Promise.reject(new Error('runs unavailable')) : response({{ runs: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
  230. () => {{ calls.push('graph'); return response({{ summary: {{ node_count: 1, edge_count: 2 }} }}); }}
  231. );
  232. await loadAll.call(context);
  233. return {{ calls, errors, manifests: context.manifests, runs: context.runs, graph: context.graph, loading: context.loading }};
  234. }}
  235. const empty = await invoke([], false);
  236. const filtered = await invoke(['oracle'], false);
  237. const failed = await invoke(['oracle'], true);
  238. process.stdout.write(JSON.stringify({{ empty, filtered, failed }}));
  239. """
  240. )
  241. assert results["empty"] == {
  242. "calls": [],
  243. "errors": [],
  244. "manifests": [],
  245. "runs": [],
  246. "graph": {},
  247. "loading": False,
  248. }
  249. assert results["filtered"]["calls"] == ["manifests", "runs", "graph"]
  250. assert results["filtered"]["manifests"] == [{"connector_id": "oracle"}]
  251. assert results["filtered"]["runs"] == [{"connector_id": "oracle"}]
  252. assert results["filtered"]["graph"] == {"node_count": 1, "edge_count": 2}
  253. assert results["failed"]["manifests"] == []
  254. assert results["failed"]["runs"] == []
  255. assert results["failed"]["graph"] == {}
  256. assert len(results["failed"]["errors"]) == 1
  257. def test_operations_await_refresh_and_rest_configuration_is_mode_gated():
  258. source = _component_source()
  259. markup = source.split("<script>", 1)[0]
  260. dry_run = _function_body(source, "dryRun")
  261. for field in ("base_url", "allowed_host"):
  262. fields = re.findall(
  263. rf'<v-text-field\b[^>]*v-model\.trim="form\.{field}"[^>]*/>', markup
  264. )
  265. assert len(fields) == 1
  266. assert "v-if=\"mode === 'rest-catalog'\"" in fields[0]
  267. assert re.search(
  268. 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*\)",
  269. dry_run,
  270. )
  271. assert '@click="loadAll"' in markup
  272. for name, connector_call in (
  273. ("dryRun", "executeConnectorRun"),
  274. ("cancel", "cancelConnectorRun"),
  275. ("resume", "resumeConnectorRun"),
  276. ):
  277. body = _function_body(source, name)
  278. assert body.index(f"await {connector_call}") < body.index("await this.loadAll()")
  279. results = _run_node(
  280. f"""
  281. const buildDryRun = new Function(
  282. 'executeConnectorRun', `return async function dryRun () {{{dry_run}}}`
  283. );
  284. async function invoke(mode) {{
  285. const payloads = [];
  286. const events = [];
  287. const context = {{
  288. mode,
  289. selected: {{ connector_id: 'oracle', version: '1.0.0' }},
  290. form: {{ source_uid: 'source-1', credential_ref: 'env:TEST', base_url: 'https://catalog.example', allowed_host: 'catalog.example' }},
  291. dialog: true,
  292. loadAll: async () => events.push('loadAll'),
  293. $snackbar: {{ error: error => {{ throw error; }} }}
  294. }};
  295. const dryRun = buildDryRun(async payload => {{ events.push('execute'); payloads.push(payload); }});
  296. await dryRun.call(context);
  297. return {{ payload: payloads[0], events, dialog: context.dialog }};
  298. }}
  299. const database = await invoke('database');
  300. const restCatalog = await invoke('rest-catalog');
  301. process.stdout.write(JSON.stringify({{ database, restCatalog }}));
  302. """
  303. )
  304. assert results["database"]["payload"]["config"] == {"credential_ref": "env:TEST"}
  305. assert results["restCatalog"]["payload"]["config"] == {
  306. "credential_ref": "env:TEST",
  307. "base_url": "https://catalog.example",
  308. "allowed_host": "catalog.example",
  309. }
  310. assert results["database"]["events"] == ["execute", "loadAll"]
  311. assert results["restCatalog"]["events"] == ["execute", "loadAll"]
  312. assert results["database"]["dialog"] is False
  313. def test_database_wrapper_is_database_only_and_keeps_scope_warning():
  314. source = DATABASE_PAGE.read_text(encoding="utf-8")
  315. assert re.search(r"<h1\b[^>]*>\s*企业连接器\s*</h1>", source)
  316. assert "rest-catalog" not in source
  317. assert "当前开放范围仅限数据库访问。文件目录、对象存储、API 与消息系统等来源将在后续版本扩展开发。" in source
  318. assert re.search(
  319. r"<connector-operations\s+:connector-ids=\"\['oracle', 'postgresql', 'sqlserver'\]\"\s+mode=\"database\"\s*/>",
  320. source,
  321. )
  322. def test_metadata_wrapper_exposes_two_tabs_and_gates_external_catalog_by_permission():
  323. source = METADATA_INDEX.read_text(encoding="utf-8")
  324. markup = source.split("<script>", 1)[0]
  325. assert len(re.findall(r"<v-tab(?![-\w])", markup)) == 2
  326. assert len(re.findall(r"<v-tab-item(?![-\w])", markup)) == 2
  327. assert "元数据管理" in markup
  328. assert "外部目录接入" in markup
  329. assert re.search(r"<v-tab\b[^>]*v-if=\"canReadConnectors\"[^>]*>\s*外部目录接入\s*</v-tab>", markup)
  330. assert re.search(r"<v-tab-item\b[^>]*v-if=\"canReadConnectors\"[^>]*>", markup)
  331. assert re.search(r"<external-catalog-access\b", markup)
  332. assert "<connector-operations" not in source
  333. computed = _named_object(source, "computed")
  334. connector_permissions = _function_body(computed, "connectorPermissions")
  335. can_read = _function_body(computed, "canReadConnectors")
  336. result = _run_node(
  337. f"""
  338. const connectorPermissions = new Function(`return function connectorPermissions () {{{connector_permissions}}}`)();
  339. const canReadConnectors = new Function(`return function canReadConnectors () {{{can_read}}}`)();
  340. const evaluate = state => {{
  341. const context = {{ $store: {{ state }} }};
  342. context.connectorPermissions = connectorPermissions.call(context);
  343. return canReadConnectors.call(context);
  344. }};
  345. process.stdout.write(JSON.stringify({{
  346. authorized: evaluate({{ user: {{ userInfo: {{ permissions: ['connectors:read'] }} }} }}),
  347. unrelated: evaluate({{ user: {{ userInfo: {{ permissions: ['metadata:read'] }} }} }}),
  348. missingUser: evaluate({{}}),
  349. malformedPermissions: evaluate({{ user: {{ userInfo: {{ permissions: 'connectors:read' }} }} }})
  350. }}));
  351. """
  352. )
  353. assert result == {
  354. "authorized": True,
  355. "unrelated": False,
  356. "missingUser": False,
  357. "malformedPermissions": False,
  358. }
  359. def test_metadata_wrapper_resets_external_tab_when_connector_read_is_revoked():
  360. source = METADATA_INDEX.read_text(encoding="utf-8")
  361. watcher = _named_object(source, "watch")
  362. reset_active_tab = _function_body(watcher, "canReadConnectors")
  363. result = _run_node(
  364. f"""
  365. const resetActiveTab = new Function(`return function canReadConnectors (canReadConnectors) {{{reset_active_tab}}}`)();
  366. const revoked = {{ activeTab: 1 }};
  367. const authorized = {{ activeTab: 1 }};
  368. const management = {{ activeTab: 0 }};
  369. resetActiveTab.call(revoked, false);
  370. resetActiveTab.call(authorized, true);
  371. resetActiveTab.call(management, false);
  372. process.stdout.write(JSON.stringify({{ revoked, authorized, management }}));
  373. """
  374. )
  375. assert result == {
  376. "revoked": {"activeTab": 0},
  377. "authorized": {"activeTab": 1},
  378. "management": {"activeTab": 0},
  379. }
  380. def test_metadata_management_preserves_crud_audit_and_research_link_after_move():
  381. source = METADATA_MANAGEMENT.read_text(encoding="utf-8")
  382. for marker in (
  383. "新增元数据",
  384. "handleSubmit",
  385. "handleDelete",
  386. "getMetaDataList",
  387. "handleAddAudit",
  388. "handleAuditSubmit",
  389. "数据研发中心",
  390. "/data-governance/development",
  391. "智能助手",
  392. ):
  393. assert marker in source
  394. assert "import FilterList from '../../components/Filter'" in source
  395. assert "import Edit from './edit'" in source
  396. assert "import AddAudit from './addAudit'" in source
  397. def test_external_catalog_access_is_rest_only_and_cannot_mount_when_unauthorized():
  398. index_source = METADATA_INDEX.read_text(encoding="utf-8")
  399. access_source = EXTERNAL_CATALOG_ACCESS.read_text(encoding="utf-8")
  400. markup = access_source.split("<script>", 1)[0]
  401. assert "受控 HTTPS 元数据目录" in access_source
  402. assert "不是任意业务 API 采集" in access_source
  403. assert "企业 URL、允许主机、凭据与 UAT 由外部负责" in access_source
  404. assert re.search(
  405. r"<connector-operations\s+:connector-ids=\"\['rest-catalog'\]\"\s+mode=\"rest-catalog\"\s*/>",
  406. markup,
  407. )
  408. assert "ConnectorOperations" in access_source
  409. assert not any(name in access_source for name in ("oracle", "postgresql", "sqlserver"))
  410. index_markup = index_source.split("<script>", 1)[0]
  411. external_tab_item = re.search(
  412. r"<v-tab-item\b(?P<attrs>[^>]*)>\s*<external-catalog-access\b",
  413. index_markup,
  414. re.S,
  415. )
  416. assert external_tab_item, "external catalog component must be inside a tab item"
  417. assert 'v-if="canReadConnectors"' in external_tab_item.group("attrs")
  418. assert index_markup.count("<external-catalog-access") == 1