Repository: getmoto/moto
Pull request #6522 (2023-07-14 11:09:00)

Techdebt: Replace sure with regular assertions in EBS
See #6035 


Patch:
diff --git a/moto/logs/models.py b/moto/logs/models.py
index f4a76433e..368a332e5 100644
--- a/moto/logs/models.py
+++ b/moto/logs/models.py
@@ -18,6 +18,34 @@ from .utils import PAGINATION_MODEL, EventMessageFilter
 MAX_RESOURCE_POLICIES_PER_REGION = 10
 
 
+class Destination(BaseModel):
+    def __init__(
+        self,
+        account_id: str,
+        region: str,
+        destination_name: str,
+        role_arn: str,
+        target_arn: str,
+        access_policy: Optional[str] = None,
+    ):
+        self.access_policy = access_policy
+        self.arn = f"arn:aws:logs:{region}:{account_id}:destination:{destination_name}"
+        self.creation_time = int(unix_time_millis())
+        self.destination_name = destination_name
+        self.role_arn = role_arn
+        self.target_arn = target_arn
+
+    def to_dict(self) -> Dict[str, Any]:
+        return {
+            "accessPolicy": self.access_policy,
+            "arn": self.arn,
+            "creationTime": self.creation_time,
+            "destinationName": self.destination_name,
+            "roleArn": self.role_arn,
+            "targetArn": self.target_arn,
+        }
+
+
 class LogQuery(BaseModel):
     def __init__(self, query_id: str, start_time: str, end_time: str, query: str):
         self.query_id = query_id
@@ -655,6 +683,7 @@ class LogsBackend(BaseBackend):
         self.filters = MetricFilters()
         self.queries: Dict[str, LogQuery] = dict()
         self.resource_policies: Dict[str, LogResourcePolicy] = dict()
+        self.destinations: Dict[str, Destination] = dict()
 
     @staticmethod
     def default_vpc_endpoint_service(
@@ -706,6 +735,60 @@ class LogsBackend(BaseBackend):
 
         return groups
 
+    def get_destination(self, destination_name: str) -> Destination:
+        for destination in self.destinations:
+            if self.destinations[destination].destination_name == destination_name:
+                return self.destinations[destination]
+        raise ResourceNotFoundException()
+
+    def put_destination(
+        self, destination_name: str, role_arn: str, target_arn: str
+    ) -> Destination:
+        for _, destination in self.destinations.items():
+            if destination.destination_name == destination_name:
+                if role_arn:
+                    destination.role_arn = role_arn
+                if target_arn:
+                    destination.target_arn = target_arn
+                return destination
+        destination = Destination(
+            self.account_id, self.region_name, destination_name, role_arn, target_arn
+        )
+        self.destinations[destination.arn] = destination
+        return destination
+
+    def delete_destination(self, destination_name: str) -> None:
+        destination = self.get_destination(destination_name)
+        self.destinations.pop(destination.arn)
+        return
+
+    def describe_destinations(
+        self, destination_name_prefix: str, limit: int, next_token: Optional[int] = None
+    ) -> Tuple[List[Dict[str, Any]], Optional[int]]:
+        if limit > 50:
+            raise InvalidParameterException(
+                constraint="Member must have value less than or equal to 50",
+                parameter="limit",
+                value=limit,
+            )
+
+        result = []
+        for destination in self.destinations:
+            result.append(self.destinations[destination].to_dict())
+        if next_token:
+            result = result[: int(next_token)]
+        result = [
+            destination
+            for destination in result
+            if destination["destinationName"].startswith(destination_name_prefix)
+        ]
+        return result, next_token
+
+    def put_destination_policy(self, destination_name: str, access_policy: str) -> None:
+        destination = self.get_destination(destination_name)
+        destination.access_policy = access_policy
+        return
+
     def create_log_stream(self, log_group_name: str, log_stream_name: str) -> None:
         if log_group_name not in self.groups:
             raise ResourceNotFoundException()
diff --git a/moto/logs/responses.py b/moto/logs/responses.py
index 62e9f5c55..836166e83 100644
--- a/moto/logs/responses.py
+++ b/moto/logs/responses.py
@@ -183,6 +183,41 @@ class LogsResponse(BaseResponse):
             result["nextToken"] = next_token
         return json.dumps(result)
 
+    def put_destination(self) -> str:
+        destination_name = self._get_param("destinationName")
+        role_arn = self._get_param("roleArn")
+        target_arn = self._get_param("targetArn")
+
+        destination = self.logs_backend.put_destination(
+            destination_name, role_arn, target_arn
+        )
+        result = {"destination": destination.to_dict()}
+        return json.dumps(result)
+
+    def delete_destination(self) -> str:
+        destination_name = self._get_param("destinationName")
+        self.logs_backend.delete_destination(destination_name)
+        return ""
+
+    def describe_destinations(self) -> str:
+        destination_name_prefix = self._get_param("DestinationNamePrefix")
+        limit = self._get_param("limit", 50)
+        next_token = self._get_param("nextToken")
+
+        destinations, next_token = self.logs_backend.describe_destinations(
+            destination_name_prefix, int(limit), next_token
+        )
+
+        result = {"destinations": destinations, "nextToken": next_token}
+        return json.dumps(result)
+
+    def put_destination_policy(self) -> str:
+        access_policy = self._get_param("accessPolicy")
+        destination_name = self._get_param("destinationName")
+
+        self.logs_backend.put_destination_policy(destination_name, access_policy)
+        return ""
+
     def create_log_stream(self) -> str:
         log_group_name = self._get_param("logGroupName")
         log_stream_name = self._get_param("logStreamName")
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index d9dc5ad77..cf1044953 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -1923,7 +1923,7 @@ class S3Response(BaseResponse):
 
         tags = {}
         for tag in parsed_xml["Tagging"]["TagSet"]["Tag"]:
-            tags[tag["Key"]] = tag["Value"]
+            tags[tag["Key"]] = tag["Value"] or ""
 
         return tags
 


Test patch:
diff --git a/tests/test_ebs/test_ebs.py b/tests/test_ebs/test_ebs.py
index f4a19f291..561811a77 100644
--- a/tests/test_ebs/test_ebs.py
+++ b/tests/test_ebs/test_ebs.py
@@ -1,7 +1,6 @@
 """Unit tests for ebs-supported APIs."""
 import boto3
 import hashlib
-import sure  # noqa # pylint: disable=unused-import
 from moto import mock_ebs, mock_ec2
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
@@ -14,12 +13,12 @@ def test_start_snapshot__minimal():
     client = boto3.client("ebs", region_name="eu-west-1")
     resp = client.start_snapshot(VolumeSize=720)
 
-    resp.should.have.key("SnapshotId")
-    resp.should.have.key("OwnerId").equals(ACCOUNT_ID)
-    resp.should.have.key("Status").equals("pending")
-    resp.should.have.key("StartTime")
-    resp.should.have.key("VolumeSize").equals(720)
-    resp.should.have.key("BlockSize").equals(512)
+    assert "SnapshotId" in resp
+    assert resp["OwnerId"] == ACCOUNT_ID
+    assert resp["Status"] == "pending"
+    assert "StartTime" in resp
+    assert resp["VolumeSize"] == 720
+    assert resp["BlockSize"] == 512
 
 
 @mock_ebs
@@ -31,14 +30,14 @@ def test_start_snapshot():
         Description="my fancy snapshot",
     )
 
-    resp.should.have.key("SnapshotId")
-    resp.should.have.key("OwnerId").equals(ACCOUNT_ID)
-    resp.should.have.key("Status").equals("pending")
-    resp.should.have.key("StartTime")
-    resp.should.have.key("VolumeSize").equals(120)
-    resp.should.have.key("BlockSize").equals(512)
-    resp.should.have.key("Tags").equals([{"Key": "kt", "Value": "vt"}])
-    resp.should.have.key("Description").equals("my fancy snapshot")
+    assert "SnapshotId" in resp
+    assert resp["OwnerId"] == ACCOUNT_ID
+    assert resp["Status"] == "pending"
+    assert "StartTime" in resp
+    assert resp["VolumeSize"] == 120
+    assert resp["BlockSize"] == 512
+    assert resp["Tags"] == [{"Key": "kt", "Value": "vt"}]
+    assert resp["Description"] == "my fancy snapshot"
 
 
 @mock_ebs
@@ -47,7 +46,7 @@ def test_complete_snapshot():
     snapshot_id = client.start_snapshot(VolumeSize=720)["SnapshotId"]
 
     resp = client.complete_snapshot(SnapshotId=snapshot_id, ChangedBlocksCount=0)
-    resp.should.have.key("Status").equals("completed")
+    assert resp["Status"] == "completed"
 
 
 @mock_ebs
@@ -65,8 +64,8 @@ def test_put_snapshot_block():
         ChecksumAlgorithm="SHA256",
     )
 
-    resp.should.have.key("Checksum").equals(checksum)
-    resp.should.have.key("ChecksumAlgorithm").equals("SHA256")
+    assert resp["Checksum"] == checksum
+    assert resp["ChecksumAlgorithm"] == "SHA256"
 
 
 @mock_ebs
@@ -88,11 +87,11 @@ def test_get_snapshot_block():
         SnapshotId=snapshot_id, BlockIndex=2, BlockToken="n/a"
     )
 
-    resp.should.have.key("DataLength").equals(524288)
-    resp.should.have.key("BlockData")
-    resp["BlockData"].read().should.equal(b"data 2")
-    resp.should.have.key("Checksum")
-    resp.should.have.key("ChecksumAlgorithm").equals("SHA256")
+    assert resp["DataLength"] == 524288
+    assert "BlockData" in resp
+    assert resp["BlockData"].read() == b"data 2"
+    assert "Checksum" in resp
+    assert resp["ChecksumAlgorithm"] == "SHA256"
 
 
 @mock_ebs
@@ -125,13 +124,13 @@ def test_list_changed_blocks():
     )
     changed_blocks = resp["ChangedBlocks"]
     changed_idxes = [b["BlockIndex"] for b in changed_blocks]
-    changed_idxes.should.equal([1, 3])
+    assert changed_idxes == [1, 3]
 
-    changed_blocks[0].should.have.key("FirstBlockToken")
-    changed_blocks[0].should.have.key("SecondBlockToken")
+    assert "FirstBlockToken" in changed_blocks[0]
+    assert "SecondBlockToken" in changed_blocks[0]
 
-    changed_blocks[1].should.have.key("FirstBlockToken")
-    changed_blocks[1].shouldnt.have.key("SecondBlockToken")
+    assert "FirstBlockToken" in changed_blocks[1]
+    assert "SecondBlockToken" not in changed_blocks[1]
 
 
 @mock_ebs
@@ -151,11 +150,11 @@ def test_list_snapshot_blocks():
 
     resp = client.list_snapshot_blocks(SnapshotId=snapshot_id)
 
-    resp.should.have.key("VolumeSize").equals(415)
-    resp.should.have.key("BlockSize").equals(512)
-    resp.should.have.key("Blocks").length_of(3)
+    assert resp["VolumeSize"] == 415
+    assert resp["BlockSize"] == 512
+    assert len(resp["Blocks"]) == 3
 
-    [b["BlockIndex"] for b in resp["Blocks"]].should.equal([1, 2, 3])
+    assert [b["BlockIndex"] for b in resp["Blocks"]] == [1, 2, 3]
 
 
 @mock_ebs
@@ -165,6 +164,6 @@ def test_start_snapshot__should_be_created_in_ec2():
     ec2 = boto3.client("ec2", region_name="eu-north-1")
     snapshot_id = ebs.start_snapshot(VolumeSize=720)["SnapshotId"]
     resp = ec2.describe_snapshots(SnapshotIds=[snapshot_id])["Snapshots"]
-    resp.should.have.length_of(1)
+    assert len(resp) == 1
 
-    resp[0].should.have.key("VolumeSize").equals(720)
+    assert resp[0]["VolumeSize"] == 720
diff --git a/tests/test_logs/test_logs.py b/tests/test_logs/test_logs.py
index 6b8bc6afb..851081196 100644
--- a/tests/test_logs/test_logs.py
+++ b/tests/test_logs/test_logs.py
@@ -35,6 +35,20 @@ json_policy_doc = json.dumps(
     }
 )
 
+access_policy_doc = json.dumps(
+    {
+        "Version": "2012-10-17",
+        "Statement": [
+            {
+                "Effect": "Allow",
+                "Principal": {"AWS": "logs.us-east-1.amazonaws.com"},
+                "Action": "logs:PutSubscriptionFilter",
+                "Resource": "destination_arn",
+            }
+        ],
+    }
+)
+
 
 @mock_logs
 def test_describe_metric_filters_happy_prefix():
@@ -255,6 +269,82 @@ def test_delete_metric_filter_invalid_log_group_name(
     response["Error"]["Message"].should.contain(failing_constraint)
 
 
+@mock_logs
+def test_destinations():
+    conn = boto3.client("logs", "us-west-2")
+    destination_name = "test-destination"
+    role_arn = "arn:aws:iam::123456789012:role/my-subscription-role"
+    target_arn = "arn:aws:kinesis:us-east-1:123456789012:stream/my-kinesis-stream"
+    role_arn_updated = "arn:aws:iam::123456789012:role/my-subscription-role-updated"
+    target_arn_updated = (
+        "arn:aws:kinesis:us-east-1:123456789012:stream/my-kinesis-stream-updated"
+    )
+
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert len(response["destinations"]) == 0
+
+    response = conn.put_destination(
+        destinationName=destination_name,
+        targetArn=target_arn,
+        roleArn=role_arn,
+        tags={"Name": destination_name},
+    )
+    assert response["destination"]["destinationName"] == destination_name
+    assert response["destination"]["targetArn"] == target_arn
+    assert response["destination"]["roleArn"] == role_arn
+
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert len(response["destinations"]) == 1
+    assert response["destinations"][0]["destinationName"] == destination_name
+    assert response["destinations"][0]["targetArn"] == target_arn
+    assert response["destinations"][0]["roleArn"] == role_arn
+
+    response = conn.put_destination(
+        destinationName=destination_name,
+        targetArn=target_arn_updated,
+        roleArn=role_arn_updated,
+        tags={"Name": destination_name},
+    )
+    assert response["destination"]["destinationName"] == destination_name
+    assert response["destination"]["targetArn"] == target_arn_updated
+    assert response["destination"]["roleArn"] == role_arn_updated
+
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert len(response["destinations"]) == 1
+    assert response["destinations"][0]["destinationName"] == destination_name
+    assert response["destinations"][0]["targetArn"] == target_arn_updated
+    assert response["destinations"][0]["roleArn"] == role_arn_updated
+
+    response = conn.put_destination_policy(
+        destinationName=destination_name, accessPolicy=access_policy_doc
+    )
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert response["destinations"][0]["accessPolicy"] == access_policy_doc
+
+    conn.put_destination(
+        destinationName=f"{destination_name}-1",
+        targetArn=target_arn,
+        roleArn=role_arn,
+        tags={"Name": destination_name},
+    )
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert len(response["destinations"]) == 2
+
+    response = conn.describe_destinations(DestinationNamePrefix=f"{destination_name}-1")
+    assert len(response["destinations"]) == 1
+
+    response = conn.delete_destination(destinationName=f"{destination_name}-1")
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+
+    response = conn.describe_destinations(DestinationNamePrefix=destination_name)
+    assert len(response["destinations"]) == 1
+
+    response = conn.delete_destination(destinationName=destination_name)
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+
+
 def put_metric_filter(conn, count=1):
     count = str(count)
     return conn.put_metric_filter(
diff --git a/tests/test_s3/test_s3_tagging.py b/tests/test_s3/test_s3_tagging.py
index f6e6236bd..dc85e41b1 100644
--- a/tests/test_s3/test_s3_tagging.py
+++ b/tests/test_s3/test_s3_tagging.py
@@ -69,6 +69,7 @@ def test_put_bucket_tagging():
             "TagSet": [
                 {"Key": "TagOne", "Value": "ValueOne"},
                 {"Key": "TagTwo", "Value": "ValueTwo"},
+                {"Key": "TagThree", "Value": ""},
             ]
         },
     )
@@ -222,6 +223,7 @@ def test_put_object_tagging():
             "TagSet": [
                 {"Key": "item1", "Value": "foo"},
                 {"Key": "item2", "Value": "bar"},
+                {"Key": "item3", "Value": ""},
             ]
         },
     )