From 13ec37aa371494783ecae36f767316fdff114e15 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Thu, 10 Sep 2026 14:17:57 +0530 Subject: [PATCH 1/3] fix(apps): send the full existing connection on a miner run (AICHAT-1798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A miner references an existing connection by qualifiedName, but _create() sent a stub carrying only the QN and connectorName — no name. atlan-popularity-app then derives the connection name from an empty value, falls back to the qualifiedName's numeric tail, and publish renames the connection to that number. The UI and a rerun never hit this because they send the whole connection. _create() now reads the connection back in full (get_by_qualified_name) and sends the entire object the same way a rerun does, so a full-replace downstream drops no attributes. The read-back carries the connection's own defaultCredentialGuid, so the credential is still reused. If the connection cannot be read and no explicit name was given, it raises CONNECTION_READ_FOR_APP_FAILED rather than silently sending a stub that would rename the connection. --- pyatlan/errors.py | 7 ++ pyatlan/model/apps/_base.py | 111 +++++++++++++++++++++++--------- tests/unit/test_app_builders.py | 96 ++++++++++++++++++++++----- 3 files changed, 168 insertions(+), 46 deletions(-) diff --git a/pyatlan/errors.py b/pyatlan/errors.py index 88b77a997..d4fa598ad 100644 --- a/pyatlan/errors.py +++ b/pyatlan/errors.py @@ -706,6 +706,13 @@ class ErrorCode(Enum): "See https://docs.atlan.com/product/capabilities/build-apps/sdks/python/apps/manage-apps", InvalidRequestError, ) + CONNECTION_READ_FOR_APP_FAILED = ( + 400, + "ATLAN-PYTHON-400-081", + "Could not read connection '{0}', so the run was not started.", + "Verify the qualifiedName and that this credential can read the connection.", + InvalidRequestError, + ) AUTHENTICATION_PASSTHROUGH = ( 401, "ATLAN-PYTHON-401-000", diff --git a/pyatlan/model/apps/_base.py b/pyatlan/model/apps/_base.py index 501949a6e..f35d90ba4 100644 --- a/pyatlan/model/apps/_base.py +++ b/pyatlan/model/apps/_base.py @@ -23,6 +23,7 @@ from pydantic.v1 import BaseModel, Extra +from pyatlan.errors import ErrorCode from pyatlan.model.credential import Credential # Handshake/runtime ids the server injects into a workflow's inputs; they must @@ -114,9 +115,11 @@ def __init__(self, client: Any): self._admin_roles: List[str] = [] self._metadata: Dict[str, Any] = {} self._update_slug: Optional[str] = None - # Full persisted connection captured by load(); re-sent verbatim by - # update() so the full-replace drops no connection attributes. Cleared by - # an explicit connection() call, which then wins. + # A full connection object to send verbatim (typeName + attributes): either + # captured by load() (update path) or read back by _create() when a fresh + # run references an existing connection by QN. Sending the whole connection + # keeps a full-replace downstream from dropping attributes (AICHAT-1798). + # Cleared by an explicit connection() call, which then wins. self._loaded_connection: Optional[Any] = None # ── Step 1 · Credential ──────────────────────────────────────────────── @@ -459,43 +462,93 @@ def _vault_credential(self, cred: Credential) -> str: ) return CredentialResponse(**raw).id or "" - def _resolve_connection_credential(self, qualified_name: str) -> Optional[str]: - """Look up an existing connection's ``defaultCredentialGuid`` so a caller - referencing a connection by QN (e.g. miners) reuses that connection's - credential without having to know its guid. Best-effort: returns None if - the connection can't be read.""" - try: - from pyatlan.model.assets import Connection - from pyatlan.model.fluent_search import FluentSearch - - request = ( - FluentSearch() - .where(Connection.TYPE_NAME.eq("Connection")) - .where(Connection.QUALIFIED_NAME.eq(qualified_name)) - .include_on_results(Connection.DEFAULT_CREDENTIAL_GUID) - .page_size(1) - ).to_request() - for asset in self._client.asset.search(request): - return asset.default_credential_guid - except Exception: # noqa: BLE001 - return None - return None + def _load_existing_connection(self, qualified_name: str) -> Dict[str, Any]: + """Read an existing connection in full and return it as the ``{typeName, + attributes}`` wire object, so a caller referencing a connection by QN (e.g. + miners) sends the whole connection the way the UI and a rerun do — not a + stub built from the QN alone. + + This is what keeps the connection intact. Popularity/publish full-replaces + the connection from this payload, so a stub missing ``name`` makes the + exporter fall back to the qualifiedName's numeric tail and rename the + connection to that number (AICHAT-1798); the same gap can blank other + attributes (e.g. category/rowLimit). A full read-back carries every + attribute, so the replace is a no-op. + + Raises whatever the read raised (e.g. ``NotFoundError``) rather than + returning a stub: a run that cannot read the connection must not rename it. + The caller decides whether an explicit name makes a failure recoverable. + + The connection's own ``defaultCredentialGuid`` rides on the read-back, so + the credential is reused with no extra lookup. Explicit ``connection(name=, + admin_*=)`` / ``credential_guid()`` still win — they are layered back on.""" + from pyatlan.model.assets import Connection + + connection = self._client.asset.get_by_qualified_name( + qualified_name=qualified_name, + asset_type=Connection, + min_ext_info=False, + ignore_relationships=True, + ) + # Serialize via .json() (not .dict()): pydantic's encoders turn set-typed + # attributes into lists and enums/datetimes into their wire form, so the + # connection is JSON-safe the same way the stored DAG (load path) is. + # .dict() would leave raw sets that fail to serialize. + attrs = ( + json.loads( + connection.json(by_alias=True, exclude_none=True, exclude_unset=True) + ).get("attributes") + or {} + ) + # Identity the run was given always wins over the read-back. + attrs["qualifiedName"] = qualified_name + parts = qualified_name.split("/") + if len(parts) >= 3 and parts[0] == "default": + attrs.setdefault("connectorName", parts[1]) + # Explicit builder values override the stored connection. + if self._connection_name is not None: + attrs["name"] = self._connection_name + if self._admin_users: + attrs["adminUsers"] = self._admin_users + if self._admin_groups: + attrs["adminGroups"] = self._admin_groups + if self._admin_roles: + attrs["adminRoles"] = self._admin_roles + if self._credential_guid: + attrs["defaultCredentialGuid"] = self._credential_guid + return {"typeName": "Connection", "attributes": attrs} def _create(self, *, name: Optional[str], run: bool, schedule: Optional[Any]): epoch = int(time.time()) qn = ( self._connection_qualified_name or f"default/{self._CONNECTOR_NAME}/{epoch}" ) - # Referencing an existing connection without a credential (e.g. miners): - # reuse that connection's own credential (its defaultCredentialGuid), looked - # up by QN — so the caller only needs to supply the connection. + # Referencing an existing connection by QN (e.g. miners): send the whole + # connection the UI/rerun way — a full read-back — so a full-replace + # downstream drops no attributes and cannot rename it (AICHAT-1798). The + # read-back carries the connection's own defaultCredentialGuid, so the + # credential is reused too. Falls back to a QN-only stub if the read fails. + # Skipped when a raw credential is being vaulted onto the connection (that + # path builds its own) or when load()/an explicit connection already set one. if ( self._extraction_method != "agent" and not self._raw_creds - and self._credential_guid is None + and self._loaded_connection is None and self._connection_qualified_name ): - self._credential_guid = self._resolve_connection_credential(qn) + try: + self._loaded_connection = self._load_existing_connection(qn) + except Exception as exc: # noqa: BLE001 + # Reading the connection is how its name reaches the run. If it + # can't be read and no name was given, a QN-only stub would let + # popularity/publish rename the connection to the qualifiedName's + # numeric tail (AICHAT-1798) — fail loudly instead of silently + # renaming. An explicit connection(name=...) is already safe, so + # fall back to the stub there. + if self._connection_name is None: + raise ErrorCode.CONNECTION_READ_FOR_APP_FAILED.exception_with_parameters( + qn + ) from exc # Named credential fields (e.g. dbt's api_credential_guid) aren't vaulted # from the payload — vault them now and place the issued guid in the field. resolved_guids: Dict[str, str] = {} diff --git a/tests/unit/test_app_builders.py b/tests/unit/test_app_builders.py index 723e8fc99..8c1339676 100644 --- a/tests/unit/test_app_builders.py +++ b/tests/unit/test_app_builders.py @@ -13,6 +13,7 @@ import pytest import pyatlan.model.apps as apps +from pyatlan.errors import InvalidRequestError from pyatlan.model.apps import AppBuilder, BigqueryCrawler, SnowflakeMiner # Every concrete builder (the hand-written flagship + all generated ones). @@ -234,27 +235,49 @@ def test_miner_references_existing_connection_by_qn_only(): def test_connector_name_derived_from_qn(client): - # Even when the builder's connector fallback differs, the QN wins. + # Even when the read-back omits connectorName, the QN supplies it. + client.asset.get_by_qualified_name.return_value = _connection( + "default/snowflake/123", name="conn" + ) SnowflakeMiner(client).connection(qualified_name="default/snowflake/123").create() out = client.app.create.call_args.kwargs["inputs"].to_inputs() assert out["connection"]["attributes"]["connectorName"] == "snowflake" -def test_miner_auto_resolves_connection_credential(client): - # Referencing an existing connection by QN (no credential) → the builder looks - # up the connection and reuses its defaultCredentialGuid on create(). - client.asset.search.return_value = iter( - [Mock(default_credential_guid="conn-cred-guid")] +def _connection(qn, **attrs): + """A real Connection asset, so _load_existing_connection can serialize it the + way it serializes a live read-back.""" + from pyatlan.model.assets import Connection + + conn = Connection() + conn.qualified_name = qn + for key, value in attrs.items(): + setattr(conn, key, value) + return conn + + +def test_miner_sends_the_full_existing_connection(client): + # Referencing an existing connection by QN (no credential): the builder reads + # the whole connection back and sends it — name, credential, everything — the + # way the UI and a rerun do, so a full-replace downstream drops nothing. + client.asset.get_by_qualified_name.return_value = _connection( + "default/snowflake/123", + name="sales-snowflake", + default_credential_guid="conn-cred-guid", + category="warehouse", ) SnowflakeMiner(client).connection(qualified_name="default/snowflake/123").create() - assert client.asset.search.called # connection was looked up + assert client.asset.get_by_qualified_name.called # the connection was read back out = client.app.create.call_args.kwargs["inputs"].to_inputs() - # CONNECT-843: the reused guid rides on the connection entity (the UI's wire - # shape), never as a bare top-level credential_guid. A top-level guid with no - # credential body makes the create endpoint rewrite that credential's shared - # config record. Do not "fix" this back to out["credential_guid"]. attrs = out["connection"]["attributes"] - assert attrs["defaultCredentialGuid"] == "conn-cred-guid" # its credential reused + # AICHAT-1798: the connection's own name (and every other attribute) ride on the + # payload, so popularity/publish cannot rename it to the qualifiedName's numeric + # tail, and a full-replace cannot blank category/rowLimit/etc. + assert attrs["name"] == "sales-snowflake" + assert attrs["category"] == "warehouse" + # CONNECT-843: the connection's own credential rides on the entity (the UI's + # wire shape), never as a bare top-level credential_guid. + assert attrs["defaultCredentialGuid"] == "conn-cred-guid" assert out["credential_guid"] == "" # and not duplicated at the top level @@ -267,9 +290,12 @@ def test_miner_auto_resolves_connection_credential(client): # create endpoint rewrite that credential's shared config record down to # {"credentialSource": "direct"}, breaking every workflow sharing the guid. # --------------------------------------------------------------------------- # -def _resolve_to(client, guid): - """Make the connection lookup in _create() resolve to ``guid``.""" - client.asset.search.return_value = iter([Mock(default_credential_guid=guid)]) +def _resolve_to(client, guid, name="looked-up-conn"): + """Make the connection read-back in _create() return a full connection carrying + ``guid`` and ``name``.""" + client.asset.get_by_qualified_name.return_value = _connection( + "default/x/123", name=name, default_credential_guid=guid + ) @pytest.mark.parametrize( @@ -284,15 +310,51 @@ def test_auto_resolved_guid_rides_on_connection_not_top_level(client, cls, conne cls(client).connection(qualified_name=f"default/{connector}/123").create() out = client.app.create.call_args.kwargs["inputs"].to_inputs() attrs = out["connection"]["attributes"] - # exactly the UI's reuse shape: identity + the connection's own credential + # exactly the UI's reuse shape: identity + the connection's own credential + name assert attrs["defaultCredentialGuid"] == "resolved-guid" assert attrs["qualifiedName"] == f"default/{connector}/123" assert attrs["connectorName"] == connector + assert attrs["name"] == "looked-up-conn" # AICHAT-1798: name preserved # the guid is NOT echoed at the top level, and no credential body is invented assert out["credential_guid"] == "" assert "credential" not in out +def test_explicit_connection_name_wins_over_looked_up_name(client): + # A caller-supplied name is never clobbered by the connection's stored name. + _resolve_to(client, "g", name="stored-in-atlas") + SnowflakeMiner(client).connection( + qualified_name="default/snowflake/123", name="chosen-by-caller" + ).create() + out = client.app.create.call_args.kwargs["inputs"].to_inputs() + assert out["connection"]["attributes"]["name"] == "chosen-by-caller" + + +def test_read_back_failure_raises_rather_than_risking_a_rename(client): + # If the connection can't be read and no name was given, sending a QN-only stub + # would rename it to the qualifiedName tail — so fail loudly instead of running. + client.asset.get_by_qualified_name.side_effect = Exception("not found") + with pytest.raises(InvalidRequestError, match="ATLAN-PYTHON-400-081"): + SnowflakeMiner(client).connection( + qualified_name="default/snowflake/123" + ).create() + client.app.create.assert_not_called() # nothing was submitted + + +def test_read_back_failure_with_explicit_name_falls_back(client): + # An explicit name is already safe from the rename, so a read failure is + # recoverable: the run still goes out, carrying the caller's name. + client.asset.get_by_qualified_name.side_effect = Exception("not found") + SnowflakeMiner(client).connection( + qualified_name="default/snowflake/123", name="chosen" + ).create() + attrs = client.app.create.call_args.kwargs["inputs"].to_inputs()["connection"][ + "attributes" + ] + assert attrs["name"] == "chosen" + assert attrs["qualifiedName"] == "default/snowflake/123" + + def test_explicit_guid_on_existing_connection_rides_on_connection(): # Same routing when the caller supplies the guid itself instead of letting # _create() resolve it. The trigger is "guid + existing connection", not @@ -353,7 +415,7 @@ def test_staged_credential_on_existing_connection_keeps_vaulting_shape(client): assert out["credential"]["authType"] == "gcp-wif" assert out["credential_guid"] == "" assert "defaultCredentialGuid" not in out["connection"]["attributes"] - client.asset.search.assert_not_called() # a staged cred needs no lookup + client.asset.get_by_qualified_name.assert_not_called() # staged cred: no read-back def test_agent_mode_on_existing_connection_ignores_credential_guid(): From fb6b6499f1114a45cddb65610f022dc37b721429 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Thu, 10 Sep 2026 15:42:39 +0530 Subject: [PATCH 2/3] fix(apps): send the connection's stored config, not the full entity (AICHAT-1798) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-back used min_ext_info=False, which returns the connection's entire attribute set — ~89 fields including empty analytics arrays and computed scores (popularityScore, viewScore, sourceRead*, assetMc*, ...). Echoing those from an extract step is noise and can clobber real popularity on a full-replace. Fetch only the config attributes the UI forwards on a miner run (identity, credential/policy strategy, admins, query settings) via get_by_qualified_name's attributes= list. The payload now matches the frontend's connection shape (~21 config fields, name included), with no analytics/popularity fields. --- pyatlan/model/apps/_base.py | 63 +++++++++++++++++++++++++-------- tests/unit/test_app_builders.py | 6 ++-- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/pyatlan/model/apps/_base.py b/pyatlan/model/apps/_base.py index f35d90ba4..943846a34 100644 --- a/pyatlan/model/apps/_base.py +++ b/pyatlan/model/apps/_base.py @@ -30,6 +30,38 @@ # not be echoed back on update (stripped server-side on create). _RUNTIME_KEYS = frozenset({"user-id", "user_id", "workflow_id", "correlation_id"}) +# The connection attributes an app run puts on its connection object — the stored +# config the UI forwards on a miner run (identity, credential/policy strategy, +# admins, query settings). Deliberately NOT the computed analytics/popularity +# fields (popularityScore, viewScore, sourceRead*, assetMc*, …) that a full +# entity read carries: echoing those from an extract step is noise at best and +# can clobber real popularity at worst. Fetched by name so only these come back. +_CONNECTION_WIRE_ATTRS = ( + "name", + "connectorName", + "defaultCredentialGuid", + "category", + "adminUsers", + "adminGroups", + "adminRoles", + "allowQuery", + "allowQueryPreview", + "queryTimeout", + "rowLimit", + "credentialStrategy", + "previewCredentialStrategy", + "policyStrategy", + "policyStrategyForSamplePreview", + "objectStorageUploadThreshold", + "hasPopularityInsights", + "connectionDbtEnvironments", + "connectionIsDQEnabled", + "isPartial", + "isSampleDataPreviewEnabled", + "vectorEmbeddingsEnabled", + "sourceLogo", +) + class AppInput(BaseModel): """A typed, configmap-derived ``inputs`` payload for an app workflow.""" @@ -115,11 +147,11 @@ def __init__(self, client: Any): self._admin_roles: List[str] = [] self._metadata: Dict[str, Any] = {} self._update_slug: Optional[str] = None - # A full connection object to send verbatim (typeName + attributes): either - # captured by load() (update path) or read back by _create() when a fresh - # run references an existing connection by QN. Sending the whole connection - # keeps a full-replace downstream from dropping attributes (AICHAT-1798). - # Cleared by an explicit connection() call, which then wins. + # A connection object to send verbatim (typeName + attributes): the full + # persisted connection captured by load() (update path), or the connection's + # stored config read back by _create() when a fresh run references an + # existing connection by QN — so its name reaches the payload and the run + # cannot rename it (AICHAT-1798). Cleared by an explicit connection() call. self._loaded_connection: Optional[Any] = None # ── Step 1 · Credential ──────────────────────────────────────────────── @@ -463,17 +495,17 @@ def _vault_credential(self, cred: Credential) -> str: return CredentialResponse(**raw).id or "" def _load_existing_connection(self, qualified_name: str) -> Dict[str, Any]: - """Read an existing connection in full and return it as the ``{typeName, - attributes}`` wire object, so a caller referencing a connection by QN (e.g. - miners) sends the whole connection the way the UI and a rerun do — not a - stub built from the QN alone. + """Read an existing connection's stored config and return it as the + ``{typeName, attributes}`` wire object, so a caller referencing a connection + by QN (e.g. miners) sends the connection the way the UI does — not a stub + built from the QN alone. - This is what keeps the connection intact. Popularity/publish full-replaces - the connection from this payload, so a stub missing ``name`` makes the + This is what keeps the connection's name intact: popularity/publish derives + the connection name from this payload, so a stub missing ``name`` makes the exporter fall back to the qualifiedName's numeric tail and rename the - connection to that number (AICHAT-1798); the same gap can blank other - attributes (e.g. category/rowLimit). A full read-back carries every - attribute, so the replace is a no-op. + connection to that number (AICHAT-1798). Only the config attributes the UI + forwards are fetched (``_CONNECTION_WIRE_ATTRS``) — never the computed + analytics/popularity fields, which must not be echoed from an extract step. Raises whatever the read raised (e.g. ``NotFoundError``) rather than returning a stub: a run that cannot read the connection must not rename it. @@ -487,8 +519,9 @@ def _load_existing_connection(self, qualified_name: str) -> Dict[str, Any]: connection = self._client.asset.get_by_qualified_name( qualified_name=qualified_name, asset_type=Connection, - min_ext_info=False, + min_ext_info=True, ignore_relationships=True, + attributes=list(_CONNECTION_WIRE_ATTRS), ) # Serialize via .json() (not .dict()): pydantic's encoders turn set-typed # attributes into lists and enums/datetimes into their wire form, so the diff --git a/tests/unit/test_app_builders.py b/tests/unit/test_app_builders.py index 8c1339676..fae48e599 100644 --- a/tests/unit/test_app_builders.py +++ b/tests/unit/test_app_builders.py @@ -256,10 +256,10 @@ def _connection(qn, **attrs): return conn -def test_miner_sends_the_full_existing_connection(client): +def test_miner_sends_the_existing_connection_config(client): # Referencing an existing connection by QN (no credential): the builder reads - # the whole connection back and sends it — name, credential, everything — the - # way the UI and a rerun do, so a full-replace downstream drops nothing. + # the connection's stored config back and sends it — name, credential, config — + # the way the UI does, so the run cannot rename it to the qualifiedName tail. client.asset.get_by_qualified_name.return_value = _connection( "default/snowflake/123", name="sales-snowflake", From 5504c43b1322ca9b014114d93bc23dc5f1c89119 Mon Sep 17 00:00:00 2001 From: Aryamanz29 Date: Thu, 10 Sep 2026 15:46:21 +0530 Subject: [PATCH 3/3] test(apps): assert the curated connection config + allowlist-drop analytics Add a client-side allowlist filter so no attribute outside _CONNECTION_WIRE_ATTRS can ride on the payload even if the read returns more, and strengthen the miner test: it now asserts the read requests only the curated attributes, the config fields (name/category/rowLimit/admins/connectorName/defaultCredentialGuid) flow through, and computed analytics on the read-back (popularityScore/viewScore) are dropped rather than forwarded. --- pyatlan/model/apps/_base.py | 4 ++++ tests/unit/test_app_builders.py | 38 ++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/pyatlan/model/apps/_base.py b/pyatlan/model/apps/_base.py index 943846a34..662a8968e 100644 --- a/pyatlan/model/apps/_base.py +++ b/pyatlan/model/apps/_base.py @@ -533,6 +533,10 @@ def _load_existing_connection(self, qualified_name: str) -> Dict[str, Any]: ).get("attributes") or {} ) + # Keep only the config attributes the UI forwards — defensive in case the + # read returns more than was asked for — so no computed analytics/popularity + # field can ride along on a full-replace. + attrs = {k: v for k, v in attrs.items() if k in _CONNECTION_WIRE_ATTRS} # Identity the run was given always wins over the read-back. attrs["qualifiedName"] = qualified_name parts = qualified_name.split("/") diff --git a/tests/unit/test_app_builders.py b/tests/unit/test_app_builders.py index fae48e599..62592d442 100644 --- a/tests/unit/test_app_builders.py +++ b/tests/unit/test_app_builders.py @@ -256,29 +256,57 @@ def _connection(qn, **attrs): return conn +_NOISE_ATTRS = ( + "popularityScore", + "viewScore", + "sourceReadTopUserList", + "assetMcIncidentNames", + "starredBy", +) + + def test_miner_sends_the_existing_connection_config(client): # Referencing an existing connection by QN (no credential): the builder reads # the connection's stored config back and sends it — name, credential, config — # the way the UI does, so the run cannot rename it to the qualifiedName tail. - client.asset.get_by_qualified_name.return_value = _connection( + # The read-back here also carries computed analytics (popularityScore/...), to + # prove those are dropped, not forwarded. + read_back = _connection( "default/snowflake/123", name="sales-snowflake", default_credential_guid="conn-cred-guid", category="warehouse", + row_limit=10000, + admin_users=["u1"], + popularity_score=1.5, + view_score=2.0, ) + client.asset.get_by_qualified_name.return_value = read_back SnowflakeMiner(client).connection(qualified_name="default/snowflake/123").create() - assert client.asset.get_by_qualified_name.called # the connection was read back + + # the read-back asks for ONLY the UI's config attributes — never the computed + # analytics/popularity fields — so they cannot ride along on a full-replace. + from pyatlan.model.apps._base import _CONNECTION_WIRE_ATTRS + + requested = client.asset.get_by_qualified_name.call_args.kwargs["attributes"] + assert requested == list(_CONNECTION_WIRE_ATTRS) + assert all(noise not in requested for noise in _NOISE_ATTRS) + out = client.app.create.call_args.kwargs["inputs"].to_inputs() attrs = out["connection"]["attributes"] - # AICHAT-1798: the connection's own name (and every other attribute) ride on the - # payload, so popularity/publish cannot rename it to the qualifiedName's numeric - # tail, and a full-replace cannot blank category/rowLimit/etc. + # AICHAT-1798: the connection's name + config ride on the payload, so + # popularity/publish cannot rename it to the qualifiedName's numeric tail. assert attrs["name"] == "sales-snowflake" assert attrs["category"] == "warehouse" + assert attrs["rowLimit"] == 10000 + assert attrs["adminUsers"] == ["u1"] + assert attrs["connectorName"] == "snowflake" # CONNECT-843: the connection's own credential rides on the entity (the UI's # wire shape), never as a bare top-level credential_guid. assert attrs["defaultCredentialGuid"] == "conn-cred-guid" assert out["credential_guid"] == "" # and not duplicated at the top level + # and NONE of the computed analytics/popularity fields leak through. + assert all(noise not in attrs for noise in _NOISE_ATTRS) # --------------------------------------------------------------------------- #