@@ -993,3 +993,182 @@ def test_passes_session_and_region_to_get_iceberg_properties(self, mock_get_clie
993993 )
994994
995995 mock_get_iceberg .assert_called_once_with (session = mock_session , region = "us-east-1" )
996+
997+
998+ class TestHasLakeFormationConfig :
999+ """Tests for _has_lake_formation_config method."""
1000+
1001+ def test_returns_false_when_attribute_missing (self ):
1002+ """Test returns False when lake_formation_config attribute does not exist."""
1003+ fg = MagicMock (spec = FeatureGroupManager )
1004+ fg ._has_lake_formation_config = FeatureGroupManager ._has_lake_formation_config .__get__ (fg )
1005+ # MagicMock(spec=...) won't have lake_formation_config since it's not on the class
1006+ del fg .lake_formation_config
1007+ assert fg ._has_lake_formation_config () is False
1008+
1009+ def test_returns_false_when_none (self ):
1010+ """Test returns False when lake_formation_config is None."""
1011+ fg = MagicMock (spec = FeatureGroupManager )
1012+ fg ._has_lake_formation_config = FeatureGroupManager ._has_lake_formation_config .__get__ (fg )
1013+ fg .lake_formation_config = None
1014+ assert fg ._has_lake_formation_config () is False
1015+
1016+ def test_returns_false_when_unassigned (self ):
1017+ """Test returns False when lake_formation_config is Unassigned()."""
1018+ from sagemaker .core .shapes import Unassigned
1019+
1020+ fg = MagicMock (spec = FeatureGroupManager )
1021+ fg ._has_lake_formation_config = FeatureGroupManager ._has_lake_formation_config .__get__ (fg )
1022+ fg .lake_formation_config = Unassigned ()
1023+ assert fg ._has_lake_formation_config () is False
1024+
1025+ def test_returns_true_when_config_present (self ):
1026+ """Test returns True when lake_formation_config has a real value."""
1027+ fg = MagicMock (spec = FeatureGroupManager )
1028+ fg ._has_lake_formation_config = FeatureGroupManager ._has_lake_formation_config .__get__ (fg )
1029+ fg .lake_formation_config = MagicMock ()
1030+ assert fg ._has_lake_formation_config () is True
1031+
1032+
1033+ class TestGetIcebergPropertiesAccessDenied :
1034+ """Tests for AccessDeniedException handling in _get_iceberg_properties."""
1035+
1036+ def setup_method (self ):
1037+ from sagemaker .core .shapes import OfflineStoreConfig , S3StorageConfig , DataCatalogConfig
1038+
1039+ self .fg = MagicMock (spec = FeatureGroupManager )
1040+ self .fg ._get_iceberg_properties = FeatureGroupManager ._get_iceberg_properties .__get__ (self .fg )
1041+ self .fg .feature_group_name = "test-fg"
1042+ self .fg .offline_store_config = OfflineStoreConfig (
1043+ s3_storage_config = S3StorageConfig (s3_uri = "s3://test-bucket/path" ),
1044+ data_catalog_config = DataCatalogConfig (
1045+ catalog = "AwsDataCatalog" , database = "test_db" , table_name = "test_table"
1046+ ),
1047+ table_format = "Iceberg" ,
1048+ )
1049+
1050+ def _make_client_error (self , code ):
1051+ from botocore .exceptions import ClientError
1052+ return ClientError ({"Error" : {"Code" : code , "Message" : "denied" }}, "GetTable" )
1053+
1054+ @patch ("sagemaker.mlops.feature_store.feature_group_manager.load_catalog" )
1055+ def test_access_denied_with_lake_formation_raises_permission_error (self , mock_load_catalog ):
1056+ """Test PermissionError with LF message when AccessDenied and LF config present."""
1057+ mock_catalog = MagicMock ()
1058+ mock_catalog .load_table .side_effect = self ._make_client_error ("AccessDeniedException" )
1059+ mock_load_catalog .return_value = mock_catalog
1060+ self .fg ._has_lake_formation_config .return_value = True
1061+
1062+ with pytest .raises (PermissionError , match = "Lake Formation governance" ):
1063+ self .fg ._get_iceberg_properties (session = MagicMock ())
1064+
1065+ @patch ("sagemaker.mlops.feature_store.feature_group_manager.load_catalog" )
1066+ def test_access_denied_without_lake_formation_raises_permission_error (self , mock_load_catalog ):
1067+ """Test PermissionError with IAM message when AccessDenied and no LF config."""
1068+ mock_catalog = MagicMock ()
1069+ mock_catalog .load_table .side_effect = self ._make_client_error ("AccessDeniedException" )
1070+ mock_load_catalog .return_value = mock_catalog
1071+ self .fg ._has_lake_formation_config .return_value = False
1072+
1073+ with pytest .raises (PermissionError , match = "glue:GetTable" ):
1074+ self .fg ._get_iceberg_properties (session = MagicMock ())
1075+
1076+ @patch ("sagemaker.mlops.feature_store.feature_group_manager.load_catalog" )
1077+ def test_non_access_denied_client_error_raises_runtime_error (self , mock_load_catalog ):
1078+ """Test RuntimeError for non-AccessDenied ClientError."""
1079+ mock_catalog = MagicMock ()
1080+ mock_catalog .load_table .side_effect = self ._make_client_error ("EntityNotFoundException" )
1081+ mock_load_catalog .return_value = mock_catalog
1082+
1083+ with pytest .raises (RuntimeError , match = "Failed to get Iceberg properties" ):
1084+ self .fg ._get_iceberg_properties (session = MagicMock ())
1085+
1086+ @patch ("sagemaker.mlops.feature_store.feature_group_manager.load_catalog" )
1087+ def test_access_denied_permission_error_chains_original (self , mock_load_catalog ):
1088+ """Test that PermissionError chains the original ClientError via __cause__."""
1089+ original = self ._make_client_error ("AccessDeniedException" )
1090+ mock_catalog = MagicMock ()
1091+ mock_catalog .load_table .side_effect = original
1092+ mock_load_catalog .return_value = mock_catalog
1093+ self .fg ._has_lake_formation_config .return_value = False
1094+
1095+ with pytest .raises (PermissionError ) as exc_info :
1096+ self .fg ._get_iceberg_properties (session = MagicMock ())
1097+ assert exc_info .value .__cause__ is original
1098+
1099+
1100+ class TestUpdateIcebergPropertiesAccessDenied :
1101+ """Tests for AccessDeniedException handling in _update_iceberg_properties."""
1102+
1103+ def setup_method (self ):
1104+ self .fg = MagicMock (spec = FeatureGroupManager )
1105+ self .fg ._update_iceberg_properties = FeatureGroupManager ._update_iceberg_properties .__get__ (self .fg )
1106+ self .fg .feature_group_name = "test-fg"
1107+
1108+ def _make_client_error (self , code ):
1109+ from botocore .exceptions import ClientError
1110+ return ClientError ({"Error" : {"Code" : code , "Message" : "denied" }}, "UpdateTable" )
1111+
1112+ def _setup_get_result (self ):
1113+ mock_table = MagicMock ()
1114+ mock_table .transaction .return_value .__enter__ = MagicMock (return_value = MagicMock ())
1115+ mock_table .transaction .return_value .__exit__ = MagicMock (return_value = False )
1116+ self .fg ._get_iceberg_properties .return_value = {
1117+ "database_name" : "test_db" ,
1118+ "table_name" : "test_table" ,
1119+ "table" : mock_table ,
1120+ "properties" : {},
1121+ }
1122+ return mock_table
1123+
1124+ def test_access_denied_with_lake_formation_raises_permission_error (self ):
1125+ """Test PermissionError with LF message when AccessDenied and LF config present."""
1126+ mock_table = self ._setup_get_result ()
1127+ mock_table .transaction ().__enter__ ().set_properties .side_effect = self ._make_client_error ("AccessDeniedException" )
1128+ self .fg ._has_lake_formation_config .return_value = True
1129+
1130+ props = IcebergProperties (properties = {"write.target-file-size-bytes" : "536870912" })
1131+ with pytest .raises (PermissionError , match = "Lake Formation governance" ):
1132+ self .fg ._update_iceberg_properties (iceberg_properties = props )
1133+
1134+ def test_access_denied_without_lake_formation_raises_permission_error (self ):
1135+ """Test PermissionError with IAM message when AccessDenied and no LF config."""
1136+ mock_table = self ._setup_get_result ()
1137+ mock_table .transaction ().__enter__ ().set_properties .side_effect = self ._make_client_error ("AccessDeniedException" )
1138+ self .fg ._has_lake_formation_config .return_value = False
1139+
1140+ props = IcebergProperties (properties = {"write.target-file-size-bytes" : "536870912" })
1141+ with pytest .raises (PermissionError , match = "glue:UpdateTable" ):
1142+ self .fg ._update_iceberg_properties (iceberg_properties = props )
1143+
1144+ def test_non_access_denied_client_error_raises_runtime_error (self ):
1145+ """Test RuntimeError for non-AccessDenied ClientError."""
1146+ mock_table = self ._setup_get_result ()
1147+ mock_table .transaction ().__enter__ ().set_properties .side_effect = self ._make_client_error ("InternalServiceException" )
1148+ self .fg ._has_lake_formation_config .return_value = False
1149+
1150+ props = IcebergProperties (properties = {"write.target-file-size-bytes" : "536870912" })
1151+ with pytest .raises (RuntimeError , match = "Failed to update Iceberg properties" ):
1152+ self .fg ._update_iceberg_properties (iceberg_properties = props )
1153+
1154+ def test_access_denied_permission_error_chains_original (self ):
1155+ """Test that PermissionError chains the original ClientError via __cause__."""
1156+ mock_table = self ._setup_get_result ()
1157+ original = self ._make_client_error ("AccessDeniedException" )
1158+ mock_table .transaction ().__enter__ ().set_properties .side_effect = original
1159+ self .fg ._has_lake_formation_config .return_value = True
1160+
1161+ props = IcebergProperties (properties = {"write.target-file-size-bytes" : "536870912" })
1162+ with pytest .raises (PermissionError ) as exc_info :
1163+ self .fg ._update_iceberg_properties (iceberg_properties = props )
1164+ assert exc_info .value .__cause__ is original
1165+
1166+ def test_access_denied_lf_message_mentions_alter (self ):
1167+ """Test that LF error message mentions ALTER permission."""
1168+ mock_table = self ._setup_get_result ()
1169+ mock_table .transaction ().__enter__ ().set_properties .side_effect = self ._make_client_error ("AccessDeniedException" )
1170+ self .fg ._has_lake_formation_config .return_value = True
1171+
1172+ props = IcebergProperties (properties = {"write.target-file-size-bytes" : "536870912" })
1173+ with pytest .raises (PermissionError , match = "ALTER permission" ):
1174+ self .fg ._update_iceberg_properties (iceberg_properties = props )
0 commit comments