diff --git a/moto/__init__.py b/moto/__init__.py index e343c317f..8aa959cff 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -12,6 +12,7 @@ from .iam import mock_iam from .s3 import mock_s3 from .s3bucket_path import mock_s3bucket_path from .ses import mock_ses +from .sns import mock_sns from .sqs import mock_sqs from .sts import mock_sts from .route53 import mock_route53 diff --git a/moto/sns/__init__.py b/moto/sns/__init__.py new file mode 100644 index 000000000..bc7150ad4 --- /dev/null +++ b/moto/sns/__init__.py @@ -0,0 +1,2 @@ +from .models import sns_backend +mock_sns = sns_backend.decorator diff --git a/moto/sns/models.py b/moto/sns/models.py new file mode 100644 index 000000000..1435da8ed --- /dev/null +++ b/moto/sns/models.py @@ -0,0 +1,155 @@ +import datetime +import requests +import uuid + +from moto.core import BaseBackend +from moto.core.utils import iso_8601_datetime +from moto.sqs.models import sqs_backend +from .utils import make_arn_for_topic, make_arn_for_subscription + +DEFAULT_ACCOUNT_ID = 123456789012 + + +class Topic(object): + def __init__(self, name): + self.name = name + self.account_id = DEFAULT_ACCOUNT_ID + self.display_name = "" + self.policy = DEFAULT_TOPIC_POLICY + self.delivery_policy = "" + self.effective_delivery_policy = DEFAULT_EFFECTIVE_DELIVERY_POLICY + self.arn = make_arn_for_topic(self.account_id, name) + + self.subscriptions_pending = 0 + self.subscriptions_confimed = 0 + self.subscriptions_deleted = 0 + + def publish(self, message): + message_id = unicode(uuid.uuid4()) + subscriptions = sns_backend.list_subscriptions(self.arn) + for subscription in subscriptions: + subscription.publish(message, message_id) + return message_id + + +class Subscription(object): + def __init__(self, topic, endpoint, protocol): + self.topic = topic + self.endpoint = endpoint + self.protocol = protocol + self.arn = make_arn_for_subscription(self.topic.arn) + + def publish(self, message, message_id): + if self.protocol == 'sqs': + queue_name = self.endpoint.split(":")[-1] + sqs_backend.send_message(queue_name, message) + elif self.protocol in ['http', 'https']: + post_data = self.get_post_data(message, message_id) + requests.post(self.endpoint, data=post_data) + + def get_post_data(self, message, message_id): + return { + "Type": "Notification", + "MessageId": message_id, + "TopicArn": self.topic.arn, + "Subject": "my subject", + "Message": message, + "Timestamp": iso_8601_datetime(datetime.datetime.now()), + "SignatureVersion": "1", + "Signature": "EXAMPLElDMXvB8r9R83tGoNn0ecwd5UjllzsvSvbItzfaMpN2nk5HVSw7XnOn/49IkxDKz8YrlH2qJXj2iZB0Zo2O71c4qQk1fMUDi3LGpij7RCW7AW9vYYsSqIKRnFS94ilu7NFhUzLiieYr4BKHpdTmdD6c0esKEYBpabxDSc=", + "SigningCertURL": "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem", + "UnsubscribeURL": "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456789012:some-topic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55" + } + + +class SNSBackend(BaseBackend): + def __init__(self): + self.topics = {} + self.subscriptions = {} + + def create_topic(self, name): + topic = Topic(name) + self.topics[topic.arn] = topic + return topic + + def list_topics(self): + return self.topics.values() + + def delete_topic(self, arn): + self.topics.pop(arn) + + def get_topic(self, arn): + return self.topics[arn] + + def set_topic_attribute(self, topic_arn, attribute_name, attribute_value): + topic = self.get_topic(topic_arn) + setattr(topic, attribute_name, attribute_value) + + def subscribe(self, topic_arn, endpoint, protocol): + topic = self.get_topic(topic_arn) + subscription = Subscription(topic, endpoint, protocol) + self.subscriptions[subscription.arn] = subscription + return subscription + + def unsubscribe(self, subscription_arn): + self.subscriptions.pop(subscription_arn) + + def list_subscriptions(self, topic_arn=None): + if topic_arn: + topic = self.get_topic(topic_arn) + return [sub for sub in self.subscriptions.values() if sub.topic == topic] + else: + return self.subscriptions.values() + + def publish(self, topic_arn, message): + topic = self.get_topic(topic_arn) + message_id = topic.publish(message) + return message_id + + +sns_backend = SNSBackend() + + +DEFAULT_TOPIC_POLICY = { + "Version": "2008-10-17", + "Id": "us-east-1/698519295917/test__default_policy_ID", + "Statement": [{ + "Effect": "Allow", + "Sid": "us-east-1/698519295917/test__default_statement_ID", + "Principal": { + "AWS": "*" + }, + "Action": [ + "SNS:GetTopicAttributes", + "SNS:SetTopicAttributes", + "SNS:AddPermission", + "SNS:RemovePermission", + "SNS:DeleteTopic", + "SNS:Subscribe", + "SNS:ListSubscriptionsByTopic", + "SNS:Publish", + "SNS:Receive", + ], + "Resource": "arn:aws:sns:us-east-1:698519295917:test", + "Condition": { + "StringLike": { + "AWS:SourceArn": "arn:aws:*:*:698519295917:*" + } + } + }] +} + +DEFAULT_EFFECTIVE_DELIVERY_POLICY = { + 'http': { + 'disableSubscriptionOverrides': False, + 'defaultHealthyRetryPolicy': { + 'numNoDelayRetries': 0, + 'numMinDelayRetries': 0, + 'minDelayTarget': 20, + 'maxDelayTarget': 20, + 'numMaxDelayRetries': 0, + 'numRetries': 3, + 'backoffFunction': 'linear' + } + } +} diff --git a/moto/sns/responses.py b/moto/sns/responses.py new file mode 100644 index 000000000..5fb7a0953 --- /dev/null +++ b/moto/sns/responses.py @@ -0,0 +1,175 @@ +import json + +from moto.core.responses import BaseResponse +from moto.core.utils import camelcase_to_underscores +from .models import sns_backend + + +class SNSResponse(BaseResponse): + + def create_topic(self): + name = self._get_param('Name') + topic = sns_backend.create_topic(name) + + return json.dumps({ + 'CreateTopicResponse': { + 'CreateTopicResult': { + 'TopicArn': topic.arn, + }, + 'ResponseMetadata': { + 'RequestId': 'a8dec8b3-33a4-11df-8963-01868b7c937a', + } + } + }) + + def list_topics(self): + topics = sns_backend.list_topics() + + return json.dumps({ + 'ListTopicsResponse': { + 'ListTopicsResult': { + 'Topics': [{'TopicArn': topic.arn} for topic in topics] + } + }, + 'ResponseMetadata': { + 'RequestId': 'a8dec8b3-33a4-11df-8963-01868b7c937a', + } + }) + + def delete_topic(self): + topic_arn = self._get_param('TopicArn') + sns_backend.delete_topic(topic_arn) + + return json.dumps({ + 'DeleteTopicResponse': { + 'ResponseMetadata': { + 'RequestId': 'a8dec8b3-33a4-11df-8963-01868b7c937a', + } + } + }) + + def get_topic_attributes(self): + topic_arn = self._get_param('TopicArn') + topic = sns_backend.get_topic(topic_arn) + + return json.dumps({ + "GetTopicAttributesResponse": { + "GetTopicAttributesResult": { + "Attributes": { + "Owner": topic.account_id, + "Policy": topic.policy, + "TopicArn": topic.arn, + "DisplayName": topic.display_name, + "SubscriptionsPending": topic.subscriptions_pending, + "SubscriptionsConfirmed": topic.subscriptions_confimed, + "SubscriptionsDeleted": topic.subscriptions_deleted, + "DeliveryPolicy": topic.delivery_policy, + "EffectiveDeliveryPolicy": topic.effective_delivery_policy, + } + }, + "ResponseMetadata": { + "RequestId": "057f074c-33a7-11df-9540-99d0768312d3" + } + } + }) + + def set_topic_attributes(self): + topic_arn = self._get_param('TopicArn') + attribute_name = self._get_param('AttributeName') + attribute_name = camelcase_to_underscores(attribute_name) + attribute_value = self._get_param('AttributeValue') + sns_backend.set_topic_attribute(topic_arn, attribute_name, attribute_value) + + return json.dumps({ + "SetTopicAttributesResponse": { + "ResponseMetadata": { + "RequestId": "a8763b99-33a7-11df-a9b7-05d48da6f042" + } + } + }) + + def subscribe(self): + topic_arn = self._get_param('TopicArn') + endpoint = self._get_param('Endpoint') + protocol = self._get_param('Protocol') + subscription = sns_backend.subscribe(topic_arn, endpoint, protocol) + + return json.dumps({ + "SubscribeResponse": { + "SubscribeResult": { + "SubscriptionArn": subscription.arn, + }, + "ResponseMetadata": { + "RequestId": "a8763b99-33a7-11df-a9b7-05d48da6f042" + } + } + }) + + def unsubscribe(self): + subscription_arn = self._get_param('SubscriptionArn') + sns_backend.unsubscribe(subscription_arn) + + return json.dumps({ + "UnsubscribeResponse": { + "ResponseMetadata": { + "RequestId": "a8763b99-33a7-11df-a9b7-05d48da6f042" + } + } + }) + + def list_subscriptions(self): + subscriptions = sns_backend.list_subscriptions() + + return json.dumps({ + "ListSubscriptionsResponse": { + "ListSubscriptionsResult": { + "Subscriptions": [{ + "TopicArn": subscription.topic.arn, + "Protocol": subscription.protocol, + "SubscriptionArn": subscription.arn, + "Owner": subscription.topic.account_id, + "Endpoint": subscription.endpoint, + } for subscription in subscriptions] + }, + "ResponseMetadata": { + "RequestId": "384ac68d-3775-11df-8963-01868b7c937a", + } + } + }) + + def list_subscriptions_by_topic(self): + topic_arn = self._get_param('TopicArn') + subscriptions = sns_backend.list_subscriptions(topic_arn) + + return json.dumps({ + "ListSubscriptionsByTopicResponse": { + "ListSubscriptionsByTopicResult": { + "Subscriptions": [{ + "TopicArn": subscription.topic.arn, + "Protocol": subscription.protocol, + "SubscriptionArn": subscription.arn, + "Owner": subscription.topic.account_id, + "Endpoint": subscription.endpoint, + } for subscription in subscriptions] + }, + "ResponseMetadata": { + "RequestId": "384ac68d-3775-11df-8963-01868b7c937a", + } + } + }) + + def publish(self): + topic_arn = self._get_param('TopicArn') + message = self._get_param('Message') + message_id = sns_backend.publish(topic_arn, message) + + return json.dumps({ + "PublishResponse": { + "PublishResult": { + "MessageId": message_id, + }, + "ResponseMetadata": { + "RequestId": "384ac68d-3775-11df-8963-01868b7c937a", + } + } + }) diff --git a/moto/sns/urls.py b/moto/sns/urls.py new file mode 100644 index 000000000..a066e263e --- /dev/null +++ b/moto/sns/urls.py @@ -0,0 +1,9 @@ +from .responses import SNSResponse + +url_bases = [ + "https?://sns.(.+).amazonaws.com" +] + +url_paths = { + '{0}/$': SNSResponse().dispatch, +} diff --git a/moto/sns/utils.py b/moto/sns/utils.py new file mode 100644 index 000000000..1a0ca8d01 --- /dev/null +++ b/moto/sns/utils.py @@ -0,0 +1,10 @@ +import uuid + + +def make_arn_for_topic(account_id, name): + return "arn:aws:sns:us-east-1:{0}:{1}".format(account_id, name) + + +def make_arn_for_subscription(topic_arn): + subscription_id = uuid.uuid4() + return "{0}:{1}".format(topic_arn, subscription_id) diff --git a/tests/test_sns/test_publishing.py b/tests/test_sns/test_publishing.py new file mode 100644 index 000000000..9a3ff8396 --- /dev/null +++ b/tests/test_sns/test_publishing.py @@ -0,0 +1,62 @@ +from urlparse import parse_qs + +import boto +from freezegun import freeze_time +import httpretty +import sure # noqa + +from moto import mock_sns, mock_sqs + + +@mock_sqs +@mock_sns +def test_publish_to_sqs(): + conn = boto.connect_sns() + conn.create_topic("some-topic") + topics_json = conn.get_all_topics() + topic_arn = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"][0]['TopicArn'] + + sqs_conn = boto.connect_sqs() + sqs_conn.create_queue("test-queue") + + conn.subscribe(topic_arn, "sqs", "arn:aws:sqs:us-east-1:123456789012:test-queue") + + conn.publish(topic=topic_arn, message="my message") + + queue = sqs_conn.get_queue("test-queue") + message = queue.read(1) + message.get_body().should.equal('my message') + + +@freeze_time("2013-01-01") +@mock_sns +def test_publish_to_http(): + httpretty.HTTPretty.register_uri( + method="POST", + uri="http://example.com/foobar", + ) + + conn = boto.connect_sns() + conn.create_topic("some-topic") + topics_json = conn.get_all_topics() + topic_arn = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"][0]['TopicArn'] + + conn.subscribe(topic_arn, "http", "http://example.com/foobar") + + response = conn.publish(topic=topic_arn, message="my message", subject="my subject") + message_id = response['PublishResponse']['PublishResult']['MessageId'] + + last_request = httpretty.last_request() + last_request.method.should.equal("POST") + parse_qs(last_request.body).should.equal({ + "Type": ["Notification"], + "MessageId": [message_id], + "TopicArn": ["arn:aws:sns:us-east-1:123456789012:some-topic"], + "Subject": ["my subject"], + "Message": ["my message"], + "Timestamp": ["2013-01-01T00:00:00Z"], + "SignatureVersion": ["1"], + "Signature": ["EXAMPLElDMXvB8r9R83tGoNn0ecwd5UjllzsvSvbItzfaMpN2nk5HVSw7XnOn/49IkxDKz8YrlH2qJXj2iZB0Zo2O71c4qQk1fMUDi3LGpij7RCW7AW9vYYsSqIKRnFS94ilu7NFhUzLiieYr4BKHpdTmdD6c0esKEYBpabxDSc="], + "SigningCertURL": ["https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem"], + "UnsubscribeURL": ["https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456789012:some-topic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55"], + }) diff --git a/tests/test_sns/test_server.py b/tests/test_sns/test_server.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_sns/test_subscriptions.py b/tests/test_sns/test_subscriptions.py new file mode 100644 index 000000000..8aa2b1587 --- /dev/null +++ b/tests/test_sns/test_subscriptions.py @@ -0,0 +1,49 @@ +import boto + +import sure # noqa + +from moto import mock_sns + + +@mock_sns +def test_creating_subscription(): + conn = boto.connect_sns() + conn.create_topic("some-topic") + topics_json = conn.get_all_topics() + topic_arn = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"][0]['TopicArn'] + + conn.subscribe(topic_arn, "http", "http://example.com/") + + subscriptions = conn.get_all_subscriptions()["ListSubscriptionsResponse"]["ListSubscriptionsResult"]["Subscriptions"] + subscriptions.should.have.length_of(1) + subscription = subscriptions[0] + subscription["TopicArn"].should.equal(topic_arn) + subscription["Protocol"].should.equal("http") + subscription["SubscriptionArn"].should.contain(topic_arn) + subscription["Endpoint"].should.equal("http://example.com/") + + # Now unsubscribe the subscription + conn.unsubscribe(subscription["SubscriptionArn"]) + + # And there should be zero subscriptions left + subscriptions = conn.get_all_subscriptions()["ListSubscriptionsResponse"]["ListSubscriptionsResult"]["Subscriptions"] + subscriptions.should.have.length_of(0) + + +@mock_sns +def test_getting_subscriptions_by_topic(): + conn = boto.connect_sns() + conn.create_topic("topic1") + conn.create_topic("topic2") + + topics_json = conn.get_all_topics() + topics = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"] + topic1_arn = topics[0]['TopicArn'] + topic2_arn = topics[1]['TopicArn'] + + conn.subscribe(topic1_arn, "http", "http://example1.com/") + conn.subscribe(topic2_arn, "http", "http://example2.com/") + + topic1_subscriptions = conn.get_all_subscriptions_by_topic(topic1_arn)["ListSubscriptionsByTopicResponse"]["ListSubscriptionsByTopicResult"]["Subscriptions"] + topic1_subscriptions.should.have.length_of(1) + topic1_subscriptions[0]['Endpoint'].should.equal("http://example1.com/") diff --git a/tests/test_sns/test_topics.py b/tests/test_sns/test_topics.py new file mode 100644 index 000000000..d7a8c5775 --- /dev/null +++ b/tests/test_sns/test_topics.py @@ -0,0 +1,54 @@ +import boto + +import sure # noqa + +from moto import mock_sns +from moto.sns.models import DEFAULT_TOPIC_POLICY, DEFAULT_EFFECTIVE_DELIVERY_POLICY + + +@mock_sns +def test_create_and_delete_topic(): + conn = boto.connect_sns() + conn.create_topic("some-topic") + + topics_json = conn.get_all_topics() + topics = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"] + topics.should.have.length_of(1) + topics[0]['TopicArn'].should.equal("arn:aws:sns:us-east-1:123456789012:some-topic") + + # Delete the topic + conn.delete_topic(topics[0]['TopicArn']) + + # And there should now be 0 topics + topics_json = conn.get_all_topics() + topics = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"] + topics.should.have.length_of(0) + + +@mock_sns +def test_topic_attributes(): + conn = boto.connect_sns() + conn.create_topic("some-topic") + + topics_json = conn.get_all_topics() + topic_arn = topics_json["ListTopicsResponse"]["ListTopicsResult"]["Topics"][0]['TopicArn'] + + attributes = conn.get_topic_attributes(topic_arn)['GetTopicAttributesResponse']['GetTopicAttributesResult']['Attributes'] + attributes["TopicArn"].should.equal("arn:aws:sns:us-east-1:123456789012:some-topic") + attributes["Owner"].should.equal(123456789012) + attributes["Policy"].should.equal(DEFAULT_TOPIC_POLICY) + attributes["DisplayName"].should.equal("") + attributes["SubscriptionsPending"].should.equal(0) + attributes["SubscriptionsConfirmed"].should.equal(0) + attributes["SubscriptionsDeleted"].should.equal(0) + attributes["DeliveryPolicy"].should.equal("") + attributes["EffectiveDeliveryPolicy"].should.equal(DEFAULT_EFFECTIVE_DELIVERY_POLICY) + + conn.set_topic_attributes(topic_arn, "Policy", {"foo": "bar"}) + conn.set_topic_attributes(topic_arn, "DisplayName", "My display name") + conn.set_topic_attributes(topic_arn, "DeliveryPolicy", {"http": {"defaultHealthyRetryPolicy": {"numRetries": 5}}}) + + attributes = conn.get_topic_attributes(topic_arn)['GetTopicAttributesResponse']['GetTopicAttributesResult']['Attributes'] + attributes["Policy"].should.equal("{'foo': 'bar'}") + attributes["DisplayName"].should.equal("My display name") + attributes["DeliveryPolicy"].should.equal("{'http': {'defaultHealthyRetryPolicy': {'numRetries': 5}}}")