Files
wehub-resource-sync bf2343b7e4
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:35:45 +08:00

349 lines
14 KiB
Python

# Copyright 2025 Collate
# Licensed under the Collate Community License, Version 1.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# https://github.com/open-metadata/OpenMetadata/blob/main/ingestion/LICENSE
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for the Databricks BaseConnection wiring and test-connection checks."""
from unittest.mock import MagicMock, patch
import pytest
from metadata.core.connections.test_connection.check import CheckError, collect_checks
from metadata.core.connections.test_connection.checks.database import DatabaseStep
from metadata.core.connections.test_connection.network import NetworkUnreachableError
from metadata.generated.schema.entity.services.connections.database.databricks.personalAccessToken import (
PersonalAccessToken,
)
from metadata.generated.schema.entity.services.connections.database.databricksConnection import (
DatabricksConnection as DatabricksConnectionConfig,
)
from metadata.ingestion.connections.connection import BaseConnection
from metadata.ingestion.source.database.databricks.connection import (
DATABRICKS_ERRORS,
DatabricksChecks,
DatabricksConnection,
)
CONNECTION_MODULE = "metadata.ingestion.source.database.databricks.connection"
def _config() -> DatabricksConnectionConfig:
return DatabricksConnectionConfig(
hostPort="my-workspace.cloud.databricks.com:443",
authType=PersonalAccessToken(token="dapi-secret"),
httpPath="/sql/1.0/warehouses/abc123",
catalog="my_catalog",
)
class _SqlAlchemyError(Exception):
"""Mirror ``sqlalchemy.exc.DBAPIError``: wraps the driver error on ``.orig``."""
def __init__(self, orig: Exception) -> None:
super().__init__(str(orig))
self.orig = orig
def test_databricks_connection_is_base_connection():
assert issubclass(DatabricksConnection, BaseConnection)
def test_get_client_uses_the_databricks_url_builder():
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
_ = DatabricksConnection(_config()).client
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
def test_close_disposes_the_engine_pool():
engine = MagicMock()
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection", return_value=engine):
connection = DatabricksConnection(_config())
assert connection.client is engine
connection.close()
engine.dispose.assert_called_once_with()
def test_connection_timeout_drives_the_per_step_budget():
from metadata.core.connections.test_connection.constants import STEP_TIMEOUT_SECONDS
# connectionTimeout (default 120) must win over the 60s framework default,
# fed through BaseConnection.step_timeout_seconds.
default_config = _config()
assert DatabricksConnection(default_config).step_timeout_seconds == default_config.connectionTimeout
slow_config = _config()
slow_config.connectionTimeout = 300
assert DatabricksConnection(slow_config).step_timeout_seconds == 300
unset_config = _config()
unset_config.connectionTimeout = None
assert DatabricksConnection(unset_config).step_timeout_seconds == STEP_TIMEOUT_SECONDS
def test_invalid_token_message_is_classified():
error = _SqlAlchemyError(Exception("Error during request to server: Invalid access token"))
assert DATABRICKS_ERRORS.classify(error).title == "Authentication failed"
def test_expired_token_message_is_classified():
error = _SqlAlchemyError(Exception("the access token is expired, please refresh it"))
assert DATABRICKS_ERRORS.classify(error).title == "Access token expired"
def test_forbidden_message_is_classified():
error = _SqlAlchemyError(Exception("HTTP Response code: 403, Forbidden"))
assert DATABRICKS_ERRORS.classify(error).title == "Access denied"
def test_malformed_http_path_is_classified():
# A bad httpPath fails CheckAccess with MALFORMED_REQUEST; needs a diagnosis.
error = _SqlAlchemyError(
Exception(
"MALFORMED_REQUEST: Path /sql/1.0/warehouses/39c390db3a5e19e must match pattern "
"/sql/1.0/endpoints/<endpointId> or /sql/1.0/warehouses/<warehouseId>"
)
)
assert DATABRICKS_ERRORS.classify(error).title == "Invalid HTTP path"
def test_permission_denied_is_classified():
error = _SqlAlchemyError(Exception("[PERMISSION_DENIED] User does not have SELECT on system.access.table_lineage"))
assert DATABRICKS_ERRORS.classify(error).title == "Insufficient privileges"
def test_insufficient_permissions_is_classified():
error = _SqlAlchemyError(Exception("INSUFFICIENT_PERMISSIONS: requires USAGE on catalog"))
assert DATABRICKS_ERRORS.classify(error).title == "Insufficient privileges"
def test_table_or_view_not_found_is_classified():
error = _SqlAlchemyError(Exception("[TABLE_OR_VIEW_NOT_FOUND] The table or view cannot be found"))
assert DATABRICKS_ERRORS.classify(error).title == "Table or view not found"
def test_schema_not_found_is_classified():
error = _SqlAlchemyError(Exception("[SCHEMA_NOT_FOUND] The schema cannot be found"))
assert DATABRICKS_ERRORS.classify(error).title == "Schema not found"
def test_catalog_does_not_exist_is_classified():
error = _SqlAlchemyError(Exception("Catalog 'nope' does not exist"))
assert DATABRICKS_ERRORS.classify(error).title == "Object not found"
def test_no_such_catalog_exception_is_classified():
# NO_SUCH_CATALOG_EXCEPTION says "was not found", not "does not exist".
error = _SqlAlchemyError(Exception("[NO_SUCH_CATALOG_EXCEPTION] Catalog 'batata' was not found. SQLSTATE: 42704"))
assert DATABRICKS_ERRORS.classify(error).title == "Catalog not found"
def test_no_such_schema_exception_is_classified():
error = _SqlAlchemyError(Exception("[NO_SUCH_SCHEMA_EXCEPTION] Schema 'nope' was not found"))
assert DATABRICKS_ERRORS.classify(error).title == "Schema not found"
def test_network_errors_classify_through_including():
error = NetworkUnreachableError("workspace:443 is not reachable")
error.__cause__ = ConnectionRefusedError(61, "Connection refused")
assert DATABRICKS_ERRORS.classify(error).title == "Connection refused"
def test_unknown_error_returns_no_diagnosis():
error = _SqlAlchemyError(Exception("something entirely unexpected"))
assert DATABRICKS_ERRORS.classify(error) is None
def test_checks_cover_exactly_the_seeded_steps():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
collected = collect_checks(checks)
assert set(collected.keys()) == {
DatabaseStep.CheckAccess,
DatabaseStep.GetDatabases,
DatabaseStep.GetSchemas,
DatabaseStep.GetTables,
DatabaseStep.GetViews,
DatabaseStep.GetQueries,
DatabaseStep.GetViewDefinitions,
DatabaseStep.GetCatalogTags,
DatabaseStep.GetSchemaTags,
DatabaseStep.GetTableTags,
DatabaseStep.GetColumnTags,
DatabaseStep.GetTableLineage,
DatabaseStep.GetColumnLineage,
}
def test_construction_does_not_touch_the_network():
# Building the provider must not connect - that belongs in a gated check.
engine = MagicMock()
DatabricksChecks(client=engine, service_connection=_config())
engine.connect.assert_not_called()
def test_construction_does_not_build_the_inspector():
# Regression: inspect(engine) connects eagerly, so building it in __init__ would
# connect before the gate (auth error escapes as a 500). Keep it lazy.
with patch(f"{CONNECTION_MODULE}.inspect") as mock_inspect:
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
mock_inspect.assert_not_called()
_ = checks._engine_wrapper.inspector
mock_inspect.assert_called_once()
def test_check_access_probes_the_host_port_then_pings():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with (
patch(f"{CONNECTION_MODULE}.tcp_probe") as mock_probe,
patch(f"{CONNECTION_MODULE}.run_sql", return_value=MagicMock()) as mock_run,
):
checks.check_access()
mock_probe.assert_called_once_with("my-workspace.cloud.databricks.com", 443)
assert mock_run.call_count == 1
def test_check_access_reports_unreachable_host_as_network_failure():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
probe_error = NetworkUnreachableError("my-workspace.cloud.databricks.com:443 is not reachable")
probe_error.__cause__ = ConnectionRefusedError(61, "Connection refused")
with (
patch(f"{CONNECTION_MODULE}.tcp_probe", side_effect=probe_error) as mock_probe,
pytest.raises(CheckError) as exc,
):
checks.check_access()
mock_probe.assert_called_once_with("my-workspace.cloud.databricks.com", 443)
assert exc.value.cause is probe_error
assert DATABRICKS_ERRORS.classify(exc.value.cause).title == "Connection refused"
def test_probe_target_defaults_to_443_when_no_port():
config = _config()
config.hostPort = "my-workspace.cloud.databricks.com"
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
assert checks._probe_target() == ("my-workspace.cloud.databricks.com", 443)
def test_first_catalog_uses_configured_catalog_without_querying():
engine = MagicMock()
checks = DatabricksChecks(client=engine, service_connection=_config())
assert checks._first_catalog() == "my_catalog"
engine.connect.assert_not_called()
def test_listing_caps_rows_at_the_sample_size_at_the_source():
# Bound the fetch (fetchmany), not fetchall-then-slice. Test-connection only;
# real ingestion goes through the common framework.
from metadata.core.connections.test_connection.checks.database import (
DEFAULT_SAMPLE_ROWS,
)
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
result = MagicMock()
result.fetchmany.return_value = [("t",)]
conn = MagicMock()
conn.execute.return_value = result
engine = MagicMock()
engine.connect.return_value.__enter__.return_value = conn
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = "my_catalog"
wrapper.first_schema = "my_schema"
wrapper.get_tables()
result.fetchmany.assert_called_once_with(DEFAULT_SAMPLE_ROWS)
result.fetchall.assert_not_called()
def test_get_databases_reports_catalog_count():
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with patch.object(
checks._engine_wrapper, "get_catalogs", return_value=[("my_catalog",), ("other",)]
) as mock_catalogs:
evidence = checks.get_databases()
mock_catalogs.assert_called_once_with(catalog_name="my_catalog")
assert evidence.summary == "2 catalogs enumerated"
def test_get_databases_failure_reports_the_attempted_command_as_evidence():
config = _config()
config.catalog = None
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
with (
patch.object(checks._engine_wrapper, "get_catalogs", side_effect=Exception("boom")),
pytest.raises(CheckError) as exc,
):
checks.get_databases()
assert exc.value.evidence.command == "SHOW CATALOGS"
def test_get_databases_omits_the_command_when_catalog_is_configured():
# A configured catalog is trusted without running SHOW CATALOGS.
checks = DatabricksChecks(client=MagicMock(), service_connection=_config())
with patch.object(checks._engine_wrapper, "get_catalogs", return_value=[("my_catalog",)]):
evidence = checks.get_databases()
assert evidence.command is None
def test_probe_target_strips_a_pasted_url_scheme_and_path():
# A pasted workspace URL must not reach the socket probe as the host, or the
# gate fails with a misleading DNS error before the driver.
config = _config()
config.hostPort = "https://my-workspace.cloud.databricks.com:443/sql/1.0/warehouses/x"
checks = DatabricksChecks(client=MagicMock(), service_connection=config)
assert checks._probe_target() == ("my-workspace.cloud.databricks.com", 443)
def test_connection_url_strips_a_pasted_url_scheme():
from urllib.parse import urlparse
from metadata.ingestion.source.database.databricks.connection import get_connection_url
config = _config()
config.hostPort = "https://my-workspace.cloud.databricks.com:443"
parsed = urlparse(get_connection_url(config))
assert parsed.hostname == "my-workspace.cloud.databricks.com"
assert parsed.port == 443
def test_auth_host_derivation_strips_a_pasted_scheme():
# OAuth/Azure-AD derive the workspace host from hostPort too; a pasted scheme
# must not leak in as the host or auth fails before the driver.
from metadata.ingestion.source.database.databricks.auth import _host, normalize_host_port
assert normalize_host_port("https://ws.cloud.databricks.com:443/sql/x") == "ws.cloud.databricks.com:443"
connection = MagicMock()
connection.hostPort = "https://ws.cloud.databricks.com:443"
assert _host(connection) == "ws.cloud.databricks.com"
def test_schema_scoped_get_schemas_skips_use_catalog_when_catalog_unresolved():
# first_catalog None (GetDatabases failed/skipped) must not emit USE CATALOG `None`.
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
engine = MagicMock()
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = None
assert wrapper.get_schemas(schema_name="my_schema") == ["my_schema"]
engine.connect.assert_not_called()
def test_get_tables_returns_empty_when_catalog_unresolved():
from metadata.ingestion.source.database.databricks.connection import (
DatabricksEngineWrapper,
)
engine = MagicMock()
wrapper = DatabricksEngineWrapper(engine)
wrapper.first_catalog = None
wrapper.first_schema = "my_schema"
assert wrapper.get_tables() == []
engine.connect.assert_not_called()