Bladeren bron

feat: integrate rest catalog with metadata governance

马小龙 2 dagen geleden
bovenliggende
commit
4061ade0ee

+ 20 - 0
frontend/src/views/dataGovernance/metadata/components/externalCatalogAccess.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="pa-3 white">
+    <v-alert type="info" outlined class="mb-3">
+      REST Catalog 用于从受控 HTTPS 元数据目录同步资产,不是任意业务 API 采集。
+    </v-alert>
+    <v-alert type="warning" outlined class="mb-5">
+      企业 URL、允许主机、凭据与 UAT 由外部负责。
+    </v-alert>
+    <connector-operations :connector-ids="['rest-catalog']" mode="rest-catalog" />
+  </div>
+</template>
+
+<script>
+import ConnectorOperations from '@/components/connectors/ConnectorOperations'
+
+export default {
+  name: 'ExternalCatalogAccess',
+  components: { ConnectorOperations }
+}
+</script>

+ 252 - 0
frontend/src/views/dataGovernance/metadata/components/metadataManagement.vue

@@ -0,0 +1,252 @@
+<template>
+  <!-- 元数据 -->
+  <div class="pa-3 white">
+    <div class="d-flex align-center mb-2">
+      <filter-list class="flex-grow-1" @search="handleSearch" />
+      <v-btn color="primary" class="ml-3" @click="$router.push('/data-governance/development')">数据研发中心</v-btn>
+    </div>
+    <table-list
+      class="mt-3"
+      :loading="loading"
+      :headers="headers"
+      :items="items"
+      :total="total"
+      :page-info="pageInfo"
+      :is-tools="false"
+      :show-select="false"
+      @pageHandleChange="pageHandleChange"
+      @sort="handleSort"
+    >
+      <template #status="{ item }">
+        <v-chip :color="item.status ? 'success' : 'error'" small>
+          {{ item.status ? '已启用' : '已禁用'}}
+        </v-chip>
+      </template>
+      <template #tag="{ item }">
+        {{ item.tag.map(e => e.name_zh).join(',') }}
+      </template>
+      <template v-slot:navBtn>
+        <v-btn class="elevation-5 buttons mr-3" color="primary" rounded @click="handleAdd">
+          <v-icon left>mdi-plus</v-icon>
+          新增元数据
+        </v-btn>
+      </template>
+      <template #actions="{ item }">
+        <v-btn text :disabled="loading" color="primary" @click="handleEdit(item)">编辑</v-btn>
+        <v-btn text :disabled="loading" color="error" @click="handleDelete(item.id)">删除</v-btn>
+        <v-btn text :disabled="loading" :color="item.status ? 'error' : 'success'" @click="handleChangeStatus(item)">
+          {{ item.status ? '禁用' : '启用' }}
+        </v-btn>
+        <v-btn text color="indigo" @click="handleAddAudit(item)">添加审核</v-btn>
+      </template>
+    </table-list>
+
+    <edit-dialog :visible.sync="show" :title="title" @submit="handleSubmit">
+      <edit v-if="show" v-loading="submitLoading" ref="form" class="mt-5" :item-data="itemData"></edit>
+    </edit-dialog>
+
+    <edit-dialog
+      :visible.sync="showAudit"
+      title="创建审核记录"
+      width-type="0"
+      @submit="handleAuditSubmit"
+      @close="handleAuditClose"
+    >
+      <add-audit v-if="showAudit" ref="formAudit" :item-data="itemDataAudit"></add-audit>
+    </edit-dialog>
+
+    <!-- 智能助手 -->
+    <!-- <assistant-iframe
+      title="元数据智能助手"
+      subTitle="我可以帮您管理元数据,支持查询、新增、更新、删除和标签管理"
+      inputPlaceholder="请输入您的需求,例如:查看所有元数据"
+      iframe-url="http://localhost:5678/webhook/9d5a941e-e1fd-42a3-afb4-ad6249d5f6e6/chat"
+    /> -->
+  </div>
+</template>
+
+<script>
+import FilterList from '../../components/Filter'
+import TableList from '@/components/List/table'
+import EditDialog from '@/components/Dialog'
+import Edit from './edit'
+import AddAudit from './addAudit'
+// import AssistantIframe from '@/components/AssistantIframe'
+
+import { api } from '@/api/dataGovernance'
+export default {
+  name: 'meta-data',
+  components: { FilterList, TableList, EditDialog, Edit, AddAudit },
+  data () {
+    return {
+      submitLoading: false,
+      show: false,
+      loading: false,
+      showAudit: false,
+      headers: [
+        { text: '中文名', value: 'name_zh' },
+        { text: '英文名', value: 'name_en' },
+        { text: '分类', value: 'category', sortable: false },
+        { text: '描述', value: 'describe', sortable: false },
+        { text: '标签', value: 'tag', sortable: false },
+        { text: '状态', value: 'status' },
+        { text: '血缘关系数量', value: 'blood_count', align: 'center' },
+        { text: '创建时间', value: 'create_time' },
+        { text: '操作', value: 'actions', sortable: false }
+      ],
+      items: [],
+      total: 0,
+      pageInfo: {
+        size: 10,
+        current: 1
+      },
+      query: {},
+      orders: [],
+      itemData: {},
+      itemDataAudit: {} // 审核记录信息
+    }
+  },
+  computed: {
+    title () {
+      return this.itemData.id ? '编辑元数据' : '新增元数据'
+    }
+  },
+  created () {
+    this.init()
+  },
+  methods: {
+    async init () {
+      this.loading = true
+      try {
+        const { data } = await api.getMetaDataList({ ...this.pageInfo, ...this.query })
+        this.total = data.total
+        this.items = data.records
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+    handleAdd () {
+      this.itemData = {}
+      this.show = true
+    },
+    async handleEdit ({ id }) {
+      try {
+        const { data } = await api.getMetadataDetails({ id })
+        if (!data.length) {
+          this.$snackbar.error('找不到元数据信息')
+          return
+        }
+        this.itemData = {
+          id,
+          ...data.pop()
+        }
+        this.show = true
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    },
+    handleDelete (id) {
+      this.$confirm('提示', '是否确定删除该项元数据').then(async () => {
+        try {
+          await api.deleteMetadata({ id })
+          this.$snackbar.success('删除成功')
+          this.init()
+        } catch (error) {
+          this.$snackbar.error(error)
+        }
+      })
+    },
+    async handleChangeStatus (item) {
+      this.loading = true
+      try {
+        const { status, ...obj } = item
+        await api.metadataUpdate({
+          ...obj,
+          status: 1 ^ status
+        })
+        this.$snackbar.success('操作成功')
+        this.init()
+      } catch (error) {
+        this.loading = false
+        this.$snackbar.error(error)
+      }
+    },
+    // 创建/编辑元数据
+    async handleSubmit () {
+      const obj = this.$refs.form.getValue()
+      if (!obj) {
+        return
+      }
+      const apiFn = obj.id ? api.metadataUpdate : api.metadataAdd
+      try {
+        this.submitLoading = true
+        const { message } = await apiFn(obj)
+        if (message.includes('重复') || message.includes('疑似重复')) {
+          this.show = false
+          this.$confirm('系统提示', '当前创建的元数据与已存在的元数据疑似重复,是否前往审核页面?').then(async () => {
+            this.$router.push('/dataReview')
+          })
+          return
+        }
+        this.$snackbar.success('操作成功')
+        this.show = false
+        this.init()
+      } catch (error) {
+        this.$snackbar.error(error)
+      } finally {
+        this.submitLoading = false
+      }
+    },
+    handleSort (val) {
+      this.orders = val
+      this.init()
+    },
+    handleSearch (obj) {
+      Object.assign(this.query, obj)
+      this.pageInfo.current = 1
+      this.init()
+    },
+    pageHandleChange (index) {
+      this.pageInfo.current = index
+      this.init()
+    },
+    // 添加审核
+    handleAddAudit (item) {
+      this.itemDataAudit = item
+      this.showAudit = true
+    },
+    handleAuditClose () {
+      this.showAudit = false
+      this.itemDataAudit = {}
+    },
+    async handleAuditSubmit () {
+      const obj = this.$refs.formAudit.getValue()
+      if (!obj) {
+        return this.$snackbar.warning('请将信息填写完整后再提交')
+      }
+      if (!obj.meta2 || !Object.keys(obj.meta2).length) {
+        return this.$snackbar.warning('请选择候选元数据')
+      }
+      if (obj.meta1.id === obj.meta2.id) {
+        return this.$snackbar.warning('候选元数据不能与主元数据相同')
+      }
+      try {
+        await api.createAuditRecord(obj)
+        this.handleAuditClose()
+        this.init()
+        this.$confirm('提示', '审核记录创建成功,是否前往审核页面').then(async () => {
+          this.$router.push('/dataReview')
+        })
+      } catch (error) {
+        this.$snackbar.error(error)
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+
+</style>

+ 29 - 266
frontend/src/views/dataGovernance/metadata/index.vue

@@ -1,280 +1,43 @@
 <template>
-  <!-- 元数据 -->
-  <div class="pa-3 white">
-    <div class="d-flex align-center mb-2">
-      <filter-list class="flex-grow-1" @search="handleSearch" />
-      <v-btn color="primary" class="ml-3" @click="$router.push('/data-governance/development')">数据研发中心</v-btn>
-    </div>
-    <table-list
-      class="mt-3"
-      :loading="loading"
-      :headers="headers"
-      :items="items"
-      :total="total"
-      :page-info="pageInfo"
-      :is-tools="false"
-      :show-select="false"
-      @pageHandleChange="pageHandleChange"
-      @sort="handleSort"
-    >
-      <template #status="{ item }">
-        <v-chip
-          :color="item.status ? 'success' : 'error'"
-          small
-        >
-          {{ item.status ? '已启用' : '已禁用'}}
-        </v-chip>
-      </template>
-      <template #tag="{ item }">
-        {{ item.tag.map(e => e.name_zh).join(',') }}
-      </template>
-      <template v-slot:navBtn>
-        <v-btn class="elevation-5 buttons mr-3" color="primary" rounded @click="handleAdd">
-          <v-icon left>mdi-plus</v-icon>
-          新增元数据
-        </v-btn>
-      </template>
-      <template #actions="{ item }">
-        <v-btn
-          text
-          :disabled="loading"
-          color="primary"
-          @click="handleEdit(item)"
-        >
-          编辑
-        </v-btn>
-        <v-btn
-          text
-          :disabled="loading"
-          color="error"
-          @click="handleDelete(item.id)"
-        >
-          删除
-        </v-btn>
-        <v-btn
-          text
-          :disabled="loading"
-          :color="item.status ? 'error' : 'success'"
-          @click="handleChangeStatus(item)"
-        >
-          {{ item.status ? '禁用' : '启用' }}
-        </v-btn>
-        <v-btn
-          text
-          color="indigo"
-          @click="handleAddAudit(item)"
-        >
-          添加审核
-        </v-btn>
-      </template>
-    </table-list>
-
-    <edit-dialog :visible.sync="show" :title="title" @submit="handleSubmit">
-      <edit v-if="show" v-loading="submitLoading" ref="form" class="mt-5" :item-data="itemData"></edit>
-    </edit-dialog>
-
-    <edit-dialog
-      :visible.sync="showAudit"
-      title="创建审核记录"
-      width-type="0"
-      @submit="handleAuditSubmit"
-      @close="handleAuditClose"
-    >
-      <add-audit v-if="showAudit" ref="formAudit" :item-data="itemDataAudit"></add-audit>
-    </edit-dialog>
-
-    <!-- 智能助手 -->
-    <!-- <assistant-iframe
-      title="元数据智能助手"
-      subTitle="我可以帮您管理元数据,支持查询、新增、更新、删除和标签管理"
-      inputPlaceholder="请输入您的需求,例如:查看所有元数据"
-      iframe-url="http://localhost:5678/webhook/9d5a941e-e1fd-42a3-afb4-ad6249d5f6e6/chat"
-    /> -->
+  <div class="metadata-governance">
+    <v-tabs v-model="activeTab" color="primary">
+      <v-tab>元数据管理</v-tab>
+      <v-tab v-if="canReadConnectors">外部目录接入</v-tab>
+    </v-tabs>
+    <v-tabs-items v-model="activeTab">
+      <v-tab-item>
+        <metadata-management />
+      </v-tab-item>
+      <v-tab-item v-if="canReadConnectors">
+        <external-catalog-access />
+      </v-tab-item>
+    </v-tabs-items>
   </div>
 </template>
 
 <script>
-import FilterList from '../components/Filter'
-import TableList from '@/components/List/table'
-import EditDialog from '@/components/Dialog'
-import Edit from './components/edit'
-import AddAudit from './components/addAudit'
-// import AssistantIframe from '@/components/AssistantIframe'
+import MetadataManagement from './components/metadataManagement'
+import ExternalCatalogAccess from './components/externalCatalogAccess'
 
-import { api } from '@/api/dataGovernance'
 export default {
-  name: 'meta-data',
-  components: { FilterList, TableList, EditDialog, Edit, AddAudit },
-  data () {
-    return {
-      submitLoading: false,
-      show: false,
-      loading: false,
-      showAudit: false,
-      headers: [
-        { text: '中文名', value: 'name_zh' },
-        { text: '英文名', value: 'name_en' },
-        { text: '分类', value: 'category', sortable: false },
-        { text: '描述', value: 'describe', sortable: false },
-        { text: '标签', value: 'tag', sortable: false },
-        { text: '状态', value: 'status' },
-        { text: '血缘关系数量', value: 'blood_count', align: 'center' },
-        { text: '创建时间', value: 'create_time' },
-        { text: '操作', value: 'actions', sortable: false }
-      ],
-      items: [],
-      total: 0,
-      pageInfo: {
-        size: 10,
-        current: 1
-      },
-      query: {},
-      orders: [],
-      itemData: {},
-      itemDataAudit: {} // 审核记录信息
-    }
-  },
+  name: 'MetadataGovernance',
+  components: { MetadataManagement, ExternalCatalogAccess },
+  data: () => ({ activeTab: 0 }),
   computed: {
-    title () {
-      return this.itemData.id ? '编辑元数据' : '新增元数据'
+    connectorPermissions () {
+      const state = this.$store && this.$store.state
+      const user = state && state.user
+      const userInfo = user && user.userInfo
+      return userInfo && Array.isArray(userInfo.permissions) ? userInfo.permissions : []
+    },
+    canReadConnectors () {
+      return this.connectorPermissions.includes('connectors:read')
     }
   },
-  created () {
-    this.init()
-  },
-  methods: {
-    async init () {
-      this.loading = true
-      try {
-        const { data } = await api.getMetaDataList({ ...this.pageInfo, ...this.query })
-        this.total = data.total
-        this.items = data.records
-      } catch (error) {
-        this.$snackbar.error(error)
-      } finally {
-        this.loading = false
-      }
-    },
-    handleAdd () {
-      this.itemData = {}
-      this.show = true
-    },
-    async handleEdit ({ id }) {
-      try {
-        const { data } = await api.getMetadataDetails({ id })
-        if (!data.length) {
-          this.$snackbar.error('找不到元数据信息')
-          return
-        }
-        this.itemData = {
-          id,
-          ...data.pop()
-        }
-        this.show = true
-      } catch (error) {
-        this.$snackbar.error(error)
-      }
-    },
-    handleDelete (id) {
-      this.$confirm('提示', '是否确定删除该项元数据').then(async () => {
-        try {
-          await api.deleteMetadata({ id })
-          this.$snackbar.success('删除成功')
-          this.init()
-        } catch (error) {
-          this.$snackbar.error(error)
-        }
-      })
-    },
-    async handleChangeStatus (item) {
-      this.loading = true
-      try {
-        const { status, ...obj } = item
-        await api.metadataUpdate({
-          ...obj,
-          status: 1 ^ status
-        })
-        this.$snackbar.success('操作成功')
-        this.init()
-      } catch (error) {
-        this.loading = false
-        this.$snackbar.error(error)
-      }
-    },
-    // 创建/编辑元数据
-    async handleSubmit () {
-      const obj = this.$refs.form.getValue()
-      if (!obj) {
-        return
-      }
-      const apiFn = obj.id ? api.metadataUpdate : api.metadataAdd
-      try {
-        this.submitLoading = true
-        const { message } = await apiFn(obj)
-        if (message.includes('重复') || message.includes('疑似重复')) {
-          this.show = false
-          this.$confirm('系统提示', '当前创建的元数据与已存在的元数据疑似重复,是否前往审核页面?').then(async () => {
-            this.$router.push('/dataReview')
-          })
-          return
-        }
-        this.$snackbar.success('操作成功')
-        this.show = false
-        this.init()
-      } catch (error) {
-        this.$snackbar.error(error)
-      } finally {
-        this.submitLoading = false
-      }
-    },
-    handleSort (val) {
-      this.orders = val
-      this.init()
-    },
-    handleSearch (obj) {
-      Object.assign(this.query, obj)
-      this.pageInfo.current = 1
-      this.init()
-    },
-    pageHandleChange (index) {
-      this.pageInfo.current = index
-      this.init()
-    },
-    // 添加审核
-    handleAddAudit (item) {
-      this.itemDataAudit = item
-      this.showAudit = true
-    },
-    handleAuditClose () {
-      this.showAudit = false
-      this.itemDataAudit = {}
-    },
-    async handleAuditSubmit () {
-      const obj = this.$refs.formAudit.getValue()
-      if (!obj) {
-        return this.$snackbar.warning('请将信息填写完整后再提交')
-      }
-      if (!obj.meta2 || !Object.keys(obj.meta2).length) {
-        return this.$snackbar.warning('请选择候选元数据')
-      }
-      if (obj.meta1.id === obj.meta2.id) {
-        return this.$snackbar.warning('候选元数据不能与主元数据相同')
-      }
-      try {
-        await api.createAuditRecord(obj)
-        this.handleAuditClose()
-        this.init()
-        this.$confirm('提示', '审核记录创建成功,是否前往审核页面').then(async () => {
-          this.$router.push('/dataReview')
-        })
-      } catch (error) {
-        this.$snackbar.error(error)
-      }
+  watch: {
+    canReadConnectors (canReadConnectors) {
+      if (!canReadConnectors && this.activeTab !== 0) this.activeTab = 0
     }
   }
 }
 </script>
-
-<style lang="scss" scoped>
-
-</style>

+ 1 - 1
tests/data_research/test_data_research_frontend_contract.py

@@ -41,6 +41,6 @@ def test_data_research_center_pages_routes_and_apis_cover_full_workflow():
 
 
 def test_metadata_page_links_to_new_data_research_center():
-    metadata = (ROOT / "frontend/src/views/dataGovernance/metadata/index.vue").read_text(encoding="utf-8")
+    metadata = (ROOT / "frontend/src/views/dataGovernance/metadata/components/metadataManagement.vue").read_text(encoding="utf-8")
     assert "/data-governance/development" in metadata
     assert "数据研发中心" in metadata

+ 114 - 0
tests/test_enterprise_connector_metadata_frontend_contract.py

@@ -7,6 +7,9 @@ from pathlib import Path
 ROOT = Path(__file__).resolve().parents[1]
 COMPONENT = ROOT / "frontend/src/components/connectors/ConnectorOperations.vue"
 DATABASE_PAGE = ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
+METADATA_INDEX = ROOT / "frontend/src/views/dataGovernance/metadata/index.vue"
+METADATA_MANAGEMENT = ROOT / "frontend/src/views/dataGovernance/metadata/components/metadataManagement.vue"
+EXTERNAL_CATALOG_ACCESS = ROOT / "frontend/src/views/dataGovernance/metadata/components/externalCatalogAccess.vue"
 
 
 def _balanced_block(source: str, start: int) -> str:
@@ -347,3 +350,114 @@ def test_database_wrapper_is_database_only_and_keeps_scope_warning():
         r"<connector-operations\s+:connector-ids=\"\['oracle', 'postgresql', 'sqlserver'\]\"\s+mode=\"database\"\s*/>",
         source,
     )
+
+
+def test_metadata_wrapper_exposes_two_tabs_and_gates_external_catalog_by_permission():
+    source = METADATA_INDEX.read_text(encoding="utf-8")
+    markup = source.split("<script>", 1)[0]
+
+    assert len(re.findall(r"<v-tab(?![-\w])", markup)) == 2
+    assert len(re.findall(r"<v-tab-item(?![-\w])", markup)) == 2
+    assert "元数据管理" in markup
+    assert "外部目录接入" in markup
+    assert re.search(r"<v-tab\b[^>]*v-if=\"canReadConnectors\"[^>]*>\s*外部目录接入\s*</v-tab>", markup)
+    assert re.search(r"<v-tab-item\b[^>]*v-if=\"canReadConnectors\"[^>]*>", markup)
+    assert re.search(r"<external-catalog-access\b", markup)
+    assert "<connector-operations" not in source
+
+    computed = _named_object(source, "computed")
+    connector_permissions = _function_body(computed, "connectorPermissions")
+    can_read = _function_body(computed, "canReadConnectors")
+    result = _run_node(
+        f"""
+        const connectorPermissions = new Function(`return function connectorPermissions () {{{connector_permissions}}}`)();
+        const canReadConnectors = new Function(`return function canReadConnectors () {{{can_read}}}`)();
+        const evaluate = state => {{
+          const context = {{ $store: {{ state }} }};
+          context.connectorPermissions = connectorPermissions.call(context);
+          return canReadConnectors.call(context);
+        }};
+        process.stdout.write(JSON.stringify({{
+          authorized: evaluate({{ user: {{ userInfo: {{ permissions: ['connectors:read'] }} }} }}),
+          unrelated: evaluate({{ user: {{ userInfo: {{ permissions: ['metadata:read'] }} }} }}),
+          missingUser: evaluate({{}}),
+          malformedPermissions: evaluate({{ user: {{ userInfo: {{ permissions: 'connectors:read' }} }} }})
+        }}));
+        """
+    )
+    assert result == {
+        "authorized": True,
+        "unrelated": False,
+        "missingUser": False,
+        "malformedPermissions": False,
+    }
+
+
+def test_metadata_wrapper_resets_external_tab_when_connector_read_is_revoked():
+    source = METADATA_INDEX.read_text(encoding="utf-8")
+    watcher = _named_object(source, "watch")
+    reset_active_tab = _function_body(watcher, "canReadConnectors")
+
+    result = _run_node(
+        f"""
+        const resetActiveTab = new Function(`return function canReadConnectors (canReadConnectors) {{{reset_active_tab}}}`)();
+        const revoked = {{ activeTab: 1 }};
+        const authorized = {{ activeTab: 1 }};
+        const management = {{ activeTab: 0 }};
+        resetActiveTab.call(revoked, false);
+        resetActiveTab.call(authorized, true);
+        resetActiveTab.call(management, false);
+        process.stdout.write(JSON.stringify({{ revoked, authorized, management }}));
+        """
+    )
+    assert result == {
+        "revoked": {"activeTab": 0},
+        "authorized": {"activeTab": 1},
+        "management": {"activeTab": 0},
+    }
+
+
+def test_metadata_management_preserves_crud_audit_and_research_link_after_move():
+    source = METADATA_MANAGEMENT.read_text(encoding="utf-8")
+
+    for marker in (
+        "新增元数据",
+        "handleSubmit",
+        "handleDelete",
+        "getMetaDataList",
+        "handleAddAudit",
+        "handleAuditSubmit",
+        "数据研发中心",
+        "/data-governance/development",
+        "智能助手",
+    ):
+        assert marker in source
+    assert "import FilterList from '../../components/Filter'" in source
+    assert "import Edit from './edit'" in source
+    assert "import AddAudit from './addAudit'" in source
+
+
+def test_external_catalog_access_is_rest_only_and_cannot_mount_when_unauthorized():
+    index_source = METADATA_INDEX.read_text(encoding="utf-8")
+    access_source = EXTERNAL_CATALOG_ACCESS.read_text(encoding="utf-8")
+    markup = access_source.split("<script>", 1)[0]
+
+    assert "受控 HTTPS 元数据目录" in access_source
+    assert "不是任意业务 API 采集" in access_source
+    assert "企业 URL、允许主机、凭据与 UAT 由外部负责" in access_source
+    assert re.search(
+        r"<connector-operations\s+:connector-ids=\"\['rest-catalog'\]\"\s+mode=\"rest-catalog\"\s*/>",
+        markup,
+    )
+    assert "ConnectorOperations" in access_source
+    assert not any(name in access_source for name in ("oracle", "postgresql", "sqlserver"))
+
+    index_markup = index_source.split("<script>", 1)[0]
+    external_tab_item = re.search(
+        r"<v-tab-item\b(?P<attrs>[^>]*)>\s*<external-catalog-access\b",
+        index_markup,
+        re.S,
+    )
+    assert external_tab_item, "external catalog component must be inside a tab item"
+    assert 'v-if="canReadConnectors"' in external_tab_item.group("attrs")
+    assert index_markup.count("<external-catalog-access") == 1