浏览代码

test: strengthen connector operation contracts

马小龙 2 天之前
父节点
当前提交
3c2bbda905
共有 1 个文件被更改,包括 297 次插入27 次删除
  1. 297 27
      tests/test_enterprise_connector_metadata_frontend_contract.py

+ 297 - 27
tests/test_enterprise_connector_metadata_frontend_contract.py

@@ -1,5 +1,7 @@
-from pathlib import Path
+import json
 import re
+import subprocess
+from pathlib import Path
 
 
 ROOT = Path(__file__).resolve().parents[1]
@@ -7,47 +9,315 @@ COMPONENT = ROOT / "frontend/src/components/connectors/ConnectorOperations.vue"
 DATABASE_PAGE = ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
 
 
-def _method_block(source: str, name: str) -> str:
-    match = re.search(rf"(?s)\b{name}\s*\([^)]*\)\s*\{{(.*?)(?=\n\s{{4}}\w+\s*\(|\n\s{{2}}\}}\n\}})", source)
+def _balanced_block(source: str, start: int) -> str:
+    """Return a JavaScript object/function block while ignoring strings/comments."""
+    assert source[start] == "{"
+    depth = 0
+    quote = None
+    escaped = False
+    index = start
+    while index < len(source):
+        char = source[index]
+        following = source[index + 1] if index + 1 < len(source) else ""
+        if quote:
+            if escaped:
+                escaped = False
+            elif char == "\\":
+                escaped = True
+            elif char == quote:
+                quote = None
+            index += 1
+            continue
+        if char in "'\"`":
+            quote = char
+            index += 1
+            continue
+        if char == "/" and following == "/":
+            newline = source.find("\n", index)
+            index = len(source) if newline < 0 else newline + 1
+            continue
+        if char == "/" and following == "*":
+            end = source.find("*/", index + 2)
+            assert end >= 0, "unterminated JavaScript comment"
+            index = end + 2
+            continue
+        if char == "{":
+            depth += 1
+        elif char == "}":
+            depth -= 1
+            if depth == 0:
+                return source[start : index + 1]
+        index += 1
+    raise AssertionError("unterminated JavaScript block")
+
+
+def _named_object(source: str, name: str) -> str:
+    match = re.search(rf"\b{re.escape(name)}\s*:\s*\{{", source)
+    assert match, f"{name} object was not found"
+    return _balanced_block(source, source.index("{", match.start()))
+
+
+def _function_body(source: str, name: str) -> str:
+    match = re.search(rf"\b(?:async\s+)?{re.escape(name)}\s*\([^)]*\)\s*\{{", source)
     assert match, f"{name} method was not found"
-    return match.group(0)
+    block = _balanced_block(source, match.end() - 1)
+    return block[1:-1]
+
+
+def _validator_expression(prop: str) -> str:
+    match = re.search(r"\bvalidator\s*:\s*(.+?)(?=\n\s*\})", prop, re.S)
+    assert match, "prop validator was not found"
+    return match.group(1).strip().rstrip(",")
+
+
+def _run_node(script: str) -> dict:
+    result = subprocess.run(
+        ["node", "--input-type=module", "-e", script],
+        check=False,
+        capture_output=True,
+        text=True,
+        cwd=ROOT / "frontend",
+    )
+    assert result.returncode == 0, result.stderr
+    return json.loads(result.stdout)
 
 
-def test_connector_operations_has_fail_closed_allowlist_for_manifests_and_runs():
+def _component_source() -> str:
     assert COMPONENT.is_file(), "ConnectorOperations shared surface is missing"
-    source = COMPONENT.read_text(encoding="utf-8")
+    return COMPONENT.read_text(encoding="utf-8")
 
-    assert re.search(
-        r"connectorIds:\s*\{\s*type:\s*Array,\s*required:\s*true,.*?validator:",
-        source,
-        re.S,
+
+def test_connector_prop_validators_execute_strict_database_and_rest_contracts():
+    source = _component_source()
+    connector_ids = _named_object(_named_object(source, "props"), "connectorIds")
+    mode = _named_object(_named_object(source, "props"), "mode")
+    pattern_match = re.search(
+        r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
     )
-    assert "/^[a-z][a-z0-9_-]{2,63}$/" in source
-    assert re.search(
-        r"mode:\s*\{\s*type:\s*String,\s*required:\s*true,.*?database.*?rest-catalog",
-        source,
-        re.S,
+    assert pattern_match, "connector ID regex declaration is missing"
+
+    connector_validator = _validator_expression(connector_ids)
+    mode_validator = _validator_expression(mode)
+    assert re.search(r"\btype\s*:\s*Array\b", connector_ids)
+    assert re.search(r"\brequired\s*:\s*true\b", connector_ids)
+    assert re.search(r"\btype\s*:\s*String\b", mode)
+    assert re.search(r"\brequired\s*:\s*true\b", mode)
+    assert "value.length > 0" in connector_validator
+    assert re.search(r"value\.every\s*\(", connector_validator)
+    assert "CONNECTOR_ID_PATTERN.test(id)" in connector_validator
+
+    results = _run_node(
+        f"""
+        const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
+        const connectorIdsValidator = ({connector_validator});
+        const modeValidator = ({mode_validator});
+        process.stdout.write(JSON.stringify({{
+          connectorIds: {{
+            valid: connectorIdsValidator(['oracle', 'postgresql', 'sqlserver']),
+            empty: connectorIdsValidator([]),
+            nonString: connectorIdsValidator(['oracle', 7]),
+            tooShort: connectorIdsValidator(['ab']),
+            uppercase: connectorIdsValidator(['Oracle']),
+            tooLong: connectorIdsValidator(['a'.repeat(65)])
+          }},
+          modes: {{
+            database: modeValidator('database'),
+            restCatalog: modeValidator('rest-catalog'),
+            metadata: modeValidator('metadata'),
+            empty: modeValidator(''),
+            typo: modeValidator('rest_catalog')
+          }}
+        }}));
+        """
     )
+    assert results["connectorIds"] == {
+        "valid": True,
+        "empty": False,
+        "nonString": False,
+        "tooShort": False,
+        "uppercase": False,
+        "tooLong": False,
+    }
+    assert results["modes"] == {
+        "database": True,
+        "restCatalog": True,
+        "metadata": False,
+        "empty": False,
+        "typo": False,
+    }
 
-    normalized = _method_block(source, "normalizedConnectorIds")
+
+def test_normalized_connector_ids_deduplicates_valid_values_and_fails_closed():
+    source = _component_source()
+    pattern_match = re.search(
+        r"const\s+CONNECTOR_ID_PATTERN\s*=\s*(/[^\n;]+/)", source
+    )
+    assert pattern_match, "connector ID regex declaration is missing"
+    normalized = _function_body(source, "normalizedConnectorIds")
     assert "Array.isArray(this.connectorIds)" in normalized
-    assert "new Set" in normalized
+    assert "CONNECTOR_ID_PATTERN.test(id)" in normalized
     assert "return []" in normalized
+    assert "new Set(this.connectorIds)" in normalized
+
+    results = _run_node(
+        f"""
+        const CONNECTOR_ID_PATTERN = {pattern_match.group(1)};
+        const normalize = new Function(
+          'CONNECTOR_ID_PATTERN', `return function normalizedConnectorIds () {{{normalized}}}`
+        )(CONNECTOR_ID_PATTERN);
+        const evaluate = connectorIds => normalize.call({{ connectorIds }});
+        process.stdout.write(JSON.stringify({{
+          deduplicated: evaluate(['oracle', 'oracle', 'sqlserver']),
+          malformed: evaluate(['oracle', 'Oracle']),
+          nonArray: evaluate(null)
+        }}));
+        """
+    )
+    assert results == {
+        "deduplicated": ["oracle", "sqlserver"],
+        "malformed": [],
+        "nonArray": [],
+    }
+
 
-    load_all = _method_block(source, "loadAll")
-    empty_guard = load_all.index("if (!this.normalizedConnectorIds.length)")
-    first_api_call = load_all.index("getConnectorManifests()")
-    assert empty_guard < first_api_call
-    assert "this.clearConnectorState()" in load_all[:first_api_call]
-    clear_state = _method_block(source, "clearConnectorState")
+def test_load_all_executes_fail_closed_filtering_and_clears_error_state():
+    source = _component_source()
+    load_all = _function_body(source, "loadAll")
+    clear_state = _function_body(source, "clearConnectorState")
+    guard = load_all.index("if (!this.normalizedConnectorIds.length)")
+    api_calls = (
+        "getConnectorManifests()",
+        "getConnectorRuns()",
+        "getDatasourceGraph()",
+    )
+
+    assert "this.clearConnectorState()" in load_all[:guard]
+    assert "return" in load_all[guard : min(load_all.index(call) for call in api_calls)]
+    assert all(guard < load_all.index(call) for call in api_calls)
     assert "this.manifests = []" in clear_state
     assert "this.runs = []" in clear_state
     assert "this.graph = {}" in clear_state
-    assert "return" in load_all[empty_guard:first_api_call]
+    assert load_all.count("new Set(this.normalizedConnectorIds)") == 1
+    assert re.search(
+        r"this\.manifests\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
+        load_all,
+    )
+    assert re.search(
+        r"this\.runs\s*=.*?\.filter\(item\s*=>\s*allowedConnectorIds\.has\(item\.connector_id\)\)",
+        load_all,
+    )
+    assert "await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()])" in load_all
+    catch_block = re.search(r"catch\s*\([^)]*\)\s*\{(.*?)\}", load_all, re.S)
+    assert catch_block and "this.clearConnectorState()" in catch_block.group(1)
+
+    results = _run_node(
+        f"""
+        const buildLoadAll = new Function(
+          'getConnectorManifests', 'getConnectorRuns', 'getDatasourceGraph',
+          `return async function loadAll () {{{load_all}}}`
+        );
+        async function invoke(ids, failRuns) {{
+          const calls = [];
+          const errors = [];
+          const context = {{
+            normalizedConnectorIds: ids,
+            loading: false,
+            manifests: [{{ connector_id: 'stale' }}],
+            runs: [{{ connector_id: 'stale' }}],
+            graph: {{ node_count: 99 }},
+            clearConnectorState () {{ this.manifests = []; this.runs = []; this.graph = {{}}; }},
+            $snackbar: {{ error: error => errors.push(String(error)) }}
+          }};
+          const response = data => Promise.resolve({{ data }});
+          const loadAll = buildLoadAll(
+            () => {{ calls.push('manifests'); return response({{ manifests: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
+            () => {{ calls.push('runs'); return failRuns ? Promise.reject(new Error('runs unavailable')) : response({{ runs: [{{ connector_id: 'oracle' }}, {{ connector_id: 'rest-catalog' }}] }}); }},
+            () => {{ calls.push('graph'); return response({{ summary: {{ node_count: 1, edge_count: 2 }} }}); }}
+          );
+          await loadAll.call(context);
+          return {{ calls, errors, manifests: context.manifests, runs: context.runs, graph: context.graph, loading: context.loading }};
+        }}
+        const empty = await invoke([], false);
+        const filtered = await invoke(['oracle'], false);
+        const failed = await invoke(['oracle'], true);
+        process.stdout.write(JSON.stringify({{ empty, filtered, failed }}));
+        """
+    )
+    assert results["empty"] == {
+        "calls": [],
+        "errors": [],
+        "manifests": [],
+        "runs": [],
+        "graph": {},
+        "loading": False,
+    }
+    assert results["filtered"]["calls"] == ["manifests", "runs", "graph"]
+    assert results["filtered"]["manifests"] == [{"connector_id": "oracle"}]
+    assert results["filtered"]["runs"] == [{"connector_id": "oracle"}]
+    assert results["filtered"]["graph"] == {"node_count": 1, "edge_count": 2}
+    assert results["failed"]["manifests"] == []
+    assert results["failed"]["runs"] == []
+    assert results["failed"]["graph"] == {}
+    assert len(results["failed"]["errors"]) == 1
+
 
-    assert "const allowedConnectorIds = new Set(this.normalizedConnectorIds)" in load_all
-    assert "this.manifests = (manifests.data.manifests || []).filter(item => allowedConnectorIds.has(item.connector_id))" in load_all
-    assert "this.runs = (runs.data.runs || []).filter(item => allowedConnectorIds.has(item.connector_id))" in load_all
+def test_operations_await_refresh_and_rest_configuration_is_mode_gated():
+    source = _component_source()
+    markup = source.split("<script>", 1)[0]
+    dry_run = _function_body(source, "dryRun")
+    for field in ("base_url", "allowed_host"):
+        fields = re.findall(
+            rf'<v-text-field\b[^>]*v-model\.trim="form\.{field}"[^>]*/>', markup
+        )
+        assert len(fields) == 1
+        assert "v-if=\"mode === 'rest-catalog'\"" in fields[0]
+    assert re.search(
+        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*\)",
+        dry_run,
+    )
+    assert '@click="loadAll"' in markup
+    for name, connector_call in (
+        ("dryRun", "executeConnectorRun"),
+        ("cancel", "cancelConnectorRun"),
+        ("resume", "resumeConnectorRun"),
+    ):
+        body = _function_body(source, name)
+        assert body.index(f"await {connector_call}") < body.index("await this.loadAll()")
+
+    results = _run_node(
+        f"""
+        const buildDryRun = new Function(
+          'executeConnectorRun', `return async function dryRun () {{{dry_run}}}`
+        );
+        async function invoke(mode) {{
+          const payloads = [];
+          const events = [];
+          const context = {{
+            mode,
+            selected: {{ connector_id: 'oracle', version: '1.0.0' }},
+            form: {{ source_uid: 'source-1', credential_ref: 'env:TEST', base_url: 'https://catalog.example', allowed_host: 'catalog.example' }},
+            dialog: true,
+            loadAll: async () => events.push('loadAll'),
+            $snackbar: {{ error: error => {{ throw error; }} }}
+          }};
+          const dryRun = buildDryRun(async payload => {{ events.push('execute'); payloads.push(payload); }});
+          await dryRun.call(context);
+          return {{ payload: payloads[0], events, dialog: context.dialog }};
+        }}
+        const database = await invoke('database');
+        const restCatalog = await invoke('rest-catalog');
+        process.stdout.write(JSON.stringify({{ database, restCatalog }}));
+        """
+    )
+    assert results["database"]["payload"]["config"] == {"credential_ref": "env:TEST"}
+    assert results["restCatalog"]["payload"]["config"] == {
+        "credential_ref": "env:TEST",
+        "base_url": "https://catalog.example",
+        "allowed_host": "catalog.example",
+    }
+    assert results["database"]["events"] == ["execute", "loadAll"]
+    assert results["restCatalog"]["events"] == ["execute", "loadAll"]
+    assert results["database"]["dialog"] is False
 
 
 def test_database_wrapper_is_database_only_and_keeps_scope_warning():