Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions localstack-core/localstack/services/sns/v2/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from localstack.aws.api.sns import (
Endpoint,
MessageAttributeMap,
PhoneNumber,
PlatformApplication,
PublishBatchRequestEntry,
TopicAttributesMap,
Expand Down Expand Up @@ -192,5 +193,7 @@ class SnsStore(BaseStore):

TAGS: TaggingService = CrossRegionAttribute(default=TaggingService)

PHONE_NUMBERS_OPTED_OUT: list[PhoneNumber] = CrossRegionAttribute(default=list)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is this in uppercase because it is supposed to be a constant? if that's the case, does it make sense to be in the store?

if it's not a constant, then maybe it could be a lowercase variable



sns_stores = AccountRegionBundle("sns", SnsStore)
37 changes: 37 additions & 0 deletions localstack-core/localstack/services/sns/v2/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from localstack.aws.api.sns import (
AmazonResourceName,
BatchEntryIdsNotDistinctException,
CheckIfPhoneNumberIsOptedOutResponse,
ConfirmSubscriptionResponse,
CreateEndpointResponse,
CreatePlatformApplicationResponse,
Expand All @@ -26,6 +27,7 @@
InvalidParameterException,
InvalidParameterValueException,
ListEndpointsByPlatformApplicationResponse,
ListPhoneNumbersOptedOutResponse,
ListPlatformApplicationsResponse,
ListString,
ListSubscriptionsByTopicResponse,
Expand All @@ -35,6 +37,7 @@
MapStringToString,
MessageAttributeMap,
NotFoundException,
OptInPhoneNumberResponse,
PhoneNumber,
PlatformApplication,
PublishBatchRequestEntryList,
Expand All @@ -61,6 +64,7 @@
messageStructure,
nextToken,
protocol,
string,
subject,
subscriptionARN,
topicARN,
Expand Down Expand Up @@ -1073,6 +1077,39 @@ def get_sms_attributes(

return GetSMSAttributesResponse(attributes=return_attributes)

#
# Phone number operations
#

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question about PHONE_NUMBERS_OPTED_OUT, how are users expected to add values to that? In its current form, using an empty list would have the same result, is that right?

Are we expecting to add a new internal route in the future to allow users to add values to this field? if yes, maybe putting it back to lowercase would make sense

def check_if_phone_number_is_opted_out(
self, context: RequestContext, phone_number: PhoneNumber, **kwargs
) -> CheckIfPhoneNumberIsOptedOutResponse:
store = sns_stores[context.account_id][context.region]
return CheckIfPhoneNumberIsOptedOutResponse(
isOptedOut=phone_number in store.PHONE_NUMBERS_OPTED_OUT
)

def list_phone_numbers_opted_out(
self, context: RequestContext, next_token: string | None = None, **kwargs
) -> ListPhoneNumbersOptedOutResponse:
store = self.get_store(context.account_id, context.region)
numbers_opted_out = PaginatedList(store.PHONE_NUMBERS_OPTED_OUT)
page, nxt = numbers_opted_out.get_page(
token_generator=lambda x: x,
next_token=next_token,
page_size=100,
)
phone_numbers = {"phoneNumbers": page, "nextToken": nxt}
return ListPhoneNumbersOptedOutResponse(**phone_numbers)

def opt_in_phone_number(
self, context: RequestContext, phone_number: PhoneNumber, **kwargs
) -> OptInPhoneNumberResponse:
store = self.get_store(context.account_id, context.region)
if phone_number in store.PHONE_NUMBERS_OPTED_OUT:
store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
return OptInPhoneNumberResponse()

def list_tags_for_resource(
self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs
) -> ListTagsForResourceResponse:
Expand Down
89 changes: 89 additions & 0 deletions tests/aws/services/sns/test_sns.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from localstack.testing.aws.util import is_aws_cloud
from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_SECRET_ACCESS_KEY
from localstack.testing.pytest import markers
from localstack.testing.snapshots.transformer_utility import TransformerUtility
from localstack.utils import testutil
from localstack.utils.aws.arns import get_partition, parse_arn, sqs_queue_arn
from localstack.utils.net import wait_for_port_closed, wait_for_port_open
Expand Down Expand Up @@ -3665,6 +3666,15 @@ def platform_credentials() -> tuple[str, str]:
return client_id, client_secret


@pytest.fixture(scope="class")
def phone_number() -> str:
# if you want to test phone number operations against AWS and a real phone number, replace this value
# and use this fixture.
# note: you might need to verify that number first in your AWS account due to the sms sandbox
phone_number = "+430000000000"
return phone_number


class TestSNSPlatformApplicationCrud:
@markers.aws.manual_setup_required
def test_create_platform_application(
Expand Down Expand Up @@ -4680,6 +4690,85 @@ def test_set_invalid_sms_attributes(self, aws_client, snapshot, attribute_key_va
)
snapshot.match("invalid-attribute", e.value.response)

@markers.aws.manual_setup_required
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
def test_is_phone_number_opted_out(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice work validating those tests 👌

self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
):
# this test expects the fixture-provided phone number to be opted out
# if you want to test against AWS, you need to manually opt out a number
# https://us-east-1.console.aws.amazon.com/sms-voice/home?region=us-east-1#/opt-out-lists?name=Default&tab=opt-out-list-opted-out-numbers
sns_store = sns_provider().get_store(account_id, region_name)

def cleanup_store():
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)

if not is_aws_cloud():
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
cleanups.append(cleanup_store)

response = aws_client.sns.check_if_phone_number_is_opted_out(phoneNumber=phone_number)
snapshot.match("phone-number-opted-out", response)

@markers.aws.manual_setup_required
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
def test_list_phone_numbers_opted_out(
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
):
# this test expects exactly one phone number opted out
# if you want to test against AWS, you need to manually opt out a number
# https://us-east-1.console.aws.amazon.com/sms-voice/home?region=us-east-1#/opt-out-lists?name=Default&tab=opt-out-list-opted-out-numbers
sns_store = sns_provider().get_store(account_id, region_name)

def cleanup_store():
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)

if not is_aws_cloud():
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
cleanups.append(cleanup_store)

snapshot.add_transformer(
TransformerUtility.jsonpath(
jsonpath="$..phoneNumbers[*]",
value_replacement="phone-number",
)
)
response = aws_client.sns.list_phone_numbers_opted_out()
snapshot.match("list-phone-numbers-opted-out", response)

@markers.aws.manual_setup_required
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
def test_opt_in_phone_number(
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
):
# this test expects exactly one phone number opted out
# if you want to test against AWS, you need to manually opt out a number
# https://us-east-1.console.aws.amazon.com/sms-voice/home?region=us-east-1#/opt-out-lists?name=Default&tab=opt-out-list-opted-out-numbers
# IMPORTANT: a phone number can only be opted in once every 30 days on AWS.
# Make sure everything else is set up and taken care of properly before trying to validate this.
sns_store = sns_provider().get_store(account_id, region_name)

def cleanup_store():
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)

if not is_aws_cloud():
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
cleanups.append(cleanup_store)
response = aws_client.sns.check_if_phone_number_is_opted_out(phoneNumber=phone_number)
assert response["isOptedOut"]

response = aws_client.sns.opt_in_phone_number(phoneNumber=phone_number)
snapshot.match("opt-in-phone-number", response)

@markers.aws.validated
def test_opt_in_non_existing_phone_number(
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name
):
non_existing_number = "+4411111111"
response = aws_client.sns.opt_in_phone_number(phoneNumber=non_existing_number)

snapshot.match("opt-in-non-existing-number", response)


class TestSNSSubscriptionHttp:
@markers.aws.validated
Expand Down
48 changes: 48 additions & 0 deletions tests/aws/services/sns/test_sns.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -6787,5 +6787,53 @@
}
}
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_is_phone_number_opted_out": {
"recorded-date": "01-12-2025, 19:33:36",
"recorded-content": {
"phone-number-opted-out": {
"isOptedOut": true,
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_list_phone_numbers_opted_out": {
"recorded-date": "01-12-2025, 19:37:44",
"recorded-content": {
"list-phone-numbers-opted-out": {
"phoneNumbers": [
"<phone-number:1>"
],
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_phone_number": {
"recorded-date": "01-12-2025, 19:44:54",
"recorded-content": {
"opt-in-phone-number": {
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_non_existing_phone_number": {
"recorded-date": "01-12-2025, 19:53:23",
"recorded-content": {
"opt-in-non-existing-number": {
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
}
}
90 changes: 69 additions & 21 deletions tests/aws/services/sns/test_sns.validation.json
Original file line number Diff line number Diff line change
Expand Up @@ -657,16 +657,58 @@
"last_validated_date": "2023-11-07T10:11:37+00:00"
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_get_sms_attributes_from_unmodified_region": {
"last_validated_date": "2025-10-15T12:02:37+00:00",
"last_validated_date": "2025-12-02T12:58:44+00:00",
"durations_in_seconds": {
"setup": 0.67,
"call": 2.07,
"setup": 0.0,
"call": 2.64,
"teardown": 0.0,
"total": 2.64
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_is_phone_number_opted_out": {
"last_validated_date": "2025-12-01T19:33:36+00:00",
"durations_in_seconds": {
"setup": 0.74,
"call": 0.63,
"teardown": 0.0,
"total": 1.37
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_list_phone_numbers_opted_out": {
"last_validated_date": "2025-12-02T12:58:54+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 0.16,
"teardown": 0.0,
"total": 0.16
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_non_existing_phone_number": {
"last_validated_date": "2025-12-02T12:58:54+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 0.14,
"teardown": 0.0,
"total": 0.14
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_phone_number": {
"last_validated_date": "2025-12-01T19:44:54+00:00",
"durations_in_seconds": {
"setup": 0.94,
"call": 1.02,
"teardown": 0.0,
"total": 2.74
"total": 1.96
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_wrong_phone_format": {
"last_validated_date": "2023-08-24T22:20:12+00:00"
"last_validated_date": "2025-12-02T12:58:42+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 0.87,
"teardown": 0.25,
"total": 1.12
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_get_sms_attributes": {
"last_validated_date": "2025-10-13T10:30:42+00:00",
Expand All @@ -678,39 +720,39 @@
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[InvalidAttributeName]": {
"last_validated_date": "2025-10-15T12:14:04+00:00",
"last_validated_date": "2025-12-02T12:58:45+00:00",
"durations_in_seconds": {
"setup": 1.45,
"call": 1.32,
"setup": 0.01,
"call": 0.3,
"teardown": 0.0,
"total": 2.77
"total": 0.31
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[InvalidSMSType]": {
"last_validated_date": "2025-10-15T12:14:12+00:00",
"last_validated_date": "2025-12-02T12:58:53+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 2.24,
"call": 2.16,
"teardown": 0.0,
"total": 2.24
"total": 2.16
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[NoLetterID]": {
"last_validated_date": "2025-10-15T12:14:09+00:00",
"last_validated_date": "2025-12-02T12:58:51+00:00",
"durations_in_seconds": {
"setup": 0.01,
"call": 3.39,
"setup": 0.0,
"call": 3.38,
"teardown": 0.0,
"total": 3.4
"total": 3.38
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[TooLongID]": {
"last_validated_date": "2025-10-15T12:14:06+00:00",
"last_validated_date": "2025-12-02T12:58:48+00:00",
"durations_in_seconds": {
"setup": 0.1,
"call": 1.78,
"setup": 0.0,
"call": 3.04,
"teardown": 0.0,
"total": 1.88
"total": 3.04
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[attribute_key_value0]": {
Expand Down Expand Up @@ -750,7 +792,13 @@
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_subscribe_sms_endpoint": {
"last_validated_date": "2024-05-14T19:34:11+00:00"
"last_validated_date": "2025-12-02T12:58:41+00:00",
"durations_in_seconds": {
"setup": 0.81,
"call": 2.06,
"teardown": 0.61,
"total": 3.48
}
},
"tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_create_subscriptions_with_attributes": {
"last_validated_date": "2025-10-06T10:25:19+00:00",
Expand Down
Loading