Repository: getmoto/moto
Pull request #7619 (2024-04-22 17:31:15)

add DeletionProtectionEnabled to dynamoDB
Adds the DeletionProtectionEnabled parameter to DynamoDB.



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7619?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.29%. Comparing base [(`1988b91`)](https://app.codecov.io/gh/getmoto/moto/commit/1988b916ed64a7ef8cd59b25c8ad24a6aea824e8?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`e18e9db`)](https://app.codecov.io/gh/getmoto/moto/pull/7619?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #7619   +/-   ##
=======================================
  Coverage   94.29%   94.29%           
=======================================
  Files        1082     1082           
  Lines       92267    92284   +17     
=======================================
+ Hits        87004    87021   +17     
  Misses       5263     5263           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7619/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/7619/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `29.41% <23.52%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7619/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.26% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/7619?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/moto/dynamodb/exceptions.py b/moto/dynamodb/exceptions.py
index 869e22a49..bca8131fb 100644
--- a/moto/dynamodb/exceptions.py
+++ b/moto/dynamodb/exceptions.py
@@ -381,3 +381,9 @@ class UnknownKeyType(MockValidationException):
     def __init__(self, key_type: str, position: str):
         msg = f"1 validation error detected: Value '{key_type}' at '{position}' failed to satisfy constraint: Member must satisfy enum value set: [HASH, RANGE]"
         super().__init__(msg)
+
+
+class DeletionProtectedException(MockValidationException):
+    def __init__(self, table_name: str):
+        msg = f"1 validation error detected: Table '{table_name}' can't be deleted while DeletionProtectionEnabled is set to True"
+        super().__init__(msg)
diff --git a/moto/dynamodb/models/__init__.py b/moto/dynamodb/models/__init__.py
index c47b97797..c6634e195 100644
--- a/moto/dynamodb/models/__init__.py
+++ b/moto/dynamodb/models/__init__.py
@@ -10,6 +10,7 @@ from moto.dynamodb.comparisons import get_expected, get_filter_expression
 from moto.dynamodb.exceptions import (
     BackupNotFoundException,
     ConditionalCheckFailed,
+    DeletionProtectedException,
     ItemSizeTooLarge,
     ItemSizeToUpdateTooLarge,
     MockValidationException,
@@ -71,6 +72,10 @@ class DynamoDBBackend(BaseBackend):
     def delete_table(self, name: str) -> Table:
         if name not in self.tables:
             raise ResourceNotFoundException
+        table_for_deletion = self.tables.get(name)
+        if isinstance(table_for_deletion, Table):
+            if table_for_deletion.deletion_protection_enabled:
+                raise DeletionProtectedException(name)
         return self.tables.pop(name)
 
     def describe_endpoints(self) -> List[Dict[str, Union[int, str]]]:
@@ -137,6 +142,7 @@ class DynamoDBBackend(BaseBackend):
         throughput: Dict[str, Any],
         billing_mode: str,
         stream_spec: Dict[str, Any],
+        deletion_protection_enabled: bool,
     ) -> Table:
         table = self.get_table(name)
         if attr_definitions:
@@ -149,6 +155,10 @@ class DynamoDBBackend(BaseBackend):
             table = self.update_table_billing_mode(name, billing_mode)
         if stream_spec:
             table = self.update_table_streams(name, stream_spec)
+        if deletion_protection_enabled:
+            table = self.update_table_deletion_protection_enabled(
+                name, deletion_protection_enabled
+            )
         return table
 
     def update_table_throughput(self, name: str, throughput: Dict[str, int]) -> Table:
@@ -161,6 +171,13 @@ class DynamoDBBackend(BaseBackend):
         table.billing_mode = billing_mode
         return table
 
+    def update_table_deletion_protection_enabled(
+        self, name: str, deletion_protection_enabled: bool
+    ) -> Table:
+        table = self.tables[name]
+        table.deletion_protection_enabled = deletion_protection_enabled
+        return table
+
     def update_table_streams(
         self, name: str, stream_specification: Dict[str, Any]
     ) -> Table:
diff --git a/moto/dynamodb/models/table.py b/moto/dynamodb/models/table.py
index 371f482e1..43c55c666 100644
--- a/moto/dynamodb/models/table.py
+++ b/moto/dynamodb/models/table.py
@@ -243,6 +243,7 @@ class Table(CloudFormationModel):
         streams: Optional[Dict[str, Any]] = None,
         sse_specification: Optional[Dict[str, Any]] = None,
         tags: Optional[List[Dict[str, str]]] = None,
+        deletion_protection_enabled: Optional[bool] = False,
     ):
         self.name = table_name
         self.account_id = account_id
@@ -306,6 +307,7 @@ class Table(CloudFormationModel):
             self.sse_specification["KMSMasterKeyId"] = self._get_default_encryption_key(
                 account_id, region
             )
+        self.deletion_protection_enabled = deletion_protection_enabled
 
     def _get_default_encryption_key(self, account_id: str, region: str) -> str:
         from moto.kms import kms_backends
@@ -443,6 +445,7 @@ class Table(CloudFormationModel):
                     index.describe() for index in self.global_indexes
                 ],
                 "LocalSecondaryIndexes": [index.describe() for index in self.indexes],
+                "DeletionProtectionEnabled": self.deletion_protection_enabled,
             }
         }
         if self.latest_stream_label:
diff --git a/moto/dynamodb/responses.py b/moto/dynamodb/responses.py
index 24019ebe1..49131ae72 100644
--- a/moto/dynamodb/responses.py
+++ b/moto/dynamodb/responses.py
@@ -302,6 +302,7 @@ class DynamoHandler(BaseResponse):
         streams = body.get("StreamSpecification")
         # Get any tags
         tags = body.get("Tags", [])
+        deletion_protection_enabled = body.get("DeletionProtectionEnabled", False)
 
         table = self.dynamodb_backend.create_table(
             table_name,
@@ -314,6 +315,7 @@ class DynamoHandler(BaseResponse):
             billing_mode=billing_mode,
             sse_specification=sse_spec,
             tags=tags,
+            deletion_protection_enabled=deletion_protection_enabled,
         )
         return dynamo_json_dump(table.describe())
 
@@ -431,6 +433,7 @@ class DynamoHandler(BaseResponse):
         throughput = self.body.get("ProvisionedThroughput", None)
         billing_mode = self.body.get("BillingMode", None)
         stream_spec = self.body.get("StreamSpecification", None)
+        deletion_protection_enabled = self.body.get("DeletionProtectionEnabled")
         table = self.dynamodb_backend.update_table(
             name=name,
             attr_definitions=attr_definitions,
@@ -438,6 +441,7 @@ class DynamoHandler(BaseResponse):
             throughput=throughput,
             billing_mode=billing_mode,
             stream_spec=stream_spec,
+            deletion_protection_enabled=deletion_protection_enabled,
         )
         return dynamo_json_dump(table.describe())
 
diff --git a/moto/ec2/models/security_groups.py b/moto/ec2/models/security_groups.py
index 532313652..81760baf5 100644
--- a/moto/ec2/models/security_groups.py
+++ b/moto/ec2/models/security_groups.py
@@ -812,6 +812,9 @@ class SecurityGroupBackend:
             group_name_or_id, vpc_id
         )  # type: ignore[assignment]
 
+        if group is None:
+            raise InvalidSecurityGroupNotFoundError(group_name_or_id)
+
         if security_rule_ids:
             group.ingress_rules = [
                 rule for rule in group.ingress_rules if rule.id not in security_rule_ids
@@ -983,6 +986,9 @@ class SecurityGroupBackend:
             group_name_or_id, vpc_id
         )  # type: ignore[assignment]
 
+        if group is None:
+            raise InvalidSecurityGroupNotFoundError(group_name_or_id)
+
         if security_rule_ids:
             group.egress_rules = [
                 rule for rule in group.egress_rules if rule.id not in security_rule_ids


Test patch:
diff --git a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
index a8ecd2224..f39f1ca55 100644
--- a/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
+++ b/tests/test_dynamodb/exceptions/test_dynamodb_exceptions.py
@@ -1389,3 +1389,31 @@ def test_cannot_scan_gsi_with_consistent_read():
         "Code": "ValidationException",
         "Message": "Consistent reads are not supported on global secondary indexes",
     }
+
+
+@mock_aws
+def test_delete_table():
+    client = boto3.client("dynamodb", region_name="us-east-1")
+
+    # Create the DynamoDB table.
+    client.create_table(
+        TableName="test1",
+        AttributeDefinitions=[
+            {"AttributeName": "client", "AttributeType": "S"},
+            {"AttributeName": "app", "AttributeType": "S"},
+        ],
+        KeySchema=[
+            {"AttributeName": "client", "KeyType": "HASH"},
+            {"AttributeName": "app", "KeyType": "RANGE"},
+        ],
+        ProvisionedThroughput={"ReadCapacityUnits": 123, "WriteCapacityUnits": 123},
+        DeletionProtectionEnabled=True,
+    )
+
+    with pytest.raises(ClientError) as err:
+        client.delete_table(TableName="test1")
+    assert err.value.response["Error"]["Code"] == "ValidationException"
+    assert (
+        err.value.response["Error"]["Message"]
+        == "1 validation error detected: Table 'test1' can't be deleted while DeletionProtectionEnabled is set to True"
+    )
diff --git a/tests/test_dynamodb/test_dynamodb_create_table.py b/tests/test_dynamodb/test_dynamodb_create_table.py
index ed8b1a91c..07f9e7c9b 100644
--- a/tests/test_dynamodb/test_dynamodb_create_table.py
+++ b/tests/test_dynamodb/test_dynamodb_create_table.py
@@ -50,6 +50,7 @@ def test_create_table_standard():
         {"AttributeName": "subject", "KeyType": "RANGE"},
     ]
     assert actual["ItemCount"] == 0
+    assert not actual["DeletionProtectionEnabled"]
 
 
 @mock_aws
@@ -233,6 +234,22 @@ def test_create_table_with_tags():
     assert resp["Tags"] == [{"Key": "tk", "Value": "tv"}]
 
 
+@mock_aws
+def test_create_table_with_deletion_protection_enabled():
+    client = boto3.client("dynamodb", region_name="us-east-1")
+
+    client.create_table(
+        TableName="test-deletion_protection",
+        KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
+        AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
+        ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1},
+        DeletionProtectionEnabled=True,
+    )
+
+    actual = client.describe_table(TableName="test-deletion_protection")["Table"]
+    assert actual["DeletionProtectionEnabled"]
+
+
 @mock_aws
 def test_create_table_pay_per_request():
     client = boto3.client("dynamodb", region_name="us-east-1")
diff --git a/tests/test_dynamodb/test_dynamodb_update_table.py b/tests/test_dynamodb/test_dynamodb_update_table.py
index f73adcbda..a1f2ad819 100644
--- a/tests/test_dynamodb/test_dynamodb_update_table.py
+++ b/tests/test_dynamodb/test_dynamodb_update_table.py
@@ -53,6 +53,23 @@ def test_update_table_throughput():
     assert table.provisioned_throughput["WriteCapacityUnits"] == 6
 
 
+@mock_aws
+def test_update_table_deletion_protection_enabled():
+    conn = boto3.resource("dynamodb", region_name="us-west-2")
+    table = conn.create_table(
+        TableName="messages",
+        KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}],
+        AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "S"}],
+        BillingMode="PAY_PER_REQUEST",
+        DeletionProtectionEnabled=False,
+    )
+    assert not table.deletion_protection_enabled
+
+    table.update(DeletionProtectionEnabled=True)
+
+    assert table.deletion_protection_enabled
+
+
 @mock_aws
 def test_update_table__enable_stream():
     conn = boto3.client("dynamodb", region_name="us-east-1")
diff --git a/tests/test_ec2/test_security_groups.py b/tests/test_ec2/test_security_groups.py
index cbb9cb5ad..ed6127d42 100644
--- a/tests/test_ec2/test_security_groups.py
+++ b/tests/test_ec2/test_security_groups.py
@@ -304,6 +304,13 @@ def test_authorize_ip_range_and_revoke():
         {"CidrIp": "123.123.123.123/32"}
     ]
 
+    # Bad group must produce error
+    with pytest.raises(ClientError) as ex:
+        client.revoke_security_group_egress(GroupId="badcompany")
+    assert ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "RequestId" in ex.value.response["ResponseMetadata"]
+    assert ex.value.response["Error"]["Code"] == "InvalidGroup.NotFound"
+
     # Wrong Cidr should throw error
     with pytest.raises(ClientError) as ex:
         wrong_permissions = copy.deepcopy(egress_permissions)
@@ -380,6 +387,13 @@ def test_authorize_other_group_and_revoke():
     assert "RequestId" in ex.value.response["ResponseMetadata"]
     assert ex.value.response["Error"]["Code"] == "InvalidGroup.NotFound"
 
+    # Bad group must produce error
+    with pytest.raises(ClientError) as ex:
+        client.revoke_security_group_ingress(GroupId="badcompany")
+    assert ex.value.response["ResponseMetadata"]["HTTPStatusCode"] == 400
+    assert "RequestId" in ex.value.response["ResponseMetadata"]
+    assert ex.value.response["Error"]["Code"] == "InvalidGroup.NotFound"
+
     # Actually revoke
     security_group.revoke_ingress(IpPermissions=permissions)