Skip to content
This repository was archived by the owner on Mar 23, 2026. It is now read-only.

Commit 5253193

Browse files
authored
SNS: v2 phone number operations (#13449)
1 parent f337af9 commit 5253193

5 files changed

Lines changed: 246 additions & 21 deletions

File tree

localstack-core/localstack/services/sns/v2/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from localstack.aws.api.sns import (
88
Endpoint,
99
MessageAttributeMap,
10+
PhoneNumber,
1011
PlatformApplication,
1112
PublishBatchRequestEntry,
1213
TopicAttributesMap,
@@ -192,5 +193,7 @@ class SnsStore(BaseStore):
192193

193194
TAGS: TaggingService = CrossRegionAttribute(default=TaggingService)
194195

196+
PHONE_NUMBERS_OPTED_OUT: list[PhoneNumber] = CrossRegionAttribute(default=list)
197+
195198

196199
sns_stores = AccountRegionBundle("sns", SnsStore)

localstack-core/localstack/services/sns/v2/provider.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from localstack.aws.api.sns import (
1313
AmazonResourceName,
1414
BatchEntryIdsNotDistinctException,
15+
CheckIfPhoneNumberIsOptedOutResponse,
1516
ConfirmSubscriptionResponse,
1617
CreateEndpointResponse,
1718
CreatePlatformApplicationResponse,
@@ -26,6 +27,7 @@
2627
InvalidParameterException,
2728
InvalidParameterValueException,
2829
ListEndpointsByPlatformApplicationResponse,
30+
ListPhoneNumbersOptedOutResponse,
2931
ListPlatformApplicationsResponse,
3032
ListString,
3133
ListSubscriptionsByTopicResponse,
@@ -35,6 +37,7 @@
3537
MapStringToString,
3638
MessageAttributeMap,
3739
NotFoundException,
40+
OptInPhoneNumberResponse,
3841
PhoneNumber,
3942
PlatformApplication,
4043
PublishBatchRequestEntryList,
@@ -61,6 +64,7 @@
6164
messageStructure,
6265
nextToken,
6366
protocol,
67+
string,
6468
subject,
6569
subscriptionARN,
6670
topicARN,
@@ -1077,6 +1081,39 @@ def get_sms_attributes(
10771081

10781082
return GetSMSAttributesResponse(attributes=return_attributes)
10791083

1084+
#
1085+
# Phone number operations
1086+
#
1087+
1088+
def check_if_phone_number_is_opted_out(
1089+
self, context: RequestContext, phone_number: PhoneNumber, **kwargs
1090+
) -> CheckIfPhoneNumberIsOptedOutResponse:
1091+
store = sns_stores[context.account_id][context.region]
1092+
return CheckIfPhoneNumberIsOptedOutResponse(
1093+
isOptedOut=phone_number in store.PHONE_NUMBERS_OPTED_OUT
1094+
)
1095+
1096+
def list_phone_numbers_opted_out(
1097+
self, context: RequestContext, next_token: string | None = None, **kwargs
1098+
) -> ListPhoneNumbersOptedOutResponse:
1099+
store = self.get_store(context.account_id, context.region)
1100+
numbers_opted_out = PaginatedList(store.PHONE_NUMBERS_OPTED_OUT)
1101+
page, nxt = numbers_opted_out.get_page(
1102+
token_generator=lambda x: x,
1103+
next_token=next_token,
1104+
page_size=100,
1105+
)
1106+
phone_numbers = {"phoneNumbers": page, "nextToken": nxt}
1107+
return ListPhoneNumbersOptedOutResponse(**phone_numbers)
1108+
1109+
def opt_in_phone_number(
1110+
self, context: RequestContext, phone_number: PhoneNumber, **kwargs
1111+
) -> OptInPhoneNumberResponse:
1112+
store = self.get_store(context.account_id, context.region)
1113+
if phone_number in store.PHONE_NUMBERS_OPTED_OUT:
1114+
store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
1115+
return OptInPhoneNumberResponse()
1116+
10801117
def list_tags_for_resource(
10811118
self, context: RequestContext, resource_arn: AmazonResourceName, **kwargs
10821119
) -> ListTagsForResourceResponse:

tests/aws/services/sns/test_sns.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from localstack.testing.aws.util import is_aws_cloud
3838
from localstack.testing.config import TEST_AWS_ACCESS_KEY_ID, TEST_AWS_SECRET_ACCESS_KEY
3939
from localstack.testing.pytest import markers
40+
from localstack.testing.snapshots.transformer_utility import TransformerUtility
4041
from localstack.utils import testutil
4142
from localstack.utils.aws.arns import get_partition, parse_arn, sqs_queue_arn
4243
from localstack.utils.net import wait_for_port_closed, wait_for_port_open
@@ -3665,6 +3666,15 @@ def platform_credentials() -> tuple[str, str]:
36653666
return client_id, client_secret
36663667

36673668

3669+
@pytest.fixture(scope="class")
3670+
def phone_number() -> str:
3671+
# if you want to test phone number operations against AWS and a real phone number, replace this value
3672+
# and use this fixture.
3673+
# note: you might need to verify that number first in your AWS account due to the sms sandbox
3674+
phone_number = "+430000000000"
3675+
return phone_number
3676+
3677+
36683678
class TestSNSPlatformApplicationCrud:
36693679
@markers.aws.manual_setup_required
36703680
def test_create_platform_application(
@@ -4680,6 +4690,85 @@ def test_set_invalid_sms_attributes(self, aws_client, snapshot, attribute_key_va
46804690
)
46814691
snapshot.match("invalid-attribute", e.value.response)
46824692

4693+
@markers.aws.manual_setup_required
4694+
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
4695+
def test_is_phone_number_opted_out(
4696+
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
4697+
):
4698+
# this test expects the fixture-provided phone number to be opted out
4699+
# if you want to test against AWS, you need to manually opt out a number
4700+
# 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
4701+
sns_store = sns_provider().get_store(account_id, region_name)
4702+
4703+
def cleanup_store():
4704+
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
4705+
4706+
if not is_aws_cloud():
4707+
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
4708+
cleanups.append(cleanup_store)
4709+
4710+
response = aws_client.sns.check_if_phone_number_is_opted_out(phoneNumber=phone_number)
4711+
snapshot.match("phone-number-opted-out", response)
4712+
4713+
@markers.aws.manual_setup_required
4714+
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
4715+
def test_list_phone_numbers_opted_out(
4716+
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
4717+
):
4718+
# this test expects exactly one phone number opted out
4719+
# if you want to test against AWS, you need to manually opt out a number
4720+
# 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
4721+
sns_store = sns_provider().get_store(account_id, region_name)
4722+
4723+
def cleanup_store():
4724+
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
4725+
4726+
if not is_aws_cloud():
4727+
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
4728+
cleanups.append(cleanup_store)
4729+
4730+
snapshot.add_transformer(
4731+
TransformerUtility.jsonpath(
4732+
jsonpath="$..phoneNumbers[*]",
4733+
value_replacement="phone-number",
4734+
)
4735+
)
4736+
response = aws_client.sns.list_phone_numbers_opted_out()
4737+
snapshot.match("list-phone-numbers-opted-out", response)
4738+
4739+
@markers.aws.manual_setup_required
4740+
@pytest.mark.skipif(is_sns_v1_provider(), reason="Not correctly implemented in v1")
4741+
def test_opt_in_phone_number(
4742+
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name, cleanups
4743+
):
4744+
# this test expects exactly one phone number opted out
4745+
# if you want to test against AWS, you need to manually opt out a number
4746+
# 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
4747+
# IMPORTANT: a phone number can only be opted in once every 30 days on AWS.
4748+
# Make sure everything else is set up and taken care of properly before trying to validate this.
4749+
sns_store = sns_provider().get_store(account_id, region_name)
4750+
4751+
def cleanup_store():
4752+
sns_store.PHONE_NUMBERS_OPTED_OUT.remove(phone_number)
4753+
4754+
if not is_aws_cloud():
4755+
sns_store.PHONE_NUMBERS_OPTED_OUT.append(phone_number)
4756+
cleanups.append(cleanup_store)
4757+
response = aws_client.sns.check_if_phone_number_is_opted_out(phoneNumber=phone_number)
4758+
assert response["isOptedOut"]
4759+
4760+
response = aws_client.sns.opt_in_phone_number(phoneNumber=phone_number)
4761+
snapshot.match("opt-in-phone-number", response)
4762+
4763+
@markers.aws.validated
4764+
def test_opt_in_non_existing_phone_number(
4765+
self, phone_number, aws_client, snapshot, sns_provider, account_id, region_name
4766+
):
4767+
non_existing_number = "+4411111111"
4768+
response = aws_client.sns.opt_in_phone_number(phoneNumber=non_existing_number)
4769+
4770+
snapshot.match("opt-in-non-existing-number", response)
4771+
46834772

46844773
class TestSNSSubscriptionHttp:
46854774
@markers.aws.validated

tests/aws/services/sns/test_sns.snapshot.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6787,5 +6787,53 @@
67876787
}
67886788
}
67896789
}
6790+
},
6791+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_is_phone_number_opted_out": {
6792+
"recorded-date": "01-12-2025, 19:33:36",
6793+
"recorded-content": {
6794+
"phone-number-opted-out": {
6795+
"isOptedOut": true,
6796+
"ResponseMetadata": {
6797+
"HTTPHeaders": {},
6798+
"HTTPStatusCode": 200
6799+
}
6800+
}
6801+
}
6802+
},
6803+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_list_phone_numbers_opted_out": {
6804+
"recorded-date": "01-12-2025, 19:37:44",
6805+
"recorded-content": {
6806+
"list-phone-numbers-opted-out": {
6807+
"phoneNumbers": [
6808+
"<phone-number:1>"
6809+
],
6810+
"ResponseMetadata": {
6811+
"HTTPHeaders": {},
6812+
"HTTPStatusCode": 200
6813+
}
6814+
}
6815+
}
6816+
},
6817+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_phone_number": {
6818+
"recorded-date": "01-12-2025, 19:44:54",
6819+
"recorded-content": {
6820+
"opt-in-phone-number": {
6821+
"ResponseMetadata": {
6822+
"HTTPHeaders": {},
6823+
"HTTPStatusCode": 200
6824+
}
6825+
}
6826+
}
6827+
},
6828+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_non_existing_phone_number": {
6829+
"recorded-date": "01-12-2025, 19:53:23",
6830+
"recorded-content": {
6831+
"opt-in-non-existing-number": {
6832+
"ResponseMetadata": {
6833+
"HTTPHeaders": {},
6834+
"HTTPStatusCode": 200
6835+
}
6836+
}
6837+
}
67906838
}
67916839
}

tests/aws/services/sns/test_sns.validation.json

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -657,16 +657,58 @@
657657
"last_validated_date": "2023-11-07T10:11:37+00:00"
658658
},
659659
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_get_sms_attributes_from_unmodified_region": {
660-
"last_validated_date": "2025-10-15T12:02:37+00:00",
660+
"last_validated_date": "2025-12-02T12:58:44+00:00",
661661
"durations_in_seconds": {
662-
"setup": 0.67,
663-
"call": 2.07,
662+
"setup": 0.0,
663+
"call": 2.64,
664+
"teardown": 0.0,
665+
"total": 2.64
666+
}
667+
},
668+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_is_phone_number_opted_out": {
669+
"last_validated_date": "2025-12-01T19:33:36+00:00",
670+
"durations_in_seconds": {
671+
"setup": 0.74,
672+
"call": 0.63,
673+
"teardown": 0.0,
674+
"total": 1.37
675+
}
676+
},
677+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_list_phone_numbers_opted_out": {
678+
"last_validated_date": "2025-12-02T12:58:54+00:00",
679+
"durations_in_seconds": {
680+
"setup": 0.0,
681+
"call": 0.16,
682+
"teardown": 0.0,
683+
"total": 0.16
684+
}
685+
},
686+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_non_existing_phone_number": {
687+
"last_validated_date": "2025-12-02T12:58:54+00:00",
688+
"durations_in_seconds": {
689+
"setup": 0.0,
690+
"call": 0.14,
691+
"teardown": 0.0,
692+
"total": 0.14
693+
}
694+
},
695+
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_opt_in_phone_number": {
696+
"last_validated_date": "2025-12-01T19:44:54+00:00",
697+
"durations_in_seconds": {
698+
"setup": 0.94,
699+
"call": 1.02,
664700
"teardown": 0.0,
665-
"total": 2.74
701+
"total": 1.96
666702
}
667703
},
668704
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_publish_wrong_phone_format": {
669-
"last_validated_date": "2023-08-24T22:20:12+00:00"
705+
"last_validated_date": "2025-12-02T12:58:42+00:00",
706+
"durations_in_seconds": {
707+
"setup": 0.0,
708+
"call": 0.87,
709+
"teardown": 0.25,
710+
"total": 1.12
711+
}
670712
},
671713
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_get_sms_attributes": {
672714
"last_validated_date": "2025-10-13T10:30:42+00:00",
@@ -678,39 +720,39 @@
678720
}
679721
},
680722
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[InvalidAttributeName]": {
681-
"last_validated_date": "2025-10-15T12:14:04+00:00",
723+
"last_validated_date": "2025-12-02T12:58:45+00:00",
682724
"durations_in_seconds": {
683-
"setup": 1.45,
684-
"call": 1.32,
725+
"setup": 0.01,
726+
"call": 0.3,
685727
"teardown": 0.0,
686-
"total": 2.77
728+
"total": 0.31
687729
}
688730
},
689731
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[InvalidSMSType]": {
690-
"last_validated_date": "2025-10-15T12:14:12+00:00",
732+
"last_validated_date": "2025-12-02T12:58:53+00:00",
691733
"durations_in_seconds": {
692734
"setup": 0.0,
693-
"call": 2.24,
735+
"call": 2.16,
694736
"teardown": 0.0,
695-
"total": 2.24
737+
"total": 2.16
696738
}
697739
},
698740
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[NoLetterID]": {
699-
"last_validated_date": "2025-10-15T12:14:09+00:00",
741+
"last_validated_date": "2025-12-02T12:58:51+00:00",
700742
"durations_in_seconds": {
701-
"setup": 0.01,
702-
"call": 3.39,
743+
"setup": 0.0,
744+
"call": 3.38,
703745
"teardown": 0.0,
704-
"total": 3.4
746+
"total": 3.38
705747
}
706748
},
707749
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[TooLongID]": {
708-
"last_validated_date": "2025-10-15T12:14:06+00:00",
750+
"last_validated_date": "2025-12-02T12:58:48+00:00",
709751
"durations_in_seconds": {
710-
"setup": 0.1,
711-
"call": 1.78,
752+
"setup": 0.0,
753+
"call": 3.04,
712754
"teardown": 0.0,
713-
"total": 1.88
755+
"total": 3.04
714756
}
715757
},
716758
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_set_invalid_sms_attributes[attribute_key_value0]": {
@@ -750,7 +792,13 @@
750792
}
751793
},
752794
"tests/aws/services/sns/test_sns.py::TestSNSSMS::test_subscribe_sms_endpoint": {
753-
"last_validated_date": "2024-05-14T19:34:11+00:00"
795+
"last_validated_date": "2025-12-02T12:58:41+00:00",
796+
"durations_in_seconds": {
797+
"setup": 0.81,
798+
"call": 2.06,
799+
"teardown": 0.61,
800+
"total": 3.48
801+
}
754802
},
755803
"tests/aws/services/sns/test_sns.py::TestSNSSubscriptionCrud::test_create_subscriptions_with_attributes": {
756804
"last_validated_date": "2025-10-06T10:25:19+00:00",

0 commit comments

Comments
 (0)