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
382 lines
15 KiB
Python
382 lines
15 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 Athena connection handling."""
|
|
|
|
import socket
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from botocore.exceptions import ClientError
|
|
|
|
from metadata.core.connections.test_connection.check import collect_checks
|
|
from metadata.core.connections.test_connection.checks.database import DatabaseStep
|
|
from metadata.core.connections.test_connection.records import Evidence
|
|
from metadata.generated.schema.entity.services.connections.database.athenaConnection import (
|
|
AthenaConnection as AthenaConnectionConfig,
|
|
)
|
|
from metadata.generated.schema.entity.services.connections.database.athenaConnection import (
|
|
AthenaScheme,
|
|
)
|
|
from metadata.generated.schema.security.credentials.awsCredentials import AWSCredentials
|
|
from metadata.generated.schema.type.filterPattern import FilterPattern
|
|
from metadata.ingestion.connections.connection import BaseConnection
|
|
from metadata.ingestion.source.database.athena import connection as athena_connection
|
|
from metadata.ingestion.source.database.athena.connection import (
|
|
ATHENA_ERRORS,
|
|
AthenaChecks,
|
|
AthenaConnection,
|
|
)
|
|
|
|
CONNECTION_MODULE = "metadata.ingestion.source.database.athena.connection"
|
|
|
|
|
|
def _config(**kwargs) -> AthenaConnectionConfig:
|
|
base = {
|
|
"awsConfig": AWSCredentials(awsAccessKeyId="key", awsRegion="us-east-2", awsSecretAccessKey="secret_key"),
|
|
"s3StagingDir": "s3://postgres/input/",
|
|
"workgroup": "primary",
|
|
"scheme": AthenaScheme.awsathena_rest,
|
|
}
|
|
base.update(kwargs)
|
|
return AthenaConnectionConfig(**base)
|
|
|
|
|
|
def _checks(**config_kwargs) -> AthenaChecks:
|
|
conn = AthenaConnection(_config(**config_kwargs))
|
|
conn._client = MagicMock()
|
|
return conn.checks()
|
|
|
|
|
|
def _client_error(code: str, message: str = "denied") -> ClientError:
|
|
return ClientError({"Error": {"Code": code, "Message": message}}, "StartQueryExecution")
|
|
|
|
|
|
def _raising_factory(exc: BaseException):
|
|
def factory():
|
|
raise exc
|
|
|
|
return factory
|
|
|
|
|
|
def test_athena_connection_is_base_connection():
|
|
assert issubclass(AthenaConnection, BaseConnection)
|
|
|
|
|
|
def test_get_client_uses_the_class_url_builder():
|
|
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
|
|
_ = AthenaConnection(_config()).client
|
|
assert mock_connection.call_args.kwargs["get_connection_url_fn"].__name__ == "get_connection_url"
|
|
|
|
|
|
def test_get_client_registers_engine_disposal():
|
|
conn = AthenaConnection(_config())
|
|
with patch(f"{CONNECTION_MODULE}.create_generic_db_connection") as mock_connection:
|
|
engine = mock_connection.return_value
|
|
_ = conn.client
|
|
conn.close()
|
|
engine.dispose.assert_called_once_with()
|
|
|
|
|
|
def test_checks_returns_provider_over_the_client():
|
|
conn = AthenaConnection(_config())
|
|
conn._client = MagicMock()
|
|
provider = conn.checks()
|
|
assert isinstance(provider, AthenaChecks)
|
|
assert provider.client is conn._client
|
|
|
|
|
|
def test_checks_wires_filter_pattern_and_catalog_from_service_connection():
|
|
pattern = FilterPattern(includes=["db2"])
|
|
conn = AthenaConnection(_config(catalogId="my_catalog", schemaFilterPattern=pattern))
|
|
conn._client = MagicMock()
|
|
provider = conn.checks()
|
|
assert provider.schema_filter_pattern == pattern
|
|
assert provider.catalog_id == "my_catalog"
|
|
|
|
|
|
def test_every_athena_step_resolves_to_a_check():
|
|
provider = AthenaChecks(client_factory=MagicMock)
|
|
resolved = collect_checks(provider)
|
|
assert set(resolved) == {
|
|
DatabaseStep.CheckAccess,
|
|
DatabaseStep.GetSchemas,
|
|
DatabaseStep.GetTables,
|
|
DatabaseStep.GetViews,
|
|
}
|
|
|
|
|
|
def test_every_check_reports_the_command_it_ran():
|
|
# pyathena reflects via the AWS API, so the shared SQL capture yields nothing;
|
|
# each reflection step names its API operation instead.
|
|
provider = _checks()
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["ecommerce"]
|
|
inspector.get_table_names.return_value = ["orders"]
|
|
inspector.get_view_names.return_value = ["v_orders"]
|
|
|
|
with (
|
|
patch(f"{CONNECTION_MODULE}.list_schemas", return_value=Evidence(summary="1 schema enumerated")),
|
|
patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector),
|
|
):
|
|
assert provider.get_schemas().command == "athena:ListDatabases (CatalogName=AwsDataCatalog)"
|
|
assert provider.get_tables().command == "athena:ListTableMetadata (CatalogName=AwsDataCatalog)"
|
|
assert provider.get_views().command == "athena:ListTableMetadata (CatalogName=AwsDataCatalog)"
|
|
|
|
|
|
def test_command_names_a_configured_catalog():
|
|
provider = _checks(catalogId="my_catalog")
|
|
|
|
with patch(f"{CONNECTION_MODULE}.list_schemas", return_value=Evidence(summary="ok")):
|
|
assert provider.get_schemas().command == "athena:ListDatabases (CatalogName=my_catalog)"
|
|
|
|
|
|
def test_error_pack_classifies_access_denied_as_not_authorized():
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("AccessDeniedException"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Not authorized"
|
|
|
|
|
|
def test_error_pack_classifies_unrecognized_client_as_unknown_access_key():
|
|
# Athena is json-protocol: an unknown access key ID arrives as this.
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("UnrecognizedClientException"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS access key not recognized"
|
|
|
|
|
|
def test_error_pack_classifies_a_signature_error_through_a_wrapping_cause():
|
|
wrapped = RuntimeError("query failed")
|
|
wrapped.__cause__ = _client_error("InvalidSignatureException")
|
|
diagnosis = ATHENA_ERRORS.classify(wrapped)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS secret key does not match"
|
|
|
|
|
|
def test_error_pack_classifies_missing_workgroup():
|
|
error = RuntimeError("WorkGroup primary is not found")
|
|
diagnosis = ATHENA_ERRORS.classify(error)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Workgroup not found"
|
|
|
|
|
|
def test_error_pack_classifies_missing_result_location():
|
|
error = RuntimeError("No output location provided for query")
|
|
diagnosis = ATHENA_ERRORS.classify(error)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Query result location not configured"
|
|
|
|
|
|
def test_error_pack_classifies_unwritable_result_location():
|
|
error = RuntimeError("Access denied when writing to location: s3://my-bucket/query-results/abc.csv")
|
|
diagnosis = ATHENA_ERRORS.classify(error)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Cannot write query results"
|
|
|
|
|
|
def test_error_pack_classifies_an_unusable_result_bucket():
|
|
error = RuntimeError("An error occurred (InvalidRequestException): Unable to verify/create output bucket my-bucket")
|
|
diagnosis = ATHENA_ERRORS.classify(error)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Query result bucket not usable"
|
|
|
|
|
|
def test_error_pack_classifies_unreachable_endpoint():
|
|
error = RuntimeError('Could not connect to the endpoint URL: "https://athena.bad.amazonaws.com/"')
|
|
diagnosis = ATHENA_ERRORS.classify(error)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Cannot reach the AWS Athena endpoint"
|
|
|
|
|
|
def test_error_pack_classifies_not_authorized():
|
|
diagnosis = ATHENA_ERRORS.classify(
|
|
_client_error("SomeOtherException", "User is not authorized to perform: glue:GetTables")
|
|
)
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Not authorized"
|
|
|
|
|
|
def test_error_pack_folds_in_the_network_pack():
|
|
diagnosis = ATHENA_ERRORS.classify(socket.gaierror("name resolution failed"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Host could not be resolved"
|
|
|
|
|
|
def test_error_pack_classifies_sts_assume_role_denied_as_not_authorized():
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("AccessDenied", "not authorized to perform: sts:AssumeRole"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "Not authorized"
|
|
|
|
|
|
def test_error_pack_classifies_invalid_client_token_as_unknown_access_key():
|
|
# STS's code, from the assume-role leg.
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("InvalidClientTokenId"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS access key not recognized"
|
|
|
|
|
|
def test_error_pack_classifies_signature_mismatch_as_wrong_secret():
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("SignatureDoesNotMatch"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS secret key does not match"
|
|
|
|
|
|
def test_error_pack_classifies_expired_token():
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("ExpiredToken"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS session token expired"
|
|
|
|
|
|
def test_error_pack_prefers_an_authentication_code_over_not_authorized_text():
|
|
# The authorization rule sits above AWS_ERRORS; its message fallback must not
|
|
# swallow a rejected identity.
|
|
diagnosis = ATHENA_ERRORS.classify(_client_error("ExpiredToken", "not authorized: token expired"))
|
|
assert diagnosis is not None
|
|
assert diagnosis.title == "AWS session token expired"
|
|
|
|
|
|
def test_error_pack_returns_none_for_unknown_error():
|
|
assert ATHENA_ERRORS.classify(RuntimeError("something unrelated")) is None
|
|
|
|
|
|
def test_client_is_built_lazily_not_at_construction():
|
|
calls = []
|
|
provider = AthenaChecks(client_factory=lambda: calls.append(1) or MagicMock())
|
|
assert calls == []
|
|
_ = provider.client
|
|
_ = provider.client
|
|
assert calls == [1]
|
|
|
|
|
|
def test_check_access_surfaces_client_build_failure_for_classification():
|
|
# An assume-role / credential failure while building the engine now happens
|
|
# inside CheckAccess (the gate), so its exception propagates from the check and
|
|
# the runner can classify it - instead of raising at provider construction.
|
|
error = _client_error("InvalidClientTokenId", "The security token included in the request is invalid")
|
|
provider = AthenaChecks(client_factory=_raising_factory(error))
|
|
with pytest.raises(ClientError) as exc_info:
|
|
provider.check_access()
|
|
assert ATHENA_ERRORS.classify(exc_info.value).title == "AWS access key not recognized"
|
|
|
|
|
|
def test_athena_url():
|
|
expected = (
|
|
"awsathena+rest://key:secret_key@athena.us-east-2.amazonaws.com:443"
|
|
"?s3_staging_dir=s3%3A%2F%2Fpostgres%2Finput%2F&work_group=primary"
|
|
)
|
|
assert AthenaConnection.get_connection_url(_config()) == expected
|
|
|
|
|
|
def test_athena_url_other_staging_dir():
|
|
expected = (
|
|
"awsathena+rest://key:secret_key@athena.us-east-2.amazonaws.com:443"
|
|
"?s3_staging_dir=s3%3A%2F%2Fpostgres%2Fintput%2F&work_group=primary"
|
|
)
|
|
assert AthenaConnection.get_connection_url(_config(s3StagingDir="s3://postgres/intput/")) == expected
|
|
|
|
|
|
def test_get_tables_caveats_when_all_targeted_schemas_empty():
|
|
checks = _checks(catalogId="my_catalog")
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1", "db2"]
|
|
inspector.get_table_names.return_value = []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_tables()
|
|
assert evidence.caveat is not None
|
|
assert evidence.caveat.title == "No readable tables"
|
|
|
|
|
|
def test_get_tables_passes_without_caveat_when_a_schema_has_tables():
|
|
checks = _checks()
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1", "db2"]
|
|
inspector.get_table_names.side_effect = lambda schema: ["t1"] if schema == "db2" else []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_tables()
|
|
assert evidence.caveat is None
|
|
|
|
|
|
def test_get_tables_honors_schema_filter_pattern():
|
|
checks = _checks(schemaFilterPattern=FilterPattern(includes=["db2"]))
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1", "db2"]
|
|
inspector.get_table_names.side_effect = lambda schema: ["t1"] if schema == "db2" else []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
checks.get_tables()
|
|
probed = {call.args[0] for call in inspector.get_table_names.call_args_list}
|
|
assert probed == {"db2"}
|
|
|
|
|
|
def test_get_tables_caveats_when_filter_matches_nothing():
|
|
checks = _checks(catalogId="my_catalog", schemaFilterPattern=FilterPattern(includes=["nope"]))
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1", "db2"]
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_tables()
|
|
assert evidence.caveat is not None
|
|
assert evidence.caveat.title == "No schemas visible"
|
|
assert "my_catalog" in evidence.caveat.remediation
|
|
inspector.get_table_names.assert_not_called()
|
|
|
|
|
|
def test_get_views_does_not_raise_or_caveat_when_empty():
|
|
checks = _checks()
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1"]
|
|
inspector.get_view_names.return_value = []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_views()
|
|
assert evidence.caveat is None
|
|
|
|
|
|
def test_get_views_reports_visible_views():
|
|
checks = _checks()
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1"]
|
|
inspector.get_view_names.return_value = ["v1"]
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_views()
|
|
assert evidence.caveat is None
|
|
assert "views visible" in evidence.summary
|
|
|
|
|
|
def test_get_tables_caveat_is_actionable():
|
|
checks = _checks(catalogId="my_catalog")
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1"]
|
|
inspector.get_table_names.return_value = []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_tables()
|
|
remediation = evidence.caveat.remediation
|
|
assert "Lake Formation" in remediation
|
|
assert any(keyword in remediation.lower() for keyword in ("describe", "select", "grant"))
|
|
|
|
|
|
def test_get_tables_caps_probing_at_max_schemas():
|
|
checks = _checks(catalogId="my_catalog")
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = [f"db{i}" for i in range(500)]
|
|
inspector.get_table_names.return_value = []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
evidence = checks.get_tables()
|
|
assert evidence.caveat is not None
|
|
assert inspector.get_table_names.call_count <= athena_connection.MAX_SCHEMAS_TO_PROBE
|
|
|
|
|
|
def test_targeted_schemas_listed_once_across_table_and_view_steps():
|
|
checks = _checks()
|
|
inspector = MagicMock()
|
|
inspector.get_schema_names.return_value = ["db1"]
|
|
inspector.get_table_names.return_value = ["t1"]
|
|
inspector.get_view_names.return_value = []
|
|
with patch(f"{CONNECTION_MODULE}.inspect", return_value=inspector):
|
|
checks.get_tables()
|
|
checks.get_views()
|
|
assert inspector.get_schema_names.call_count == 1
|