From 8d527743d5010feb70adcfe1f5b6c7d50bfa66e5 Mon Sep 17 00:00:00 2001 From: gruebel Date: Fri, 11 Oct 2019 17:58:48 +0200 Subject: [PATCH 1/3] Add sns.list_tags_for_resource --- IMPLEMENTATION_COVERAGE.md | 4 ++-- moto/sns/models.py | 9 +++++++- moto/sns/responses.py | 35 ++++++++++++++++++++++++++++- tests/test_sns/test_topics_boto3.py | 31 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 764df13d..62565a06 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -5866,7 +5866,7 @@ - [ ] update_job ## sns -48% implemented +52% implemented - [ ] add_permission - [ ] check_if_phone_number_is_opted_out - [ ] confirm_subscription @@ -5886,7 +5886,7 @@ - [X] list_platform_applications - [X] list_subscriptions - [ ] list_subscriptions_by_topic -- [ ] list_tags_for_resource +- [x] list_tags_for_resource - [X] list_topics - [ ] opt_in_phone_number - [X] publish diff --git a/moto/sns/models.py b/moto/sns/models.py index 92e6c61d..d7f15338 100644 --- a/moto/sns/models.py +++ b/moto/sns/models.py @@ -44,6 +44,8 @@ class Topic(BaseModel): self.subscriptions_confimed = 0 self.subscriptions_deleted = 0 + self._tags = {} + def publish(self, message, subject=None, message_attributes=None): message_id = six.text_type(uuid.uuid4()) subscriptions, _ = self.sns_backend.list_subscriptions(self.arn) @@ -277,7 +279,7 @@ class SNSBackend(BaseBackend): def update_sms_attributes(self, attrs): self.sms_attributes.update(attrs) - def create_topic(self, name, attributes=None): + def create_topic(self, name, attributes=None, tags=None): fails_constraints = not re.match(r'^[a-zA-Z0-9_-]{1,256}$', name) if fails_constraints: raise InvalidParameterValue("Topic names must be made up of only uppercase and lowercase ASCII letters, numbers, underscores, and hyphens, and must be between 1 and 256 characters long.") @@ -285,6 +287,8 @@ class SNSBackend(BaseBackend): if attributes: for attribute in attributes: setattr(candidate_topic, camelcase_to_underscores(attribute), attributes[attribute]) + if tags: + candidate_topic._tags = tags if candidate_topic.arn in self.topics: return self.topics[candidate_topic.arn] else: @@ -499,6 +503,9 @@ class SNSBackend(BaseBackend): raise SNSInvalidParameter("Invalid parameter: FilterPolicy: Match value must be String, number, true, false, or null") + def list_tags_for_resource(self, resource_arn): + return self.topics[resource_arn]._tags + sns_backends = {} for region in Session().get_available_regions('sns'): diff --git a/moto/sns/responses.py b/moto/sns/responses.py index 578c5ea6..7cc085be 100644 --- a/moto/sns/responses.py +++ b/moto/sns/responses.py @@ -30,6 +30,10 @@ class SNSResponse(BaseResponse): in attributes ) + def _get_tags(self): + tags = self._get_list_prefix('Tags.member') + return {tag['key']: tag['value'] for tag in tags} + def _parse_message_attributes(self, prefix='', value_namespace='Value.'): message_attributes = self._get_object_map( 'MessageAttributes.entry', @@ -85,7 +89,8 @@ class SNSResponse(BaseResponse): def create_topic(self): name = self._get_param('Name') attributes = self._get_attributes() - topic = self.backend.create_topic(name, attributes) + tags = self._get_tags() + topic = self.backend.create_topic(name, attributes, tags) if self.request_json: return json.dumps({ @@ -691,6 +696,18 @@ class SNSResponse(BaseResponse): template = self.response_template(CONFIRM_SUBSCRIPTION_TEMPLATE) return template.render(sub_arn='{0}:68762e72-e9b1-410a-8b3b-903da69ee1d5'.format(arn)) + def list_tags_for_resource(self): + arn = self._get_param('ResourceArn') + + if arn not in self.backend.topics: + error_response = self._error('ResourceNotFound', 'Resource does not exist') + return error_response, dict(status=404) + + result = self.backend.list_tags_for_resource(arn) + + template = self.response_template(LIST_TAGS_FOR_RESOURCE_TEMPLATE) + return template.render(tags=result) + CREATE_TOPIC_TEMPLATE = """ @@ -1072,3 +1089,19 @@ CONFIRM_SUBSCRIPTION_TEMPLATE = """ + + + {% for name, value in tags.items() %} + + {{ name }} + {{ value }} + + {% endfor %} + + + + 97fa763f-861b-5223-a946-20251f2a42e2 + +""" diff --git a/tests/test_sns/test_topics_boto3.py b/tests/test_sns/test_topics_boto3.py index 870fa6f6..4a994e72 100644 --- a/tests/test_sns/test_topics_boto3.py +++ b/tests/test_sns/test_topics_boto3.py @@ -44,6 +44,37 @@ def test_create_topic_with_attributes(): attributes['DisplayName'].should.equal('test-topic') +@mock_sns +def test_create_topic_with_tags(): + conn = boto3.client("sns", region_name="us-east-1") + conn.create_topic( + Name='some-topic-with-attribute', + Tags=[ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + }, + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ] + ) + topics_json = conn.list_topics() + topic_arn = topics_json["Topics"][0]['TopicArn'] + + conn.list_tags_for_resource(ResourceArn=topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + }, + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ]) + + @mock_sns def test_create_topic_should_be_indempodent(): conn = boto3.client("sns", region_name="us-east-1") From 726775678cadc53f7340fa69e8e7c4effefa39df Mon Sep 17 00:00:00 2001 From: gruebel Date: Sat, 12 Oct 2019 20:36:15 +0200 Subject: [PATCH 2/3] Add sns.tag_resource --- IMPLEMENTATION_COVERAGE.md | 4 +- moto/sns/exceptions.py | 16 ++++ moto/sns/models.py | 17 +++- moto/sns/responses.py | 19 +++- tests/test_sns/test_topics_boto3.py | 138 +++++++++++++++++++++++++++- 5 files changed, 183 insertions(+), 11 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 62565a06..972031ad 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -5866,7 +5866,7 @@ - [ ] update_job ## sns -52% implemented +55% implemented - [ ] add_permission - [ ] check_if_phone_number_is_opted_out - [ ] confirm_subscription @@ -5897,7 +5897,7 @@ - [X] set_subscription_attributes - [ ] set_topic_attributes - [X] subscribe -- [ ] tag_resource +- [x] tag_resource - [X] unsubscribe - [ ] untag_resource diff --git a/moto/sns/exceptions.py b/moto/sns/exceptions.py index 706b3b5c..6d29e7ac 100644 --- a/moto/sns/exceptions.py +++ b/moto/sns/exceptions.py @@ -10,6 +10,14 @@ class SNSNotFoundError(RESTError): "NotFound", message) +class ResourceNotFoundError(RESTError): + code = 404 + + def __init__(self): + super(ResourceNotFoundError, self).__init__( + 'ResourceNotFound', 'Resource does not exist') + + class DuplicateSnsEndpointError(RESTError): code = 400 @@ -42,6 +50,14 @@ class InvalidParameterValue(RESTError): "InvalidParameterValue", message) +class TagLimitExceededError(RESTError): + code = 400 + + def __init__(self): + super(TagLimitExceededError, self).__init__( + 'TagLimitExceeded', 'Could not complete request: tag quota of per resource exceeded') + + class InternalError(RESTError): code = 500 diff --git a/moto/sns/models.py b/moto/sns/models.py index d7f15338..55b243e4 100644 --- a/moto/sns/models.py +++ b/moto/sns/models.py @@ -18,7 +18,7 @@ from moto.awslambda import lambda_backends from .exceptions import ( SNSNotFoundError, DuplicateSnsEndpointError, SnsEndpointDisabled, SNSInvalidParameter, - InvalidParameterValue, InternalError + InvalidParameterValue, InternalError, ResourceNotFoundError, TagLimitExceededError ) from .utils import make_arn_for_topic, make_arn_for_subscription @@ -504,8 +504,23 @@ class SNSBackend(BaseBackend): raise SNSInvalidParameter("Invalid parameter: FilterPolicy: Match value must be String, number, true, false, or null") def list_tags_for_resource(self, resource_arn): + if resource_arn not in self.topics: + raise ResourceNotFoundError + return self.topics[resource_arn]._tags + def tag_resource(self, resource_arn, tags): + if resource_arn not in self.topics: + raise ResourceNotFoundError + + updated_tags = self.topics[resource_arn]._tags.copy() + updated_tags.update(tags) + + if len(updated_tags) > 50: + raise TagLimitExceededError + + self.topics[resource_arn]._tags = updated_tags + sns_backends = {} for region in Session().get_available_regions('sns'): diff --git a/moto/sns/responses.py b/moto/sns/responses.py index 7cc085be..3b8383d1 100644 --- a/moto/sns/responses.py +++ b/moto/sns/responses.py @@ -699,15 +699,19 @@ class SNSResponse(BaseResponse): def list_tags_for_resource(self): arn = self._get_param('ResourceArn') - if arn not in self.backend.topics: - error_response = self._error('ResourceNotFound', 'Resource does not exist') - return error_response, dict(status=404) - result = self.backend.list_tags_for_resource(arn) template = self.response_template(LIST_TAGS_FOR_RESOURCE_TEMPLATE) return template.render(tags=result) + def tag_resource(self): + arn = self._get_param('ResourceArn') + tags = self._get_tags() + + self.backend.tag_resource(arn, tags) + + return self.response_template(TAG_RESOURCE_TEMPLATE).render() + CREATE_TOPIC_TEMPLATE = """ @@ -1105,3 +1109,10 @@ LIST_TAGS_FOR_RESOURCE_TEMPLATE = """ + + + fd4ab1da-692f-50a7-95ad-e7c665877d98 + +""" diff --git a/tests/test_sns/test_topics_boto3.py b/tests/test_sns/test_topics_boto3.py index 4a994e72..ace825af 100644 --- a/tests/test_sns/test_topics_boto3.py +++ b/tests/test_sns/test_topics_boto3.py @@ -47,8 +47,8 @@ def test_create_topic_with_attributes(): @mock_sns def test_create_topic_with_tags(): conn = boto3.client("sns", region_name="us-east-1") - conn.create_topic( - Name='some-topic-with-attribute', + response = conn.create_topic( + Name='some-topic-with-tags', Tags=[ { 'Key': 'tag_key_1', @@ -60,8 +60,7 @@ def test_create_topic_with_tags(): } ] ) - topics_json = conn.list_topics() - topic_arn = topics_json["Topics"][0]['TopicArn'] + topic_arn = response['TopicArn'] conn.list_tags_for_resource(ResourceArn=topic_arn)['Tags'].should.equal([ { @@ -231,3 +230,134 @@ def test_add_remove_permissions(): TopicArn=response['TopicArn'], Label='Test1234' ) + + +@mock_sns +def test_tag_topic(): + conn = boto3.client('sns', region_name='us-east-1') + response = conn.create_topic( + Name = 'some-topic-with-tags' + ) + topic_arn = response['TopicArn'] + + conn.tag_resource( + ResourceArn=topic_arn, + Tags=[ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + } + ] + ) + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + } + ]) + + conn.tag_resource( + ResourceArn=topic_arn, + Tags=[ + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ] + ) + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + }, + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ]) + + conn.tag_resource( + ResourceArn = topic_arn, + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + } + ] + ) + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + }, + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ]) + + +@mock_sns +def test_list_tags_for_resource_error(): + conn = boto3.client('sns', region_name = 'us-east-1') + conn.create_topic( + Name = 'some-topic-with-tags', + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + } + ] + ) + + conn.list_tags_for_resource.when.called_with( + ResourceArn = 'not-existing-topic' + ).should.throw( + ClientError, + 'Resource does not exist' + ) + + +@mock_sns +def test_tag_resource_errors(): + conn = boto3.client('sns', region_name = 'us-east-1') + response = conn.create_topic( + Name = 'some-topic-with-tags', + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + } + ] + ) + topic_arn = response['TopicArn'] + + conn.tag_resource.when.called_with( + ResourceArn = 'not-existing-topic', + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + } + ] + ).should.throw( + ClientError, + 'Resource does not exist' + ) + + too_many_tags = [{'Key': 'tag_key_{}'.format(i), 'Value': 'tag_value_{}'.format(i)} for i in range(51)] + conn.tag_resource.when.called_with( + ResourceArn = topic_arn, + Tags = too_many_tags + ).should.throw( + ClientError, + 'Could not complete request: tag quota of per resource exceeded' + ) + + # when the request fails, the tags should not be updated + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + } + ]) From 26ef792690243c026c71a19e7d70c74e9c44276b Mon Sep 17 00:00:00 2001 From: gruebel Date: Sat, 12 Oct 2019 21:10:51 +0200 Subject: [PATCH 3/3] Add sns.untag_resource --- IMPLEMENTATION_COVERAGE.md | 4 +- moto/sns/models.py | 7 +++ moto/sns/responses.py | 15 +++++++ tests/test_sns/test_topics_boto3.py | 70 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 2 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 972031ad..57f169b8 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -5866,7 +5866,7 @@ - [ ] update_job ## sns -55% implemented +58% implemented - [ ] add_permission - [ ] check_if_phone_number_is_opted_out - [ ] confirm_subscription @@ -5899,7 +5899,7 @@ - [X] subscribe - [x] tag_resource - [X] unsubscribe -- [ ] untag_resource +- [x] untag_resource ## sqs 65% implemented diff --git a/moto/sns/models.py b/moto/sns/models.py index 55b243e4..51b5c2b2 100644 --- a/moto/sns/models.py +++ b/moto/sns/models.py @@ -521,6 +521,13 @@ class SNSBackend(BaseBackend): self.topics[resource_arn]._tags = updated_tags + def untag_resource(self, resource_arn, tag_keys): + if resource_arn not in self.topics: + raise ResourceNotFoundError + + for key in tag_keys: + self.topics[resource_arn]._tags.pop(key, None) + sns_backends = {} for region in Session().get_available_regions('sns'): diff --git a/moto/sns/responses.py b/moto/sns/responses.py index 3b8383d1..c315e2a8 100644 --- a/moto/sns/responses.py +++ b/moto/sns/responses.py @@ -712,6 +712,14 @@ class SNSResponse(BaseResponse): return self.response_template(TAG_RESOURCE_TEMPLATE).render() + def untag_resource(self): + arn = self._get_param('ResourceArn') + tag_keys = self._get_multi_param('TagKeys.member') + + self.backend.untag_resource(arn, tag_keys) + + return self.response_template(UNTAG_RESOURCE_TEMPLATE).render() + CREATE_TOPIC_TEMPLATE = """ @@ -1116,3 +1124,10 @@ TAG_RESOURCE_TEMPLATE = """ + + + 14eb7b1a-4cbd-5a56-80db-2d06412df769 + +""" diff --git a/tests/test_sns/test_topics_boto3.py b/tests/test_sns/test_topics_boto3.py index ace825af..05c8f74b 100644 --- a/tests/test_sns/test_topics_boto3.py +++ b/tests/test_sns/test_topics_boto3.py @@ -297,6 +297,52 @@ def test_tag_topic(): ]) +@mock_sns +def test_untag_topic(): + conn = boto3.client('sns', region_name = 'us-east-1') + response = conn.create_topic( + Name = 'some-topic-with-tags', + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_1' + }, + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ] + ) + topic_arn = response['TopicArn'] + + conn.untag_resource( + ResourceArn = topic_arn, + TagKeys = [ + 'tag_key_1' + ] + ) + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ]) + + # removing a non existing tag should not raise any error + conn.untag_resource( + ResourceArn = topic_arn, + TagKeys = [ + 'not-existing-tag' + ] + ) + conn.list_tags_for_resource(ResourceArn = topic_arn)['Tags'].should.equal([ + { + 'Key': 'tag_key_2', + 'Value': 'tag_value_2' + } + ]) + + @mock_sns def test_list_tags_for_resource_error(): conn = boto3.client('sns', region_name = 'us-east-1') @@ -361,3 +407,27 @@ def test_tag_resource_errors(): 'Value': 'tag_value_X' } ]) + + +@mock_sns +def test_untag_resource_error(): + conn = boto3.client('sns', region_name = 'us-east-1') + conn.create_topic( + Name = 'some-topic-with-tags', + Tags = [ + { + 'Key': 'tag_key_1', + 'Value': 'tag_value_X' + } + ] + ) + + conn.untag_resource.when.called_with( + ResourceArn = 'not-existing-topic', + TagKeys = [ + 'tag_key_1' + ] + ).should.throw( + ClientError, + 'Resource does not exist' + )