diff --git a/localstack/services/sns/constants.py b/localstack/services/sns/constants.py index d9aac8606e8c4..3e606ef91bb08 100644 --- a/localstack/services/sns/constants.py +++ b/localstack/services/sns/constants.py @@ -32,5 +32,6 @@ # Endpoint to access all the PlatformEndpoint sent Messages PLATFORM_ENDPOINT_MSGS_ENDPOINT = "/_aws/sns/platform-endpoint-messages" SMS_MSGS_ENDPOINT = "/_aws/sns/sms-messages" +SUBSCRIPTION_TOKENS_ENDPOINT = "/_aws/sns/subscription-tokens" DUMMY_SUBSCRIPTION_PRINCIPAL = "arn:aws:iam::{{account_id}}:user/DummySNSPrincipal" diff --git a/localstack/services/sns/provider.py b/localstack/services/sns/provider.py index 89a95b5620c5d..2ba923c49851d 100644 --- a/localstack/services/sns/provider.py +++ b/localstack/services/sns/provider.py @@ -897,6 +897,7 @@ def register_sns_api_resource(router: Router): """Register the retrospection endpoints as internal LocalStack endpoints.""" router.add(SNSServicePlatformEndpointMessagesApiResource()) router.add(SNSServiceSMSMessagesApiResource()) + router.add(SNSServiceSubscriptionTokenApiResource()) def _format_messages(sent_messages: List[Dict[str, str]], validated_keys: List[str]): @@ -1041,3 +1042,47 @@ def on_delete(self, request: Request) -> Response: store.sms_messages.clear() return Response("", status=204) + + +class SNSServiceSubscriptionTokenApiResource: + """Provides a REST API for retrospective access to Subscription Confirmation Tokens to confirm subscriptions. + Those are not sent for email, and sometimes inaccessible when working with external HTTPS endpoint which won't be + able to reach your local host. + + This is registered as a LocalStack internal HTTP resource. + + This endpoint has the following parameter: + - GET `subscription_arn`: `subscriptionArn`resource in SNS for which you want the SubscriptionToken + """ + + @route(f"{sns_constants.SUBSCRIPTION_TOKENS_ENDPOINT}/", methods=["GET"]) + def on_get(self, _request: Request, subscription_arn: str): + try: + parsed_arn = parse_arn(subscription_arn) + except InvalidArnException: + response = Response("", 400) + response.set_json( + { + "error": "The provided SubscriptionARN is invalid", + "subscription_arn": subscription_arn, + } + ) + return response + + store: SnsStore = sns_stores[parsed_arn["account"]][parsed_arn["region"]] + + for token, sub_arn in store.subscription_tokens.items(): + if sub_arn == subscription_arn: + return { + "subscription_token": token, + "subscription_arn": subscription_arn, + } + + response = Response("", 404) + response.set_json( + { + "error": "The provided SubscriptionARN is not found", + "subscription_arn": subscription_arn, + } + ) + return response diff --git a/tests/aws/services/sns/test_sns.py b/tests/aws/services/sns/test_sns.py index b701e01b194d0..2bf18a6f13964 100644 --- a/tests/aws/services/sns/test_sns.py +++ b/tests/aws/services/sns/test_sns.py @@ -24,7 +24,11 @@ TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME, ) -from localstack.services.sns.constants import PLATFORM_ENDPOINT_MSGS_ENDPOINT, SMS_MSGS_ENDPOINT +from localstack.services.sns.constants import ( + PLATFORM_ENDPOINT_MSGS_ENDPOINT, + SMS_MSGS_ENDPOINT, + SUBSCRIPTION_TOKENS_ENDPOINT, +) from localstack.services.sns.provider import SnsProvider from localstack.testing.aws.util import is_aws_cloud from localstack.testing.pytest import markers @@ -3651,7 +3655,8 @@ def handler(_request): @markers.snapshot.skip_snapshot_verify( paths=[ "$.http-message-headers.Accept", # requests adds the header but not SNS, not very important - "$.http-message-headers-raw.Accept", # requests adds the header but not SNS, not very important + "$.http-message-headers-raw.Accept", + "$.http-confirm-sub-headers.Accept", ] ) def test_subscribe_external_http_endpoint( @@ -3703,6 +3708,8 @@ def _clean_headers(response_headers: dict): assert "Signature" in payload assert "SigningCertURL" in payload + snapshot.match("http-confirm-sub-headers", _clean_headers(sub_request.headers)) + token = payload["Token"] subscribe_url = payload["SubscribeURL"] service_url, subscribe_url_path = payload["SubscribeURL"].rsplit("/", maxsplit=1) @@ -4529,3 +4536,62 @@ def check_message(): assert delete_res.status_code == 204 msg_with_region = requests.get(msgs_url, params={"region": "us-east-1"}).json() assert not msg_with_region["sms_messages"] + + @markers.aws.only_localstack + def test_subscription_tokens_can_retrospect( + self, sns_create_topic, sns_subscription, sns_create_http_endpoint, aws_client + ): + sns_store = SnsProvider.get_store(TEST_AWS_ACCOUNT_ID, TEST_AWS_REGION_NAME) + # clean up the saved tokens + sns_store.subscription_tokens.clear() + + message = "Good news everyone!" + # Necessitate manual set up to allow external access to endpoint, only in local testing + topic_arn, subscription_arn, endpoint_url, server = sns_create_http_endpoint() + assert poll_condition( + lambda: len(server.log) >= 1, + timeout=5, + ) + sub_request, _ = server.log[0] + payload = sub_request.get_json(force=True) + assert payload["Type"] == "SubscriptionConfirmation" + token = payload["Token"] + server.clear() + + # we won't confirm the subscription, to simulate an external provider that wouldn't be able to access LocalStack + # try to access the internal to confirm the Token is there + tokens_base_url = config.get_edge_url() + SUBSCRIPTION_TOKENS_ENDPOINT + api_contents = requests.get(f"{tokens_base_url}/{subscription_arn}").json() + assert api_contents["subscription_token"] == token + assert api_contents["subscription_arn"] == subscription_arn + + # try to send a message to an unconfirmed subscription, assert that the message isn't received + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + + assert poll_condition( + lambda: len(server.log) == 0, + timeout=1, + ) + + aws_client.sns.confirm_subscription(TopicArn=topic_arn, Token=token) + aws_client.sns.publish(Message=message, TopicArn=topic_arn) + assert poll_condition( + lambda: len(server.log) == 1, + timeout=2, + ) + + wrong_sub_arn = subscription_arn.replace(TEST_AWS_REGION_NAME, "us-west-1") + wrong_region_req = requests.get(f"{tokens_base_url}/{wrong_sub_arn}") + assert wrong_region_req.status_code == 404 + assert wrong_region_req.json() == { + "error": "The provided SubscriptionARN is not found", + "subscription_arn": wrong_sub_arn, + } + + # Ensure proper error is raised with wrong ARN + incorrect_arn_req = requests.get(f"{tokens_base_url}/randomarnhere") + assert incorrect_arn_req.status_code == 400 + assert incorrect_arn_req.json() == { + "error": "The provided SubscriptionARN is invalid", + "subscription_arn": "randomarnhere", + } diff --git a/tests/aws/services/sns/test_sns.snapshot.json b/tests/aws/services/sns/test_sns.snapshot.json index b3c74269feb8c..b2a4f65eb0136 100644 --- a/tests/aws/services/sns/test_sns.snapshot.json +++ b/tests/aws/services/sns/test_sns.snapshot.json @@ -4284,7 +4284,7 @@ } }, "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[True]": { - "recorded-date": "09-10-2023, 16:01:30", + "recorded-date": "12-10-2023, 00:47:24", "recorded-content": { "subscription-confirmation": { "Message": "You have chosen to subscribe to the topic arn:aws:sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", @@ -4298,6 +4298,17 @@ "TopicArn": "arn:aws:sns::111111111111:", "Type": "SubscriptionConfirmation" }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn:aws:sns::111111111111:" + }, "broken-topic-arn-confirm": { "ErrorResponse": { "Error": { @@ -4386,7 +4397,7 @@ } }, "tests/aws/services/sns/test_sns.py::TestSNSSubscriptionHttp::test_subscribe_external_http_endpoint[False]": { - "recorded-date": "09-10-2023, 16:01:39", + "recorded-date": "12-10-2023, 00:47:29", "recorded-content": { "subscription-confirmation": { "Message": "You have chosen to subscribe to the topic arn:aws:sns::111111111111:.\nTo confirm the subscription, visit the SubscribeURL included in this message.", @@ -4400,6 +4411,17 @@ "TopicArn": "arn:aws:sns::111111111111:", "Type": "SubscriptionConfirmation" }, + "http-confirm-sub-headers": { + "Accept-Encoding": "gzip,deflate", + "Connection": "connection", + "Content-Length": "content--length", + "Content-Type": "text/plain; charset=UTF-8", + "Host": "", + "User-Agent": "Amazon Simple Notification Service Agent", + "X-Amz-Sns-Message-Id": "", + "X-Amz-Sns-Message-Type": "SubscriptionConfirmation", + "X-Amz-Sns-Topic-Arn": "arn:aws:sns::111111111111:" + }, "broken-topic-arn-confirm": { "ErrorResponse": { "Error": {