Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions backend/src/analytics_agent/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,30 @@ async def list_connections(session: AsyncSession = Depends(get_session)):
if f is not None
]
else:
status_str = "unconfigured"
fields = []
from analytics_agent.engines.factory import _CONNECTOR_MAP as _CM

spec = _CM.get(intg.type)
if spec is not None and spec.display_fields:
status_str = "connected" if spec.is_configured(conn_cfg) else "unconfigured"
fields = []
for df in spec.display_fields:
raw = conn_cfg.get(df.key, "") or os.environ.get(
spec.env_map.get(df.key, ""), ""
)
value = ("(configured)" if raw else "") if df.sensitive else str(raw)
fields.append(
ConnectionField(
key=df.key,
label=df.label,
value=value,
sensitive=df.sensitive,
secret_key=df.secret_key,
placeholder=df.placeholder,
)
)
else:
status_str = "unconfigured"
fields = []

oauth_status = (
OAuthStatus(
Expand Down
36 changes: 36 additions & 0 deletions backend/src/analytics_agent/engines/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@
_registry: dict[str, QueryEngine] = {}


@dataclass
class DisplayField:
"""How a connector config key should render in the Settings UI."""

key: str
label: str
placeholder: str = ""
sensitive: bool = False
# When sensitive, the key under body.secrets the frontend posts on save.
# Must appear in secret_env_vars.
secret_key: str = ""


@dataclass
class ConnectorSpec:
"""Describes how to launch a native connector as an MCP subprocess via uvx."""
Expand All @@ -21,6 +34,10 @@ class ConnectorSpec:
required_keys: list[str] = field(default_factory=list)
# Keys where ANY ONE being present counts as having credentials.
credential_keys: list[str] = field(default_factory=list)
# Field schema used by /api/connections to render the Data Sources list.
# When non-empty, list_connections derives ConnectionField objects from
# this spec instead of hand-coding a per-type branch.
display_fields: list[DisplayField] = field(default_factory=list)

def is_configured(self, conn_cfg: dict, sso_connected: bool = False) -> bool:
"""True when the connection has enough config to attempt a real query.
Expand Down Expand Up @@ -113,6 +130,25 @@ def build_mcp_config(self, connection: dict) -> dict:
},
required_keys=["host"],
credential_keys=["user", "password"],
display_fields=[
DisplayField(key="host", label="Host", placeholder="kyuubi-host or localhost"),
DisplayField(key="port", label="Port", placeholder="10000"),
DisplayField(key="database", label="Database", placeholder="default"),
DisplayField(key="auth", label="Auth", placeholder="NONE (or NOSASL, LDAP, KERBEROS)"),
DisplayField(key="user", label="Username", placeholder="analytics_user"),
DisplayField(
key="password",
label="Password",
placeholder="LDAP/PLAIN only",
sensitive=True,
secret_key="password",
),
DisplayField(
key="kerberos_service_name",
label="Kerberos Service Name",
placeholder="hive",
),
],
),
"bigquery": ConnectorSpec(
package="analytics-agent-connector-bigquery",
Expand Down
175 changes: 175 additions & 0 deletions tests/unit/test_settings_wire_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
_resolve_secrets,
_upsert_env_vars,
create_connection,
list_connections,
update_connection,
)
from fastapi import HTTPException
Expand Down Expand Up @@ -332,3 +333,177 @@ async def _fake_upsert(**kwargs: object) -> None:
assert persisted_config.get("account") == "myorg-myacct"
env_vars = _read_env(env_file)
assert env_vars["SNOWFLAKE_PASSWORD"] == "postpwd"


# ---------------------------------------------------------------------------
# list_connections: spec-driven display_fields rendering
# ---------------------------------------------------------------------------


def _hive_integration(config: dict | None = None) -> MagicMock:
intg = MagicMock()
intg.name = "myhive"
intg.type = "hive"
intg.label = "My Hive"
intg.source = "ui"
intg.config = orjson.dumps(config or {}).decode()
intg.updated_at = None
return intg


@pytest.mark.asyncio
async def test_list_connections_hive_unconfigured_renders_all_fields() -> None:
"""Hive with no config still shows all 7 plugin fields (closes #52)."""
intg = _hive_integration(config={})
session = AsyncMock()

mock_intg_repo = AsyncMock()
mock_intg_repo.list_all = AsyncMock(return_value=[intg])
mock_cred_repo = AsyncMock()
mock_cred_repo.get = AsyncMock(return_value=None)
mock_settings_repo = AsyncMock()

with (
patch("analytics_agent.db.repository.IntegrationRepo", return_value=mock_intg_repo),
patch("analytics_agent.db.repository.CredentialRepo", return_value=mock_cred_repo),
patch("analytics_agent.api.settings.SettingsRepo", return_value=mock_settings_repo),
patch(
"analytics_agent.api.settings._get_datahub_connections",
AsyncMock(return_value=[]),
),
patch(
"analytics_agent.api.settings._get_disabled_tools",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_enabled_mutations",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_disabled_connections",
AsyncMock(return_value=set()),
),
patch.dict("analytics_agent.api.settings.os.environ", {}, clear=True),
):
conns = await list_connections(session)

hive = next(c for c in conns if c.type == "hive")
assert hive.status == "unconfigured"
field_keys = [f.key for f in hive.fields]
assert field_keys == [
"host",
"port",
"database",
"auth",
"user",
"password",
"kerberos_service_name",
]
labels = {f.key: f.label for f in hive.fields}
assert labels["host"] == "Host"
assert labels["password"] == "Password"
# Sensitive password field is masked and routes through secret_key.
password = next(f for f in hive.fields if f.key == "password")
assert password.sensitive is True
assert password.secret_key == "password"
assert password.value == ""


@pytest.mark.asyncio
async def test_list_connections_hive_configured_masks_password_and_shows_values() -> None:
intg = _hive_integration(
config={
"host": "kyuubi.internal",
"port": "10000",
"database": "analytics",
"user": "svc",
"password": "super-secret",
}
)
session = AsyncMock()

mock_intg_repo = AsyncMock()
mock_intg_repo.list_all = AsyncMock(return_value=[intg])
mock_cred_repo = AsyncMock()
mock_cred_repo.get = AsyncMock(return_value=None)
mock_settings_repo = AsyncMock()

with (
patch("analytics_agent.db.repository.IntegrationRepo", return_value=mock_intg_repo),
patch("analytics_agent.db.repository.CredentialRepo", return_value=mock_cred_repo),
patch("analytics_agent.api.settings.SettingsRepo", return_value=mock_settings_repo),
patch(
"analytics_agent.api.settings._get_datahub_connections",
AsyncMock(return_value=[]),
),
patch(
"analytics_agent.api.settings._get_disabled_tools",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_enabled_mutations",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_disabled_connections",
AsyncMock(return_value=set()),
),
patch.dict("analytics_agent.api.settings.os.environ", {}, clear=True),
):
conns = await list_connections(session)

hive = next(c for c in conns if c.type == "hive")
assert hive.status == "connected"
values = {f.key: f.value for f in hive.fields}
assert values["host"] == "kyuubi.internal"
assert values["port"] == "10000"
assert values["user"] == "svc"
# Password value is never sent verbatim; the placeholder string signals "set".
assert values["password"] == "(configured)"


@pytest.mark.asyncio
async def test_list_connections_unknown_type_falls_through_to_empty() -> None:
"""A type with no spec and no display_fields still gets handled gracefully."""
intg = MagicMock()
intg.name = "mystery"
intg.type = "totally-unknown-engine"
intg.label = "Mystery"
intg.source = "ui"
intg.config = orjson.dumps({"foo": "bar"}).decode()
intg.updated_at = None
session = AsyncMock()

mock_intg_repo = AsyncMock()
mock_intg_repo.list_all = AsyncMock(return_value=[intg])
mock_cred_repo = AsyncMock()
mock_cred_repo.get = AsyncMock(return_value=None)
mock_settings_repo = AsyncMock()

with (
patch("analytics_agent.db.repository.IntegrationRepo", return_value=mock_intg_repo),
patch("analytics_agent.db.repository.CredentialRepo", return_value=mock_cred_repo),
patch("analytics_agent.api.settings.SettingsRepo", return_value=mock_settings_repo),
patch(
"analytics_agent.api.settings._get_datahub_connections",
AsyncMock(return_value=[]),
),
patch(
"analytics_agent.api.settings._get_disabled_tools",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_enabled_mutations",
AsyncMock(return_value=set()),
),
patch(
"analytics_agent.api.settings._get_disabled_connections",
AsyncMock(return_value=set()),
),
patch.dict("analytics_agent.api.settings.os.environ", {}, clear=True),
):
conns = await list_connections(session)

mystery = next(c for c in conns if c.type == "totally-unknown-engine")
assert mystery.status == "unconfigured"
assert mystery.fields == []
Loading