@kensio/yulin 1.20.15 → 1.20.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.d.ts +5 -0
- package/dist/sdk/module/sim-sdk-module-client-interceptor.js +14 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.d.ts +26 -0
- package/dist/sdk/module/sim-sdk-static-client-factory.js +59 -0
- package/dist/sdk/send-patch.d.ts +8 -0
- package/dist/sdk/send-patch.js +11 -0
- package/dist/service/aws/caller/sim-aws-caller-resolver.d.ts +28 -6
- package/dist/service/aws/caller/sim-aws-caller-resolver.js +18 -11
- package/dist/service/aws/caller/sim-aws-caller.d.ts +9 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.d.ts +19 -0
- package/dist/service/aws/caller/sim-aws-resolved-caller.js +26 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.d.ts +6 -0
- package/dist/service/aws/factory/sim-aws-account-region-service-builder.js +3 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.d.ts +6 -0
- package/dist/service/aws/factory/sim-aws-account-service-cache.js +5 -0
- package/dist/service/aws/factory/sim-aws-service-factory.d.ts +5 -0
- package/dist/service/aws/factory/sim-aws-service-factory.js +6 -0
- package/dist/service/aws/sim-aws-properties.d.ts +15 -0
- package/dist/service/aws/sim-aws.js +2 -1
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.d.ts +30 -7
- package/dist/service/iam/authorize/context/sim-iam-auth-z-caller-context-builder.js +20 -11
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.d.ts +2 -1
- package/dist/service/iam/authorize/context/sim-iam-auth-z-context-builder.js +5 -1
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.d.ts +10 -0
- package/dist/service/iam/authorize/match/sim-iam-policy-principal-matcher.js +15 -3
- package/dist/service/iam/authorize/sim-iam-account-auth-z.d.ts +11 -4
- package/dist/service/iam/authorize/sim-iam-account-auth-z.js +5 -3
- package/dist/service/iam/authorize/sim-iam-authorizer.d.ts +2 -1
- package/dist/service/iam/authorize/sim-iam-authorizer.js +1 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.d.ts +9 -0
- package/dist/service/iam/role/sim-iam-role-with-policy.factory.js +4 -2
- package/dist/service/iam/sim-iam-account-parts.d.ts +8 -0
- package/dist/service/iam/sim-iam-account-parts.js +1 -0
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.d.ts +9 -2
- package/dist/service/sts/auth-z/assume-role-auth-z-coordinator.js +2 -2
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.d.ts +10 -4
- package/dist/service/sts/auth-z/assume-role-source-account-auth-z.js +8 -7
- package/dist/service/sts/auth-z/assume-role-target-auth-z.d.ts +2 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.d.ts +6 -2
- package/dist/service/sts/auth-z/assume-role-trust-policy-authorizer.js +14 -11
- package/dist/service/sts/command/assume-role/assume-role.handler.d.ts +5 -1
- package/dist/service/sts/command/assume-role/assume-role.handler.js +5 -2
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.d.ts +5 -1
- package/dist/service/sts/command/get-caller-identity/get-caller-identity.handler.js +4 -1
- package/dist/service/sts/service-role/sim-service-role.js +5 -1
- package/dist/service/sts/sim-sts.d.ts +9 -1
- package/dist/service/sts/sim-sts.js +4 -0
- package/docs/README.md +54 -0
- package/docs/ai-skill/README.md +69 -0
- package/docs/cli/README.md +284 -0
- package/docs/factories/README.md +140 -0
- package/docs/lint/README.md +176 -0
- package/docs/non-aws-dependencies/README.md +261 -0
- package/docs/sdk/README.md +272 -0
- package/docs/serve/README.md +1126 -0
- package/docs/services/acm/README.md +823 -0
- package/docs/services/apigateway/README.md +1919 -0
- package/docs/services/apigatewayv2/README.md +2678 -0
- package/docs/services/athena/README.md +1044 -0
- package/docs/services/bedrock/README.md +350 -0
- package/docs/services/cloudformation/README.md +3440 -0
- package/docs/services/cloudfront/README.md +2874 -0
- package/docs/services/cloudwatch/README.md +420 -0
- package/docs/services/cognito/README.md +4923 -0
- package/docs/services/dynamodb/README.md +3784 -0
- package/docs/services/ecr/README.md +302 -0
- package/docs/services/ecs/README.md +2010 -0
- package/docs/services/elbv2/README.md +1897 -0
- package/docs/services/eventbridge/README.md +1110 -0
- package/docs/services/firehose/README.md +805 -0
- package/docs/services/glue/README.md +501 -0
- package/docs/services/iam/README.md +1283 -0
- package/docs/services/kinesis/README.md +454 -0
- package/docs/services/kms/README.md +659 -0
- package/docs/services/lambda/README.md +3645 -0
- package/docs/services/logs/README.md +868 -0
- package/docs/services/organizations/README.md +574 -0
- package/docs/services/personalize/README.md +835 -0
- package/docs/services/rekognition/README.md +1070 -0
- package/docs/services/route53/README.md +1783 -0
- package/docs/services/s3/README.md +3024 -0
- package/docs/services/scheduler/README.md +806 -0
- package/docs/services/secretsmanager/README.md +667 -0
- package/docs/services/ses/README.md +1011 -0
- package/docs/services/sns/README.md +1904 -0
- package/docs/services/sqs/README.md +1185 -0
- package/docs/services/ssm/README.md +1072 -0
- package/docs/services/stepfunctions/README.md +1332 -0
- package/docs/services/sts/README.md +245 -0
- package/docs/services/wafv2/README.md +1378 -0
- package/docs/terraform/README.md +248 -0
- package/docs/time/README.md +284 -0
- package/llms.txt +59 -0
- package/package.json +7 -4
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
# Simulated SNS
|
|
2
|
+
|
|
3
|
+
Yulin includes a simulated Amazon SNS for tests and local development. Topics are held in memory and
|
|
4
|
+
every operation is authorized by simulated IAM.
|
|
5
|
+
|
|
6
|
+
Standard topics only. SNS-specific types are imported from the `@kensio/yulin/sns` subpath.
|
|
7
|
+
|
|
8
|
+
A message published to a topic is delivered to every queue subscribed to it, invokes every Lambda
|
|
9
|
+
function subscribed to it, and is recorded as an SMS for every phone number subscribed to it. Only
|
|
10
|
+
those three protocols are simulated. A message published straight to a phone number is recorded
|
|
11
|
+
as an SMS a test can assert on.
|
|
12
|
+
|
|
13
|
+
## Creating a topic and publishing to it
|
|
14
|
+
|
|
15
|
+
```typescript sim-sns-create-and-publish
|
|
16
|
+
/**
|
|
17
|
+
* Creating a simulated topic and publishing a message to it.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { CreateTopicCommand, PublishCommand } from "@aws-sdk/client-sns";
|
|
21
|
+
|
|
22
|
+
import { SimAws } from "@kensio/yulin";
|
|
23
|
+
|
|
24
|
+
const simAws = new SimAws();
|
|
25
|
+
const sns = simAws.sns();
|
|
26
|
+
|
|
27
|
+
const { TopicArn } = await sns.createTopic(
|
|
28
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
console.log(TopicArn); // "arn:aws:sns:us-east-1:888888888888:orders"
|
|
32
|
+
|
|
33
|
+
const { MessageId } = await sns.publish(
|
|
34
|
+
new PublishCommand({
|
|
35
|
+
TopicArn,
|
|
36
|
+
Message: "order-1",
|
|
37
|
+
Subject: "New order",
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
console.log(MessageId !== undefined); // true
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
A topic ARN is `arn:aws:sns:<region>:<account-id>:<name>`. An SNS ARN has no resource type in it.
|
|
45
|
+
The topic name follows the account id directly, the same as an SQS queue ARN.
|
|
46
|
+
|
|
47
|
+
`CreateTopic` is idempotent, as it is on real AWS. A second request for the same name returns the
|
|
48
|
+
existing topic's ARN and leaves that topic alone, and the attributes the second request carries are
|
|
49
|
+
ignored. SQS is stricter, and fails a repeated `CreateQueue` whose attributes differ.
|
|
50
|
+
|
|
51
|
+
A topic name is up to 256 characters of alphanumerics, hyphens and underscores. Anything else is
|
|
52
|
+
refused with `InvalidParameterException`, including a name ending in `.fifo`.
|
|
53
|
+
|
|
54
|
+
`DeleteTopic` removes the topic and frees its name at once, and a topic can be recreated straight
|
|
55
|
+
away. Deleting a topic that was never there succeeds, as it does on real SNS.
|
|
56
|
+
|
|
57
|
+
## Topic attributes
|
|
58
|
+
|
|
59
|
+
`GetTopicAttributes` returns everything the topic has. There is no list of attribute names on the
|
|
60
|
+
request, unlike the SQS command of the same shape.
|
|
61
|
+
|
|
62
|
+
`SetTopicAttributes` sets one attribute per request. The two with behaviour behind them are
|
|
63
|
+
`DisplayName` and `Policy`.
|
|
64
|
+
|
|
65
|
+
```typescript sim-sns-topic-attributes
|
|
66
|
+
/**
|
|
67
|
+
* Reading and changing the attributes of a simulated topic.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import {
|
|
71
|
+
CreateTopicCommand,
|
|
72
|
+
GetTopicAttributesCommand,
|
|
73
|
+
SetTopicAttributesCommand,
|
|
74
|
+
} from "@aws-sdk/client-sns";
|
|
75
|
+
|
|
76
|
+
import { SimAws } from "@kensio/yulin";
|
|
77
|
+
|
|
78
|
+
const simAws = new SimAws();
|
|
79
|
+
const sns = simAws.sns();
|
|
80
|
+
|
|
81
|
+
const { TopicArn } = await sns.createTopic(
|
|
82
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
await sns.setTopicAttributes(
|
|
86
|
+
new SetTopicAttributesCommand({
|
|
87
|
+
TopicArn,
|
|
88
|
+
AttributeName: "DisplayName",
|
|
89
|
+
AttributeValue: "Orders",
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const read = await sns.getTopicAttributes(
|
|
94
|
+
new GetTopicAttributesCommand({ TopicArn }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
console.log(read.Attributes?.["DisplayName"]); // "Orders"
|
|
98
|
+
console.log(read.Attributes?.["Owner"]); // "888888888888"
|
|
99
|
+
console.log(read.Attributes?.["SubscriptionsConfirmed"]); // "0"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`DisplayName` is reported as an empty string for a topic that has never had one set, as real SNS
|
|
103
|
+
reports it. The three subscription counts are reported as zero, the counts a topic with no
|
|
104
|
+
subscriptions has.
|
|
105
|
+
|
|
106
|
+
An attribute real SNS has and this simulation gives no behaviour to is refused by name rather than
|
|
107
|
+
taken and ignored. That covers `FifoTopic`, `KmsMasterKeyId`, `SignatureVersion`, `TracingConfig`,
|
|
108
|
+
`ArchivePolicy`, `DeliveryPolicy`, `ContentBasedDeduplication` and the delivery status logging
|
|
109
|
+
attributes such as `SQSSuccessFeedbackRoleArn`. A topic that appeared to accept `KmsMasterKeyId`
|
|
110
|
+
would look encrypted to the request that set it and be plain to everything else.
|
|
111
|
+
|
|
112
|
+
## Publishing
|
|
113
|
+
|
|
114
|
+
`Publish` takes a `Message`, an optional `Subject`, and message attributes. A message and its
|
|
115
|
+
attributes together may be up to 256 KB.
|
|
116
|
+
|
|
117
|
+
```typescript sim-sns-message-attributes
|
|
118
|
+
/**
|
|
119
|
+
* Message attributes on a published message.
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
import { CreateTopicCommand, PublishCommand } from "@aws-sdk/client-sns";
|
|
123
|
+
|
|
124
|
+
import { SimAws } from "@kensio/yulin";
|
|
125
|
+
|
|
126
|
+
const simAws = new SimAws();
|
|
127
|
+
const sns = simAws.sns();
|
|
128
|
+
|
|
129
|
+
const { TopicArn } = await sns.createTopic(
|
|
130
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const published = await sns.publish(
|
|
134
|
+
new PublishCommand({
|
|
135
|
+
TopicArn,
|
|
136
|
+
Message: "order-1",
|
|
137
|
+
MessageAttributes: {
|
|
138
|
+
tenant: { DataType: "String", StringValue: "acme" },
|
|
139
|
+
attempt: { DataType: "Number", StringValue: "1" },
|
|
140
|
+
regions: {
|
|
141
|
+
DataType: "String.Array",
|
|
142
|
+
StringValue: JSON.stringify(["eu-west-2", "us-east-1"]),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
console.log(published.MessageId !== undefined); // true
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The name and data type rules are the real ones. A data type is `String`, `String.Array`, `Number` or
|
|
152
|
+
`Binary`, and each takes a custom label after a dot, so `Number.int` is a number as far as the rules
|
|
153
|
+
go. A reserved `AWS.` or `Amazon.` prefix on a name, a data type built on none of the four, or a
|
|
154
|
+
value that disagrees with its data type is refused. A test finds any of those without going near AWS. The two reserved names real
|
|
155
|
+
SNS defines for SMS, `AWS.SNS.SMS.SenderID` and `AWS.SNS.SMS.SMSType`, are the exception.
|
|
156
|
+
[Sending an SMS](#sending-an-sms) covers those.
|
|
157
|
+
|
|
158
|
+
A `Subject` is UTF-8 text with no line breaks or control characters, of fewer than 100 characters.
|
|
159
|
+
That is the contract real SNS states. A subject of exactly 100 characters is already too long. A
|
|
160
|
+
publish with no `Message`, or with one over the size limit, is refused with
|
|
161
|
+
`InvalidParameterException`.
|
|
162
|
+
|
|
163
|
+
`PublishBatch` takes up to ten entries. An entry that fails on its own is reported in `Failed` while
|
|
164
|
+
the rest of the batch goes through, as real SNS reports it. An empty batch, more than ten entries, a
|
|
165
|
+
malformed entry id or two entries sharing an id fail the whole request.
|
|
166
|
+
|
|
167
|
+
The size limit is the one thing a batch is held to as a whole. Ten entries each just inside it are
|
|
168
|
+
one batch far outside it, and a single entry over it fails the whole batch with
|
|
169
|
+
`BatchRequestTooLongException`. The response singles out no entry.
|
|
170
|
+
|
|
171
|
+
```typescript sim-sns-publish-batch
|
|
172
|
+
/**
|
|
173
|
+
* A batch publish where one entry fails on its own.
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
import { CreateTopicCommand, PublishBatchCommand } from "@aws-sdk/client-sns";
|
|
177
|
+
|
|
178
|
+
import { SimAws } from "@kensio/yulin";
|
|
179
|
+
|
|
180
|
+
const simAws = new SimAws();
|
|
181
|
+
const sns = simAws.sns();
|
|
182
|
+
|
|
183
|
+
const { TopicArn } = await sns.createTopic(
|
|
184
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const published = await sns.publishBatch(
|
|
188
|
+
new PublishBatchCommand({
|
|
189
|
+
TopicArn,
|
|
190
|
+
PublishBatchRequestEntries: [
|
|
191
|
+
{ Id: "one", Message: "order-1" },
|
|
192
|
+
{
|
|
193
|
+
Id: "two",
|
|
194
|
+
Message: "order-2",
|
|
195
|
+
// "Map" is not an SNS message attribute data type.
|
|
196
|
+
MessageAttributes: { tenant: { DataType: "Map", StringValue: "acme" } },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
console.log(published.Successful?.map((entry) => entry.Id)); // ["one"]
|
|
203
|
+
console.log(published.Failed?.[0]?.Code); // "InvalidParameterValueException"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Sending an SMS
|
|
207
|
+
|
|
208
|
+
A `Publish` that names a `PhoneNumber` sends an SMS to that number. The message stays inside the
|
|
209
|
+
simulation. Simulated SNS records what it would have texted, and `sentSmsMessages()` on the service
|
|
210
|
+
reads the records back, oldest first. A topic texts a number as well, under
|
|
211
|
+
[texting a subscribed number](#texting-a-subscribed-number).
|
|
212
|
+
|
|
213
|
+
```typescript sim-sns-sms
|
|
214
|
+
/**
|
|
215
|
+
* Publishing an SMS to a phone number and reading the record back.
|
|
216
|
+
*/
|
|
217
|
+
|
|
218
|
+
import {
|
|
219
|
+
CheckIfPhoneNumberIsOptedOutCommand,
|
|
220
|
+
PublishCommand,
|
|
221
|
+
} from "@aws-sdk/client-sns";
|
|
222
|
+
|
|
223
|
+
import { SimAws } from "@kensio/yulin";
|
|
224
|
+
|
|
225
|
+
const simAws = new SimAws();
|
|
226
|
+
const sns = simAws.sns();
|
|
227
|
+
|
|
228
|
+
const { MessageId } = await sns.publish(
|
|
229
|
+
new PublishCommand({
|
|
230
|
+
PhoneNumber: "+15550100",
|
|
231
|
+
Message: "Your code is 123456",
|
|
232
|
+
MessageAttributes: {
|
|
233
|
+
"AWS.SNS.SMS.SenderID": { DataType: "String", StringValue: "Orders" },
|
|
234
|
+
"AWS.SNS.SMS.SMSType": {
|
|
235
|
+
DataType: "String",
|
|
236
|
+
StringValue: "Transactional",
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
}),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const [sent] = sns.sentSmsMessages();
|
|
243
|
+
|
|
244
|
+
console.log(sent?.phoneNumber); // "+15550100"
|
|
245
|
+
console.log(sent?.message); // "Your code is 123456"
|
|
246
|
+
console.log(sent?.senderId); // "Orders"
|
|
247
|
+
console.log(sent?.suppressed); // false
|
|
248
|
+
console.log(sent?.messageId === MessageId); // true
|
|
249
|
+
|
|
250
|
+
// On real SNS a recipient opts out by replying STOP. Nothing in a test process
|
|
251
|
+
// can reply, so the simulator does it.
|
|
252
|
+
sns.optOutPhoneNumber("+15550100");
|
|
253
|
+
|
|
254
|
+
await sns.publish(
|
|
255
|
+
new PublishCommand({
|
|
256
|
+
PhoneNumber: "+15550100",
|
|
257
|
+
Message: "Your code is 654321",
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
const [, stopped] = sns.sentSmsMessages();
|
|
262
|
+
|
|
263
|
+
console.log(stopped?.suppressed); // true
|
|
264
|
+
|
|
265
|
+
const { isOptedOut } = await sns.checkIfPhoneNumberIsOptedOut(
|
|
266
|
+
new CheckIfPhoneNumberIsOptedOutCommand({ phoneNumber: "+15550100" }),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
console.log(isOptedOut); // true
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The number is E.164, a plus sign followed by up to fifteen digits. Anything else is refused with
|
|
273
|
+
`InvalidParameterException`. A publish naming both a `TopicArn` and a `PhoneNumber` is refused as
|
|
274
|
+
well, since one of the two would otherwise win silently.
|
|
275
|
+
|
|
276
|
+
`AWS.SNS.SMS.SenderID` and `AWS.SNS.SMS.SMSType` are the two reserved attributes a publish may carry.
|
|
277
|
+
Both are recorded, and the record reads them back as `senderId` and `smsType`. The other reserved SMS
|
|
278
|
+
attributes are refused by name. `AWS.SNS.SMS.MaxPrice` caps what a message may cost, and
|
|
279
|
+
`AWS.MM.SMS.OriginationNumber`, `AWS.MM.SMS.EntityId` and `AWS.MM.SMS.TemplateId` pick the routing
|
|
280
|
+
and the registration it travels under. Each one changes a real send. Here it would be inert.
|
|
281
|
+
|
|
282
|
+
Real SNS puts a number on the opt-out list when the recipient replies STOP, and its API only takes
|
|
283
|
+
numbers back off. Standing in for the handset is the simulator's job. `optOutPhoneNumber` on the
|
|
284
|
+
service is what puts a number on the list, the same kind of accessor as `verifyIdentity` on simulated
|
|
285
|
+
SES.
|
|
286
|
+
|
|
287
|
+
A publish to a number on the list succeeds and answers with a `MessageId`, as it does on real SNS.
|
|
288
|
+
The record for that message says `suppressed`. A test can then tell a message that would have arrived
|
|
289
|
+
from one the opt-out list stopped. `CheckIfPhoneNumberIsOptedOutCommand` and
|
|
290
|
+
`ListPhoneNumbersOptedOutCommand` read the list, and `OptInPhoneNumberCommand` takes a number back
|
|
291
|
+
off it. Real SNS allows an opt-in once every thirty days per number, and that limit is absent here.
|
|
292
|
+
|
|
293
|
+
The records and the opt-out list belong to one account and region, the way topics do. A message
|
|
294
|
+
published in `eu-west-2` is invisible from `us-east-1`.
|
|
295
|
+
|
|
296
|
+
## Subscriptions
|
|
297
|
+
|
|
298
|
+
`Subscribe` with the `sqs` protocol and a queue ARN, the `lambda` protocol and a function ARN, or the
|
|
299
|
+
`sms` protocol and a phone number, answers with a subscription ARN straight away. There is no
|
|
300
|
+
confirmation step for any of the three, as there is none on real SNS. The subscription is confirmed
|
|
301
|
+
the moment it exists.
|
|
302
|
+
|
|
303
|
+
```typescript sim-sns-subscriptions
|
|
304
|
+
/**
|
|
305
|
+
* Subscribing a queue to a simulated topic, and reading the subscription back.
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
import {
|
|
309
|
+
CreateTopicCommand,
|
|
310
|
+
GetSubscriptionAttributesCommand,
|
|
311
|
+
ListSubscriptionsByTopicCommand,
|
|
312
|
+
SetSubscriptionAttributesCommand,
|
|
313
|
+
SubscribeCommand,
|
|
314
|
+
UnsubscribeCommand,
|
|
315
|
+
} from "@aws-sdk/client-sns";
|
|
316
|
+
import { CreateQueueCommand } from "@aws-sdk/client-sqs";
|
|
317
|
+
|
|
318
|
+
import { SimAws } from "@kensio/yulin";
|
|
319
|
+
|
|
320
|
+
const simAws = new SimAws();
|
|
321
|
+
const sns = simAws.sns();
|
|
322
|
+
|
|
323
|
+
const { TopicArn } = await sns.createTopic(
|
|
324
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await simAws
|
|
328
|
+
.sqs()
|
|
329
|
+
.createQueue(new CreateQueueCommand({ QueueName: "order-consumer" }));
|
|
330
|
+
|
|
331
|
+
const queueArn = `arn:aws:sqs:${simAws.defaultRegionName}:${simAws.defaultAccountId}:order-consumer`;
|
|
332
|
+
|
|
333
|
+
// An sqs subscription needs no confirmation, so the ARN comes back at once
|
|
334
|
+
// rather than "pending confirmation".
|
|
335
|
+
const { SubscriptionArn } = await sns.subscribe(
|
|
336
|
+
new SubscribeCommand({ TopicArn, Protocol: "sqs", Endpoint: queueArn }),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
console.log(SubscriptionArn?.startsWith(`${TopicArn ?? ""}:`)); // true
|
|
340
|
+
|
|
341
|
+
const listed = await sns.listSubscriptionsByTopic(
|
|
342
|
+
new ListSubscriptionsByTopicCommand({ TopicArn }),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
console.log(listed.Subscriptions?.[0]?.Endpoint === queueArn); // true
|
|
346
|
+
|
|
347
|
+
// The subscription reports what it is and how it delivers.
|
|
348
|
+
const read = await sns.getSubscriptionAttributes(
|
|
349
|
+
new GetSubscriptionAttributesCommand({ SubscriptionArn }),
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
console.log(read.Attributes?.["PendingConfirmation"]); // "false"
|
|
353
|
+
console.log(read.Attributes?.["RawMessageDelivery"]); // "false"
|
|
354
|
+
|
|
355
|
+
await sns.setSubscriptionAttributes(
|
|
356
|
+
new SetSubscriptionAttributesCommand({
|
|
357
|
+
SubscriptionArn,
|
|
358
|
+
AttributeName: "RawMessageDelivery",
|
|
359
|
+
AttributeValue: "true",
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
await sns.unsubscribe(new UnsubscribeCommand({ SubscriptionArn }));
|
|
364
|
+
|
|
365
|
+
console.log(simAws.sns().topicSubscriptions("orders").length); // 0
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
A subscription ARN is the topic's ARN with an opaque id on the end. That is the only handle on a
|
|
369
|
+
subscription. `Unsubscribe`, `GetSubscriptionAttributes` and `SetSubscriptionAttributes` all name one
|
|
370
|
+
by it.
|
|
371
|
+
|
|
372
|
+
The endpoint has to be what the protocol implies, and it is checked when the subscription is made. A
|
|
373
|
+
queue ARN over the `lambda` protocol is refused, as it is on real SNS, and so is an `sms` endpoint
|
|
374
|
+
outside E.164. A function ARN may carry a version number or an alias name on the end
|
|
375
|
+
(`arn:aws:lambda:us-east-1:888888888888:function:orders:live`), and messages then go to the version
|
|
376
|
+
that qualifier names.
|
|
377
|
+
|
|
378
|
+
Subscribing the same endpoint to the same topic twice answers with the subscription that is already
|
|
379
|
+
there, as real SNS does. The attributes the repeated request carries are ignored, the same way a
|
|
380
|
+
repeated `CreateTopic` leaves the existing topic alone. They are still validated, and a repeated
|
|
381
|
+
request naming an attribute this simulation has no place for is still refused.
|
|
382
|
+
|
|
383
|
+
`RawMessageDelivery` can be set on the `Subscribe` request or afterwards with
|
|
384
|
+
`SetSubscriptionAttributes`, and its value is `"true"` or `"false"`. Anything else is refused.
|
|
385
|
+
Reading it as false would silently change what the subscription delivers. The other two attributes with behaviour
|
|
386
|
+
behind them are `FilterPolicy`
|
|
387
|
+
and `FilterPolicyScope`, under
|
|
388
|
+
[filtering what a subscription receives](#filtering-what-a-subscription-receives).
|
|
389
|
+
|
|
390
|
+
`Unsubscribe` of an ARN that names no subscription is `NotFoundException`, so unsubscribing the same
|
|
391
|
+
ARN twice fails the second time. `DeleteTopic` removes the topic's subscriptions along with it, and a
|
|
392
|
+
topic recreated under the same name starts with none.
|
|
393
|
+
|
|
394
|
+
`GetTopicAttributes` counts the topic's subscriptions in `SubscriptionsConfirmed`, and counts the
|
|
395
|
+
ones that have been unsubscribed in `SubscriptionsDeleted`. `SubscriptionsPending` is always zero,
|
|
396
|
+
because no protocol simulated needs a confirmation.
|
|
397
|
+
|
|
398
|
+
Only the `sqs`, `lambda` and `sms` protocols are simulated. Every other protocol real SNS has is
|
|
399
|
+
refused by name at `Subscribe` time, with the reason it is missing. The alternative would be a
|
|
400
|
+
subscription that never delivers anything.
|
|
401
|
+
|
|
402
|
+
## Delivering to a queue
|
|
403
|
+
|
|
404
|
+
Publishing to a topic delivers one message to each subscribed queue. That happens after the publish
|
|
405
|
+
has been answered, as it does on real SNS. A test waits for it with
|
|
406
|
+
`simAws.backgroundTasksComplete()` before receiving.
|
|
407
|
+
|
|
408
|
+
```typescript sim-sns-fan-out
|
|
409
|
+
/**
|
|
410
|
+
* Publishing once and having two queues each receive a copy.
|
|
411
|
+
*/
|
|
412
|
+
|
|
413
|
+
import {
|
|
414
|
+
CreateTopicCommand,
|
|
415
|
+
PublishCommand,
|
|
416
|
+
SubscribeCommand,
|
|
417
|
+
} from "@aws-sdk/client-sns";
|
|
418
|
+
import {
|
|
419
|
+
CreateQueueCommand,
|
|
420
|
+
ReceiveMessageCommand,
|
|
421
|
+
SetQueueAttributesCommand,
|
|
422
|
+
} from "@aws-sdk/client-sqs";
|
|
423
|
+
|
|
424
|
+
import { SimAws } from "@kensio/yulin";
|
|
425
|
+
|
|
426
|
+
const simAws = new SimAws();
|
|
427
|
+
const sns = simAws.sns();
|
|
428
|
+
const sqs = simAws.sqs();
|
|
429
|
+
|
|
430
|
+
const { TopicArn } = await sns.createTopic(
|
|
431
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Create a queue that admits SNS to send to it for this topic, and subscribe
|
|
436
|
+
* it. The queue policy is what allows the delivery, and it is checked on every
|
|
437
|
+
* message rather than remembered from subscribe time.
|
|
438
|
+
*/
|
|
439
|
+
async function subscribeQueue(queueName: string): Promise<string> {
|
|
440
|
+
const { QueueUrl } = await sqs.createQueue(
|
|
441
|
+
new CreateQueueCommand({ QueueName: queueName }),
|
|
442
|
+
);
|
|
443
|
+
const queueArn = `arn:aws:sqs:${simAws.defaultRegionName}:${simAws.defaultAccountId}:${queueName}`;
|
|
444
|
+
|
|
445
|
+
await sqs.setQueueAttributes(
|
|
446
|
+
new SetQueueAttributesCommand({
|
|
447
|
+
QueueUrl,
|
|
448
|
+
Attributes: {
|
|
449
|
+
Policy: JSON.stringify({
|
|
450
|
+
Version: "2012-10-17",
|
|
451
|
+
Statement: [
|
|
452
|
+
{
|
|
453
|
+
Effect: "Allow",
|
|
454
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
455
|
+
Action: "sqs:SendMessage",
|
|
456
|
+
Resource: queueArn,
|
|
457
|
+
Condition: { ArnLike: { "aws:SourceArn": TopicArn } },
|
|
458
|
+
},
|
|
459
|
+
],
|
|
460
|
+
}),
|
|
461
|
+
},
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
await sns.subscribe(
|
|
466
|
+
new SubscribeCommand({ TopicArn, Protocol: "sqs", Endpoint: queueArn }),
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
return QueueUrl ?? "";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const fulfilment = await subscribeQueue("fulfilment");
|
|
473
|
+
const audit = await subscribeQueue("audit");
|
|
474
|
+
|
|
475
|
+
await sns.publish(new PublishCommand({ TopicArn, Message: "order-1" }));
|
|
476
|
+
|
|
477
|
+
// Delivery happens after the publish is answered, as it does on real SNS.
|
|
478
|
+
await simAws.backgroundTasksComplete();
|
|
479
|
+
|
|
480
|
+
for (const QueueUrl of [fulfilment, audit]) {
|
|
481
|
+
const { Messages } = await sqs.receiveMessage(
|
|
482
|
+
new ReceiveMessageCommand({ QueueUrl }),
|
|
483
|
+
);
|
|
484
|
+
const envelope = JSON.parse(Messages?.[0]?.Body ?? "{}") as {
|
|
485
|
+
Type: string;
|
|
486
|
+
Message: string;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
console.log(envelope.Type); // "Notification"
|
|
490
|
+
console.log(envelope.Message); // "order-1"
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
The queue's policy is what allows the delivery. It has to admit `sns.amazonaws.com` for
|
|
495
|
+
`sqs:SendMessage`, and the `aws:SourceArn` condition is what keeps one topic's grant from opening the
|
|
496
|
+
queue to another. The policy is checked on every message rather than remembered from subscribe time,
|
|
497
|
+
and a permission taken away afterwards stops delivery.
|
|
498
|
+
|
|
499
|
+
The subscription is made without checking the queue, as it is on real SNS. A subscription to a queue
|
|
500
|
+
that is absent, or to one whose policy says no, is created and fails when a message is delivered to
|
|
501
|
+
it.
|
|
502
|
+
|
|
503
|
+
A failed delivery goes unreported to the publisher, as it does on real SNS. The publish is answered
|
|
504
|
+
with a `MessageId` before anything is delivered. The failure is recorded instead, and a queue that is
|
|
505
|
+
unexpectedly empty says why:
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
const [failure] = simAws.sns().deliveryFailures;
|
|
509
|
+
|
|
510
|
+
console.log(failure?.endpointArn);
|
|
511
|
+
console.log(failure?.reason);
|
|
512
|
+
console.log(failure?.wasRefused); // true when the queue policy said no
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
A queue in another account or another region receives a message the same way. Real SNS delivers
|
|
516
|
+
across both. Simulated S3 event notifications are stricter, since real S3 requires a destination
|
|
517
|
+
queue to be in the bucket's region, where a topic delivers to a queue in any region.
|
|
518
|
+
|
|
519
|
+
## The message a queue receives
|
|
520
|
+
|
|
521
|
+
By default the body is the SNS envelope, the JSON document real SNS wraps a published message in. A
|
|
522
|
+
consumer reads the message out of it with `JSON.parse(body).Message`:
|
|
523
|
+
|
|
524
|
+
```json
|
|
525
|
+
{
|
|
526
|
+
"Type": "Notification",
|
|
527
|
+
"MessageId": "0f2a0a49-9e3f-4d02-9e5f-2d9f0e5b6d51",
|
|
528
|
+
"TopicArn": "arn:aws:sns:us-east-1:888888888888:orders",
|
|
529
|
+
"Subject": "New order",
|
|
530
|
+
"Message": "order-1",
|
|
531
|
+
"Timestamp": "2026-01-01T00:00:00.000Z",
|
|
532
|
+
"SignatureVersion": "1",
|
|
533
|
+
"Signature": "...",
|
|
534
|
+
"SigningCertURL": "https://sns.us-east-1.yulin.invalid/SimulatedNotificationService-....pem",
|
|
535
|
+
"UnsubscribeURL": "https://sns.us-east-1.yulin.invalid/?Action=Unsubscribe&SubscriptionArn=...",
|
|
536
|
+
"MessageAttributes": { "tenant": { "Type": "String", "Value": "acme" } }
|
|
537
|
+
}
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
`Subject` and `MessageAttributes` are there only when the publish carried them. A binary message
|
|
541
|
+
attribute travels base64 encoded, since the envelope is JSON.
|
|
542
|
+
|
|
543
|
+
With `RawMessageDelivery` set to `true` on the subscription, the body is the published `Message` on
|
|
544
|
+
its own and the published message attributes arrive as SQS message attributes instead. Both matter,
|
|
545
|
+
because a consumer written for one breaks on the other.
|
|
546
|
+
|
|
547
|
+
## Delivering to a Lambda function
|
|
548
|
+
|
|
549
|
+
Subscribing a function with the `lambda` protocol invokes it once per published message, with the SNS
|
|
550
|
+
event shape. The function's resource policy has to allow `sns.amazonaws.com` to invoke it for the
|
|
551
|
+
topic. `AddPermission` grants that:
|
|
552
|
+
|
|
553
|
+
```typescript sim-sns-lambda-delivery
|
|
554
|
+
/**
|
|
555
|
+
* Invoking a simulated Lambda function from a simulated topic.
|
|
556
|
+
*/
|
|
557
|
+
|
|
558
|
+
import {
|
|
559
|
+
AddPermissionCommand,
|
|
560
|
+
CreateFunctionCommand,
|
|
561
|
+
} from "@aws-sdk/client-lambda";
|
|
562
|
+
import {
|
|
563
|
+
CreateTopicCommand,
|
|
564
|
+
PublishCommand,
|
|
565
|
+
SubscribeCommand,
|
|
566
|
+
} from "@aws-sdk/client-sns";
|
|
567
|
+
import { SimAws } from "@kensio/yulin";
|
|
568
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
569
|
+
|
|
570
|
+
interface SnsRecord {
|
|
571
|
+
EventSource: string;
|
|
572
|
+
Sns: { Subject: string | null; Message: string };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const simAws = new SimAws();
|
|
576
|
+
const sns = simAws.sns();
|
|
577
|
+
const consumerArn = `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:order-consumer`;
|
|
578
|
+
|
|
579
|
+
const { TopicArn } = await sns.createTopic(
|
|
580
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
await simAws.lambda().createFunction(
|
|
584
|
+
new CreateFunctionCommand({
|
|
585
|
+
FunctionName: "order-consumer",
|
|
586
|
+
Role: `arn:aws:iam::${simAws.defaultAccountId}:role/OrderConsumerRole`,
|
|
587
|
+
Code: {
|
|
588
|
+
ZipFile: makeLambdaZipFileInput((event: { Records: [SnsRecord] }) => {
|
|
589
|
+
const [record] = event.Records;
|
|
590
|
+
|
|
591
|
+
console.log(record.EventSource); // "aws:sns"
|
|
592
|
+
console.log(record.Sns.Subject); // "New order"
|
|
593
|
+
console.log(record.Sns.Message); // "order-1"
|
|
594
|
+
|
|
595
|
+
return "handled";
|
|
596
|
+
}),
|
|
597
|
+
},
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
// The function's resource policy is what allows the invocation, and it is
|
|
602
|
+
// checked on every message rather than remembered from subscribe time.
|
|
603
|
+
await simAws.lambda().addPermission(
|
|
604
|
+
new AddPermissionCommand({
|
|
605
|
+
FunctionName: "order-consumer",
|
|
606
|
+
StatementId: "AllowSns",
|
|
607
|
+
Action: "lambda:InvokeFunction",
|
|
608
|
+
Principal: "sns.amazonaws.com",
|
|
609
|
+
SourceArn: TopicArn,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
// A lambda subscription needs no confirmation either, so the ARN comes back at
|
|
614
|
+
// once.
|
|
615
|
+
await sns.subscribe(
|
|
616
|
+
new SubscribeCommand({ TopicArn, Protocol: "lambda", Endpoint: consumerArn }),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
await sns.publish(
|
|
620
|
+
new PublishCommand({ TopicArn, Subject: "New order", Message: "order-1" }),
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
// The invocation happens after the publish is answered, as it does on real SNS.
|
|
624
|
+
await simAws.backgroundTasksComplete();
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The permission is checked on every message rather than remembered from subscribe time, and one taken
|
|
628
|
+
away afterwards stops delivery. The subscription is made without checking the function, the same as a
|
|
629
|
+
queue subscription. A subscription to a function that is absent, or to one whose policy says no, is
|
|
630
|
+
created and fails when a message is delivered to it. Both failures are recorded on
|
|
631
|
+
`simAws.sns().deliveryFailures` the same way a queue's are, and a handler that throws is recorded
|
|
632
|
+
there too. Delivery to the topic's other subscriptions carries on.
|
|
633
|
+
|
|
634
|
+
A function in another account or another region is invoked the same way, on that account's own
|
|
635
|
+
resource policy.
|
|
636
|
+
|
|
637
|
+
An endpoint carrying a version number or an alias name delivers to the version it names, and the
|
|
638
|
+
grant it needs is one made on that same qualifier. `AddPermission` takes a `Qualifier` for it. The
|
|
639
|
+
version behind an alias runs, and `UpdateAlias` moves what an existing subscription reaches without
|
|
640
|
+
touching the subscription:
|
|
641
|
+
|
|
642
|
+
```typescript sim-sns-lambda-alias
|
|
643
|
+
/**
|
|
644
|
+
* Subscribing a simulated Lambda alias to a topic, so published messages reach
|
|
645
|
+
* the version the alias points at.
|
|
646
|
+
*/
|
|
647
|
+
|
|
648
|
+
import {
|
|
649
|
+
AddPermissionCommand,
|
|
650
|
+
CreateAliasCommand,
|
|
651
|
+
CreateFunctionCommand,
|
|
652
|
+
PublishVersionCommand,
|
|
653
|
+
} from "@aws-sdk/client-lambda";
|
|
654
|
+
import {
|
|
655
|
+
CreateTopicCommand,
|
|
656
|
+
PublishCommand,
|
|
657
|
+
SubscribeCommand,
|
|
658
|
+
} from "@aws-sdk/client-sns";
|
|
659
|
+
import { SimAws } from "@kensio/yulin";
|
|
660
|
+
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
|
|
661
|
+
|
|
662
|
+
const simAws = new SimAws();
|
|
663
|
+
const sns = simAws.sns();
|
|
664
|
+
const lambda = simAws.lambda();
|
|
665
|
+
const functionArn = `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:order-consumer`;
|
|
666
|
+
|
|
667
|
+
const { TopicArn } = await sns.createTopic(
|
|
668
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
await lambda.createFunction(
|
|
672
|
+
new CreateFunctionCommand({
|
|
673
|
+
FunctionName: "order-consumer",
|
|
674
|
+
Role: `arn:aws:iam::${simAws.defaultAccountId}:role/OrderConsumerRole`,
|
|
675
|
+
Code: {
|
|
676
|
+
ZipFile: makeLambdaZipFileInput((_event, context) => {
|
|
677
|
+
console.log(context.functionVersion); // "1", the version behind `live`
|
|
678
|
+
|
|
679
|
+
return "handled";
|
|
680
|
+
}),
|
|
681
|
+
},
|
|
682
|
+
}),
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const published = await lambda.publishVersion(
|
|
686
|
+
new PublishVersionCommand({ FunctionName: "order-consumer" }),
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
await lambda.createAlias(
|
|
690
|
+
new CreateAliasCommand({
|
|
691
|
+
FunctionName: "order-consumer",
|
|
692
|
+
Name: "live",
|
|
693
|
+
FunctionVersion: published.Version,
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// The grant is made on the alias, which is the resource the delivery names.
|
|
698
|
+
await lambda.addPermission(
|
|
699
|
+
new AddPermissionCommand({
|
|
700
|
+
FunctionName: "order-consumer",
|
|
701
|
+
Qualifier: "live",
|
|
702
|
+
StatementId: "AllowSns",
|
|
703
|
+
Action: "lambda:InvokeFunction",
|
|
704
|
+
Principal: "sns.amazonaws.com",
|
|
705
|
+
SourceArn: TopicArn,
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
await sns.subscribe(
|
|
710
|
+
new SubscribeCommand({
|
|
711
|
+
TopicArn,
|
|
712
|
+
Protocol: "lambda",
|
|
713
|
+
Endpoint: `${functionArn}:live`,
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
await sns.publish(new PublishCommand({ TopicArn, Message: "order-1" }));
|
|
718
|
+
await simAws.backgroundTasksComplete();
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
The qualifier is resolved on every delivery, along with the resource policy. An endpoint naming a
|
|
722
|
+
version or an alias that is not there is recorded on `simAws.sns().deliveryFailures` the way a
|
|
723
|
+
missing function is. Real SNS checks no endpoint at `Subscribe` time, so this is where a subscription
|
|
724
|
+
pointing at nothing shows up.
|
|
725
|
+
|
|
726
|
+
## The event a Lambda function receives
|
|
727
|
+
|
|
728
|
+
`Records` always holds exactly one entry, even when the message came from a `PublishBatch`. Real SNS
|
|
729
|
+
gives each published message its own invocation. A handler looping over `Records` sees one message
|
|
730
|
+
per call.
|
|
731
|
+
|
|
732
|
+
```json
|
|
733
|
+
{
|
|
734
|
+
"Records": [
|
|
735
|
+
{
|
|
736
|
+
"EventSource": "aws:sns",
|
|
737
|
+
"EventVersion": "1.0",
|
|
738
|
+
"EventSubscriptionArn": "arn:aws:sns:us-east-1:888888888888:orders:8f1c...",
|
|
739
|
+
"Sns": {
|
|
740
|
+
"Type": "Notification",
|
|
741
|
+
"MessageId": "0f2a0a49-9e3f-4d02-9e5f-2d9f0e5b6d51",
|
|
742
|
+
"TopicArn": "arn:aws:sns:us-east-1:888888888888:orders",
|
|
743
|
+
"Subject": "New order",
|
|
744
|
+
"Message": "order-1",
|
|
745
|
+
"Timestamp": "2026-01-01T00:00:00.000Z",
|
|
746
|
+
"SignatureVersion": "1",
|
|
747
|
+
"Signature": "...",
|
|
748
|
+
"SigningCertUrl": "https://sns.us-east-1.yulin.invalid/SimulatedNotificationService-....pem",
|
|
749
|
+
"UnsubscribeUrl": "https://sns.us-east-1.yulin.invalid/?Action=Unsubscribe&SubscriptionArn=...",
|
|
750
|
+
"MessageAttributes": { "tenant": { "Type": "String", "Value": "acme" } }
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
]
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
Two fields are spelled differently from the envelope a queue receives. The envelope has
|
|
758
|
+
`SigningCertURL` and `UnsubscribeURL`, and the Lambda event has `SigningCertUrl` and
|
|
759
|
+
`UnsubscribeUrl`. That is real SNS behaviour, and a consumer has to be written against it. `Subject`
|
|
760
|
+
and `MessageAttributes` are always present here as well, carrying `null` and `{}` for a publish that
|
|
761
|
+
omitted them, where the envelope leaves both out.
|
|
762
|
+
|
|
763
|
+
`RawMessageDelivery` has no effect on a `lambda` subscription. Real SNS treats it as an SQS and HTTP
|
|
764
|
+
setting, and a function is invoked with the whole event whatever the value.
|
|
765
|
+
|
|
766
|
+
## Texting a subscribed number
|
|
767
|
+
|
|
768
|
+
Subscribing a phone number with the `sms` protocol records one SMS per published message the
|
|
769
|
+
subscription accepts. The records are the ones `sentSmsMessages()` reads back, alongside those a
|
|
770
|
+
publish straight to a `PhoneNumber` leaves.
|
|
771
|
+
|
|
772
|
+
```typescript sim-sns-sms-subscription
|
|
773
|
+
/**
|
|
774
|
+
* Fanning a published message out to a subscribed phone number.
|
|
775
|
+
*/
|
|
776
|
+
|
|
777
|
+
import {
|
|
778
|
+
CreateTopicCommand,
|
|
779
|
+
PublishCommand,
|
|
780
|
+
SubscribeCommand,
|
|
781
|
+
} from "@aws-sdk/client-sns";
|
|
782
|
+
|
|
783
|
+
import { SimAws } from "@kensio/yulin";
|
|
784
|
+
|
|
785
|
+
const simAws = new SimAws();
|
|
786
|
+
const sns = simAws.sns();
|
|
787
|
+
|
|
788
|
+
const { TopicArn } = await sns.createTopic(
|
|
789
|
+
new CreateTopicCommand({ Name: "alerts" }),
|
|
790
|
+
);
|
|
791
|
+
|
|
792
|
+
// An sms subscription needs no confirmation either, so the ARN comes back at
|
|
793
|
+
// once.
|
|
794
|
+
const { SubscriptionArn } = await sns.subscribe(
|
|
795
|
+
new SubscribeCommand({
|
|
796
|
+
TopicArn,
|
|
797
|
+
Protocol: "sms",
|
|
798
|
+
Endpoint: "+15550100",
|
|
799
|
+
Attributes: { FilterPolicy: JSON.stringify({ severity: ["high"] }) },
|
|
800
|
+
}),
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
await sns.publish(
|
|
804
|
+
new PublishCommand({
|
|
805
|
+
TopicArn,
|
|
806
|
+
Subject: "Disk usage",
|
|
807
|
+
Message: "Disk full",
|
|
808
|
+
MessageAttributes: {
|
|
809
|
+
severity: { DataType: "String", StringValue: "high" },
|
|
810
|
+
},
|
|
811
|
+
}),
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// A topic delivers after the publish has been answered, as it does on real
|
|
815
|
+
// SNS.
|
|
816
|
+
await simAws.backgroundTasksComplete();
|
|
817
|
+
|
|
818
|
+
const [sms] = sns.sentSmsMessages();
|
|
819
|
+
|
|
820
|
+
console.log(sms?.phoneNumber); // "+15550100"
|
|
821
|
+
console.log(sms?.message); // "Disk full"
|
|
822
|
+
console.log(sms?.topicArn === TopicArn); // true
|
|
823
|
+
console.log(sms?.subscriptionArn === SubscriptionArn); // true
|
|
824
|
+
console.log(sms?.suppressed); // false
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
The recorded body is the message as it was published. A handset receives the text on its own, and the
|
|
828
|
+
SNS envelope a queue receives and the `Subject` an email carries are both left off.
|
|
829
|
+
|
|
830
|
+
Each record names the topic and the subscription that produced it, in `topicArn` and
|
|
831
|
+
`subscriptionArn`. A record from a publish straight to a phone number leaves both undefined. That is
|
|
832
|
+
how a test tells one kind of send from the other. The `messageId` is the one the publish was answered
|
|
833
|
+
with, so every SMS one publish fanned out carries it.
|
|
834
|
+
|
|
835
|
+
A filter policy applies to an `sms` subscription the way it applies to any other. The number receives
|
|
836
|
+
the messages its own policy accepts, and the rest of the topic's subscriptions are unaffected.
|
|
837
|
+
|
|
838
|
+
A subscribed number on the opt-out list has its message recorded as `suppressed`, and the publish
|
|
839
|
+
still succeeds. The topic's other subscriptions receive theirs. `optOutPhoneNumber` on the service is
|
|
840
|
+
what puts a number on the list, under [sending an SMS](#sending-an-sms).
|
|
841
|
+
|
|
842
|
+
`Unsubscribe` stops the texting. A message published afterwards leaves no record for that number.
|
|
843
|
+
|
|
844
|
+
## Filtering what a subscription receives
|
|
845
|
+
|
|
846
|
+
A subscription with a `FilterPolicy` receives only the messages its policy matches. Every other
|
|
847
|
+
subscription of the topic is unaffected, and one subscriber filtering a message out leaves what
|
|
848
|
+
another receives alone. A policy applies to a subscribed function as it does to a subscribed queue.
|
|
849
|
+
It decides whether the message reaches the subscription at all, whatever the subscription delivers
|
|
850
|
+
to.
|
|
851
|
+
|
|
852
|
+
The policy is a JSON document of keys and the match conditions each one accepts. Separate keys are an
|
|
853
|
+
and, and the list a key holds is an or.
|
|
854
|
+
|
|
855
|
+
```typescript sim-sns-filter-policy
|
|
856
|
+
/**
|
|
857
|
+
* Two queues on one topic, each taking the messages its policy names.
|
|
858
|
+
*/
|
|
859
|
+
|
|
860
|
+
import {
|
|
861
|
+
CreateTopicCommand,
|
|
862
|
+
PublishCommand,
|
|
863
|
+
SubscribeCommand,
|
|
864
|
+
} from "@aws-sdk/client-sns";
|
|
865
|
+
import {
|
|
866
|
+
CreateQueueCommand,
|
|
867
|
+
ReceiveMessageCommand,
|
|
868
|
+
SetQueueAttributesCommand,
|
|
869
|
+
} from "@aws-sdk/client-sqs";
|
|
870
|
+
|
|
871
|
+
import { SimAws } from "@kensio/yulin";
|
|
872
|
+
|
|
873
|
+
const simAws = new SimAws();
|
|
874
|
+
const sns = simAws.sns();
|
|
875
|
+
const sqs = simAws.sqs();
|
|
876
|
+
|
|
877
|
+
const { TopicArn } = await sns.createTopic(
|
|
878
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* Subscribe a queue that admits SNS, with a filter policy of its own.
|
|
883
|
+
*/
|
|
884
|
+
async function subscribeQueue(
|
|
885
|
+
queueName: string,
|
|
886
|
+
filterPolicy: unknown,
|
|
887
|
+
): Promise<string> {
|
|
888
|
+
const { QueueUrl } = await sqs.createQueue(
|
|
889
|
+
new CreateQueueCommand({ QueueName: queueName }),
|
|
890
|
+
);
|
|
891
|
+
const queueArn = `arn:aws:sqs:${simAws.defaultRegionName}:${simAws.defaultAccountId}:${queueName}`;
|
|
892
|
+
|
|
893
|
+
await sqs.setQueueAttributes(
|
|
894
|
+
new SetQueueAttributesCommand({
|
|
895
|
+
QueueUrl,
|
|
896
|
+
Attributes: {
|
|
897
|
+
Policy: JSON.stringify({
|
|
898
|
+
Version: "2012-10-17",
|
|
899
|
+
Statement: [
|
|
900
|
+
{
|
|
901
|
+
Effect: "Allow",
|
|
902
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
903
|
+
Action: "sqs:SendMessage",
|
|
904
|
+
Resource: queueArn,
|
|
905
|
+
Condition: { ArnEquals: { "aws:SourceArn": TopicArn } },
|
|
906
|
+
},
|
|
907
|
+
],
|
|
908
|
+
}),
|
|
909
|
+
},
|
|
910
|
+
}),
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
await sns.subscribe(
|
|
914
|
+
new SubscribeCommand({
|
|
915
|
+
TopicArn,
|
|
916
|
+
Protocol: "sqs",
|
|
917
|
+
Endpoint: queueArn,
|
|
918
|
+
Attributes: { FilterPolicy: JSON.stringify(filterPolicy) },
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
return QueueUrl ?? "";
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const orders = await subscribeQueue("order-handling", { type: ["order"] });
|
|
926
|
+
const refunds = await subscribeQueue("refund-handling", { type: ["refund"] });
|
|
927
|
+
|
|
928
|
+
await sns.publish(
|
|
929
|
+
new PublishCommand({
|
|
930
|
+
TopicArn,
|
|
931
|
+
Message: "order-1",
|
|
932
|
+
MessageAttributes: { type: { DataType: "String", StringValue: "order" } },
|
|
933
|
+
}),
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
await simAws.backgroundTasksComplete();
|
|
937
|
+
|
|
938
|
+
const delivered = await sqs.receiveMessage(
|
|
939
|
+
new ReceiveMessageCommand({ QueueUrl: orders }),
|
|
940
|
+
);
|
|
941
|
+
const filtered = await sqs.receiveMessage(
|
|
942
|
+
new ReceiveMessageCommand({ QueueUrl: refunds }),
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
console.log(delivered.Messages?.length); // 1
|
|
946
|
+
console.log(filtered.Messages); // undefined
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
By default a policy is matched against the message attributes of the publish, under the
|
|
950
|
+
`MessageAttributes` scope. These are the operators:
|
|
951
|
+
|
|
952
|
+
| Condition | Matches |
|
|
953
|
+
| --------------------------------------- | ------------------------------------------- |
|
|
954
|
+
| `"order"` | that value exactly, case sensitively |
|
|
955
|
+
| `["order", "refund"]` | either value, since a list is an or |
|
|
956
|
+
| `{"prefix": "order-"}` | the start of the value |
|
|
957
|
+
| `{"suffix": ".csv"}` | the end of the value |
|
|
958
|
+
| `{"equals-ignore-case": "Order"}` | the value in any case |
|
|
959
|
+
| `{"anything-but": "order"}` | every value but that one |
|
|
960
|
+
| `{"anything-but": ["order", "refund"]}` | every value but those |
|
|
961
|
+
| `{"anything-but": {"prefix": "tmp-"}}` | every value not starting that way |
|
|
962
|
+
| `{"numeric": [">", 100]}` | a number, with `=`, `<`, `<=`, `>` and `>=` |
|
|
963
|
+
| `{"numeric": [">", 0, "<=", 100]}` | a number inside a range |
|
|
964
|
+
| `{"exists": true}` | the key being there, holding something |
|
|
965
|
+
| `{"exists": false}` | the key not being there |
|
|
966
|
+
|
|
967
|
+
Only `{"exists": false}` matches a key the message leaves out. Every other operator needs a value to
|
|
968
|
+
look at, `anything-but` included. An absent key holds no value to be anything but the excluded one.
|
|
969
|
+
|
|
970
|
+
`{"exists": false}` also needs the message to carry some other key, which real SNS states as well. A
|
|
971
|
+
publish with no message attributes at all matches no filter policy of the default scope, this one
|
|
972
|
+
included. Under the `MessageBody` scope the same goes for a body holding no keys. A body that fails
|
|
973
|
+
to parse as JSON matches no policy, whichever operator it uses.
|
|
974
|
+
|
|
975
|
+
Numeric matching applies to the `Number` message attribute data type, as it does on real SNS. Digits
|
|
976
|
+
published as a `String` are text. A `String.Array` attribute matches when any member of it does. A
|
|
977
|
+
`Binary` attribute matches no condition, since filtering is on text.
|
|
978
|
+
|
|
979
|
+
`$or` matches when either of two separate keys does, which the rest of a policy cannot say:
|
|
980
|
+
|
|
981
|
+
```json
|
|
982
|
+
{ "$or": [{ "type": ["order"] }, { "tenant": ["acme"] }] }
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Real SNS reads `$or` as an or only when it holds a list of at least two objects, none of which names
|
|
986
|
+
a reserved keyword such as `numeric` or `prefix`. Anything else is an attribute named `$or` there,
|
|
987
|
+
and the policy quietly stops being an or, matching no message at all. Each of those is refused here
|
|
988
|
+
when the policy is set.
|
|
989
|
+
|
|
990
|
+
### Filtering on the message body
|
|
991
|
+
|
|
992
|
+
With `FilterPolicyScope` set to `MessageBody`, the policy is matched against the message body read as
|
|
993
|
+
JSON. That is the scope that nests. A policy names a nested key by nesting itself.
|
|
994
|
+
|
|
995
|
+
```typescript sim-sns-filter-policy-body
|
|
996
|
+
/**
|
|
997
|
+
* Filtering on a nested key of the published message body.
|
|
998
|
+
*/
|
|
999
|
+
|
|
1000
|
+
import {
|
|
1001
|
+
CreateTopicCommand,
|
|
1002
|
+
PublishCommand,
|
|
1003
|
+
SubscribeCommand,
|
|
1004
|
+
} from "@aws-sdk/client-sns";
|
|
1005
|
+
import {
|
|
1006
|
+
CreateQueueCommand,
|
|
1007
|
+
ReceiveMessageCommand,
|
|
1008
|
+
SetQueueAttributesCommand,
|
|
1009
|
+
} from "@aws-sdk/client-sqs";
|
|
1010
|
+
|
|
1011
|
+
import { SimAws } from "@kensio/yulin";
|
|
1012
|
+
|
|
1013
|
+
const simAws = new SimAws();
|
|
1014
|
+
const sns = simAws.sns();
|
|
1015
|
+
const sqs = simAws.sqs();
|
|
1016
|
+
|
|
1017
|
+
const { TopicArn } = await sns.createTopic(
|
|
1018
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
1019
|
+
);
|
|
1020
|
+
const { QueueUrl } = await sqs.createQueue(
|
|
1021
|
+
new CreateQueueCommand({ QueueName: "gold-orders" }),
|
|
1022
|
+
);
|
|
1023
|
+
const queueArn = `arn:aws:sqs:${simAws.defaultRegionName}:${simAws.defaultAccountId}:gold-orders`;
|
|
1024
|
+
|
|
1025
|
+
await sqs.setQueueAttributes(
|
|
1026
|
+
new SetQueueAttributesCommand({
|
|
1027
|
+
QueueUrl,
|
|
1028
|
+
Attributes: {
|
|
1029
|
+
Policy: JSON.stringify({
|
|
1030
|
+
Version: "2012-10-17",
|
|
1031
|
+
Statement: [
|
|
1032
|
+
{
|
|
1033
|
+
Effect: "Allow",
|
|
1034
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
1035
|
+
Action: "sqs:SendMessage",
|
|
1036
|
+
Resource: queueArn,
|
|
1037
|
+
Condition: { ArnEquals: { "aws:SourceArn": TopicArn } },
|
|
1038
|
+
},
|
|
1039
|
+
],
|
|
1040
|
+
}),
|
|
1041
|
+
},
|
|
1042
|
+
}),
|
|
1043
|
+
);
|
|
1044
|
+
|
|
1045
|
+
await sns.subscribe(
|
|
1046
|
+
new SubscribeCommand({
|
|
1047
|
+
TopicArn,
|
|
1048
|
+
Protocol: "sqs",
|
|
1049
|
+
Endpoint: queueArn,
|
|
1050
|
+
Attributes: {
|
|
1051
|
+
FilterPolicyScope: "MessageBody",
|
|
1052
|
+
FilterPolicy: JSON.stringify({
|
|
1053
|
+
customer: { tier: ["gold"] },
|
|
1054
|
+
amount: [{ numeric: [">", 100] }],
|
|
1055
|
+
}),
|
|
1056
|
+
},
|
|
1057
|
+
}),
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
for (const body of [
|
|
1061
|
+
{ customer: { tier: "silver" }, amount: 500 },
|
|
1062
|
+
{ customer: { tier: "gold" }, amount: 500 },
|
|
1063
|
+
]) {
|
|
1064
|
+
await sns.publish(
|
|
1065
|
+
new PublishCommand({ TopicArn, Message: JSON.stringify(body) }),
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
await simAws.backgroundTasksComplete();
|
|
1070
|
+
|
|
1071
|
+
const { Messages } = await sqs.receiveMessage(
|
|
1072
|
+
new ReceiveMessageCommand({ QueueUrl, MaxNumberOfMessages: 10 }),
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
console.log(Messages?.length); // 1
|
|
1076
|
+
```
|
|
1077
|
+
|
|
1078
|
+
A body that fails to parse as JSON, or that parses to something other than an object, matches no
|
|
1079
|
+
policy of this scope. The result is a miss, and the publish still succeeds. The body comes from
|
|
1080
|
+
whoever published,
|
|
1081
|
+
the scope is the subscription's own business, and a publisher would otherwise fail on a subscription
|
|
1082
|
+
it knows nothing of. A key holding `null`, an object or an empty list holds no value to match, so it
|
|
1083
|
+
is a key `{"exists": false}` matches, as long as the body holds some other key.
|
|
1084
|
+
|
|
1085
|
+
The two attributes can be set on `Subscribe` or afterwards with `SetSubscriptionAttributes`, and
|
|
1086
|
+
`GetSubscriptionAttributes` reports both once a policy is set. Setting `FilterPolicy` with no value
|
|
1087
|
+
takes the policy off, and the subscription receives everything again.
|
|
1088
|
+
|
|
1089
|
+
A policy is read when it is set rather than when a message arrives. An operator real SNS has never
|
|
1090
|
+
had, a match condition naming two operators, a key holding no conditions, and a nested key under the
|
|
1091
|
+
`MessageAttributes` scope are all refused there. So is `cidr`, the one real SNS operator missing
|
|
1092
|
+
here.
|
|
1093
|
+
|
|
1094
|
+
## Verifying a message signature
|
|
1095
|
+
|
|
1096
|
+
The `Signature` in the envelope is a real RSA signature over the string real SNS signs. That string
|
|
1097
|
+
is the signed fields in alphabetical order, each one its name and its value followed by a newline.
|
|
1098
|
+
Signature version `1` is SHA1withRSA, and real SNS signs with it unless a topic opts into version 2.
|
|
1099
|
+
|
|
1100
|
+
The key pair belongs to the simulated SNS scope, as a real signing certificate belongs to a region.
|
|
1101
|
+
`SigningCertURL` names its certificate, and `simAws.sns().signingCertificate(url)` hands it over.
|
|
1102
|
+
Fetching the URL itself fails, since simulated SNS has no HTTP endpoint.
|
|
1103
|
+
|
|
1104
|
+
```typescript sim-sns-message-signature
|
|
1105
|
+
/**
|
|
1106
|
+
* Verifying the signature on a message a simulated topic delivered.
|
|
1107
|
+
*/
|
|
1108
|
+
|
|
1109
|
+
import { createVerify } from "node:crypto";
|
|
1110
|
+
|
|
1111
|
+
import {
|
|
1112
|
+
CreateTopicCommand,
|
|
1113
|
+
PublishCommand,
|
|
1114
|
+
SubscribeCommand,
|
|
1115
|
+
} from "@aws-sdk/client-sns";
|
|
1116
|
+
import {
|
|
1117
|
+
CreateQueueCommand,
|
|
1118
|
+
ReceiveMessageCommand,
|
|
1119
|
+
SetQueueAttributesCommand,
|
|
1120
|
+
} from "@aws-sdk/client-sqs";
|
|
1121
|
+
|
|
1122
|
+
import { SimAws } from "@kensio/yulin";
|
|
1123
|
+
|
|
1124
|
+
const simAws = new SimAws();
|
|
1125
|
+
const sns = simAws.sns();
|
|
1126
|
+
const sqs = simAws.sqs();
|
|
1127
|
+
|
|
1128
|
+
const { TopicArn } = await sns.createTopic(
|
|
1129
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
1130
|
+
);
|
|
1131
|
+
const { QueueUrl } = await sqs.createQueue(
|
|
1132
|
+
new CreateQueueCommand({ QueueName: "orders" }),
|
|
1133
|
+
);
|
|
1134
|
+
const queueArn = `arn:aws:sqs:${simAws.defaultRegionName}:${simAws.defaultAccountId}:orders`;
|
|
1135
|
+
|
|
1136
|
+
await sqs.setQueueAttributes(
|
|
1137
|
+
new SetQueueAttributesCommand({
|
|
1138
|
+
QueueUrl,
|
|
1139
|
+
Attributes: {
|
|
1140
|
+
Policy: JSON.stringify({
|
|
1141
|
+
Version: "2012-10-17",
|
|
1142
|
+
Statement: [
|
|
1143
|
+
{
|
|
1144
|
+
Effect: "Allow",
|
|
1145
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
1146
|
+
Action: "sqs:SendMessage",
|
|
1147
|
+
Resource: queueArn,
|
|
1148
|
+
Condition: { ArnLike: { "aws:SourceArn": TopicArn } },
|
|
1149
|
+
},
|
|
1150
|
+
],
|
|
1151
|
+
}),
|
|
1152
|
+
},
|
|
1153
|
+
}),
|
|
1154
|
+
);
|
|
1155
|
+
|
|
1156
|
+
await sns.subscribe(
|
|
1157
|
+
new SubscribeCommand({ TopicArn, Protocol: "sqs", Endpoint: queueArn }),
|
|
1158
|
+
);
|
|
1159
|
+
|
|
1160
|
+
await sns.publish(new PublishCommand({ TopicArn, Message: "order-1" }));
|
|
1161
|
+
await simAws.backgroundTasksComplete();
|
|
1162
|
+
|
|
1163
|
+
const { Messages } = await sqs.receiveMessage(
|
|
1164
|
+
new ReceiveMessageCommand({ QueueUrl }),
|
|
1165
|
+
);
|
|
1166
|
+
const envelope = JSON.parse(Messages?.[0]?.Body ?? "{}") as {
|
|
1167
|
+
Type: string;
|
|
1168
|
+
MessageId: string;
|
|
1169
|
+
Subject?: string;
|
|
1170
|
+
Message: string;
|
|
1171
|
+
Timestamp: string;
|
|
1172
|
+
TopicArn: string;
|
|
1173
|
+
SignatureVersion: string;
|
|
1174
|
+
Signature: string;
|
|
1175
|
+
SigningCertURL: string;
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
// The string SNS signs is the signed fields in alphabetical order, each one its
|
|
1179
|
+
// name and its value followed by a newline. A field the message does not carry,
|
|
1180
|
+
// such as Subject, is left out.
|
|
1181
|
+
const signed = (
|
|
1182
|
+
[
|
|
1183
|
+
["Message", envelope.Message],
|
|
1184
|
+
["MessageId", envelope.MessageId],
|
|
1185
|
+
["Subject", envelope.Subject],
|
|
1186
|
+
["Timestamp", envelope.Timestamp],
|
|
1187
|
+
["TopicArn", envelope.TopicArn],
|
|
1188
|
+
["Type", envelope.Type],
|
|
1189
|
+
] as const
|
|
1190
|
+
)
|
|
1191
|
+
.filter(([, value]) => value !== undefined)
|
|
1192
|
+
.map(([name, value]) => `${name}\n${value ?? ""}\n`)
|
|
1193
|
+
.join("");
|
|
1194
|
+
|
|
1195
|
+
// The certificate comes from the simulator rather than from the network: the
|
|
1196
|
+
// SigningCertURL names the simulated host and nothing serves it.
|
|
1197
|
+
const certificate = sns.signingCertificate(envelope.SigningCertURL);
|
|
1198
|
+
|
|
1199
|
+
const verified = createVerify("RSA-SHA1")
|
|
1200
|
+
.update(signed, "utf8")
|
|
1201
|
+
.verify(certificate ?? "", envelope.Signature, "base64");
|
|
1202
|
+
|
|
1203
|
+
console.log(envelope.SignatureVersion); // "1"
|
|
1204
|
+
console.log(verified); // true
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
The message attributes stay out of the signature, here and on real AWS, and changing one in flight
|
|
1208
|
+
leaves the signature valid.
|
|
1209
|
+
|
|
1210
|
+
## IAM permissions
|
|
1211
|
+
|
|
1212
|
+
Every operation is authorized against the topic's ARN, which carries the topic name with no resource
|
|
1213
|
+
type in front of it. Three details of real SNS trip policies up:
|
|
1214
|
+
|
|
1215
|
+
- `ListTopics` has no resource type at all. It is authorized against `*`, and only a policy whose
|
|
1216
|
+
`Resource` is `*` allows it. A policy naming one topic grants no listing, and one naming
|
|
1217
|
+
`arn:aws:sns:<region>:<account-id>:*` grants none either.
|
|
1218
|
+
- `Unsubscribe`, `ListSubscriptions`, `GetSubscriptionAttributes` and `SetSubscriptionAttributes`
|
|
1219
|
+
have no resource type either, for the same reason. SNS has no subscription resource type for a
|
|
1220
|
+
policy to name. They are authorized against `*`, out of reach of a topic policy and of an identity
|
|
1221
|
+
policy naming the topic ARN. `Subscribe` and `ListSubscriptionsByTopic` do name a topic, and are
|
|
1222
|
+
authorized against its ARN.
|
|
1223
|
+
- `PublishBatch` is authorized as `sns:Publish`. There is no `sns:PublishBatch` action for a policy to
|
|
1224
|
+
name.
|
|
1225
|
+
|
|
1226
|
+
A denial is `AuthorizationErrorException`, the error real SNS answers with. Most other services use
|
|
1227
|
+
`AccessDenied`.
|
|
1228
|
+
|
|
1229
|
+
```typescript sim-sns-iam-policy
|
|
1230
|
+
/**
|
|
1231
|
+
* A Role allowed to publish to one simulated topic and nothing else.
|
|
1232
|
+
*/
|
|
1233
|
+
|
|
1234
|
+
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
|
|
1235
|
+
import {
|
|
1236
|
+
CreateTopicCommand,
|
|
1237
|
+
ListTopicsCommand,
|
|
1238
|
+
PublishCommand,
|
|
1239
|
+
} from "@aws-sdk/client-sns";
|
|
1240
|
+
|
|
1241
|
+
import { SimAws } from "@kensio/yulin";
|
|
1242
|
+
|
|
1243
|
+
const simAws = new SimAws();
|
|
1244
|
+
const accountId = simAws.defaultAccountId;
|
|
1245
|
+
const regionName = simAws.defaultRegionName;
|
|
1246
|
+
const sns = simAws.sns();
|
|
1247
|
+
|
|
1248
|
+
const { TopicArn } = await sns.createTopic(
|
|
1249
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
1250
|
+
);
|
|
1251
|
+
|
|
1252
|
+
const role = await simAws.iam().createRole(
|
|
1253
|
+
new CreateRoleCommand({
|
|
1254
|
+
RoleName: "OrderPublisher",
|
|
1255
|
+
AssumeRolePolicyDocument: JSON.stringify({
|
|
1256
|
+
Version: "2012-10-17",
|
|
1257
|
+
Statement: {
|
|
1258
|
+
Effect: "Allow",
|
|
1259
|
+
Principal: { AWS: `arn:aws:iam::${accountId}:root` },
|
|
1260
|
+
Action: "sts:AssumeRole",
|
|
1261
|
+
},
|
|
1262
|
+
}),
|
|
1263
|
+
}),
|
|
1264
|
+
);
|
|
1265
|
+
|
|
1266
|
+
await simAws.iam().putRolePolicy(
|
|
1267
|
+
new PutRolePolicyCommand({
|
|
1268
|
+
RoleName: "OrderPublisher",
|
|
1269
|
+
PolicyName: "PublishOrders",
|
|
1270
|
+
PolicyDocument: JSON.stringify({
|
|
1271
|
+
Version: "2012-10-17",
|
|
1272
|
+
Statement: {
|
|
1273
|
+
Effect: "Allow",
|
|
1274
|
+
Action: "sns:Publish",
|
|
1275
|
+
// A topic ARN has no resource type: not "...:topic/orders".
|
|
1276
|
+
Resource: `arn:aws:sns:${regionName}:${accountId}:orders`,
|
|
1277
|
+
},
|
|
1278
|
+
}),
|
|
1279
|
+
}),
|
|
1280
|
+
);
|
|
1281
|
+
|
|
1282
|
+
const caller = { kind: "arn", arn: role.Role.Arn } as const;
|
|
1283
|
+
|
|
1284
|
+
const published = await sns.publish(
|
|
1285
|
+
new PublishCommand({ TopicArn, Message: "order-1" }),
|
|
1286
|
+
{ caller },
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
console.log(published.MessageId !== undefined); // true
|
|
1290
|
+
|
|
1291
|
+
// Listing is not covered by a policy naming one topic.
|
|
1292
|
+
try {
|
|
1293
|
+
await sns.listTopics(new ListTopicsCommand({}), { caller });
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
console.log((error as Error).name); // "AuthorizationErrorException"
|
|
1296
|
+
}
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
## Topic policies
|
|
1300
|
+
|
|
1301
|
+
A topic's `Policy` attribute is its resource policy, and simulated IAM evaluates it as one. It is
|
|
1302
|
+
what admits a service principal such as `s3.amazonaws.com`, which owns no identity policies
|
|
1303
|
+
anywhere. It is also half of what admits a principal from another account, which needs an identity
|
|
1304
|
+
policy in its own account as well.
|
|
1305
|
+
|
|
1306
|
+
The policy is set with `CreateTopic` or `SetTopicAttributes` and read back with `GetTopicAttributes`.
|
|
1307
|
+
|
|
1308
|
+
```typescript sim-sns-topic-policy
|
|
1309
|
+
/**
|
|
1310
|
+
* A topic policy admitting S3 to publish to a topic, for one Bucket only.
|
|
1311
|
+
*/
|
|
1312
|
+
|
|
1313
|
+
import {
|
|
1314
|
+
CreateTopicCommand,
|
|
1315
|
+
PublishCommand,
|
|
1316
|
+
SetTopicAttributesCommand,
|
|
1317
|
+
} from "@aws-sdk/client-sns";
|
|
1318
|
+
|
|
1319
|
+
import { SimAws } from "@kensio/yulin";
|
|
1320
|
+
|
|
1321
|
+
const simAws = new SimAws();
|
|
1322
|
+
const sns = simAws.sns();
|
|
1323
|
+
const topicArn = `arn:aws:sns:${simAws.defaultRegionName}:${simAws.defaultAccountId}:orders`;
|
|
1324
|
+
|
|
1325
|
+
const { TopicArn } = await sns.createTopic(
|
|
1326
|
+
new CreateTopicCommand({ Name: "orders" }),
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
await sns.setTopicAttributes(
|
|
1330
|
+
new SetTopicAttributesCommand({
|
|
1331
|
+
TopicArn,
|
|
1332
|
+
AttributeName: "Policy",
|
|
1333
|
+
AttributeValue: JSON.stringify({
|
|
1334
|
+
Version: "2012-10-17",
|
|
1335
|
+
Statement: [
|
|
1336
|
+
{
|
|
1337
|
+
Effect: "Allow",
|
|
1338
|
+
Principal: { Service: "s3.amazonaws.com" },
|
|
1339
|
+
Action: "SNS:Publish",
|
|
1340
|
+
Resource: topicArn,
|
|
1341
|
+
Condition: { ArnLike: { "aws:SourceArn": "arn:aws:s3:::uploads" } },
|
|
1342
|
+
},
|
|
1343
|
+
],
|
|
1344
|
+
}),
|
|
1345
|
+
}),
|
|
1346
|
+
);
|
|
1347
|
+
|
|
1348
|
+
// S3 has no identity policies anywhere, so the topic policy is the whole
|
|
1349
|
+
// decision. What it is publishing for goes in as aws:SourceArn.
|
|
1350
|
+
const s3 = { kind: "service", service: "s3.amazonaws.com" } as const;
|
|
1351
|
+
|
|
1352
|
+
const published = await sns.publish(
|
|
1353
|
+
new PublishCommand({ TopicArn, Message: "uploads/order-1.json" }),
|
|
1354
|
+
{ caller: s3, sourceArn: "arn:aws:s3:::uploads" },
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1357
|
+
console.log(published.MessageId !== undefined); // true
|
|
1358
|
+
|
|
1359
|
+
// A Bucket the condition does not cover is refused.
|
|
1360
|
+
try {
|
|
1361
|
+
await sns.publish(
|
|
1362
|
+
new PublishCommand({ TopicArn, Message: "reports/order-1.json" }),
|
|
1363
|
+
{ caller: s3, sourceArn: "arn:aws:s3:::reports" },
|
|
1364
|
+
);
|
|
1365
|
+
} catch (error) {
|
|
1366
|
+
console.log((error as Error).name); // "AuthorizationErrorException"
|
|
1367
|
+
}
|
|
1368
|
+
```
|
|
1369
|
+
|
|
1370
|
+
`sourceArn` is what a request says it is being made on behalf of, and `sourceAccount` is the Account
|
|
1371
|
+
owning that resource, supplied as `aws:SourceAccount`. A request that omits one leaves the key out
|
|
1372
|
+
entirely, and a statement conditioned on it matches nothing.
|
|
1373
|
+
|
|
1374
|
+
A caller from another account needs both sides to allow the request, as it does on real AWS. The
|
|
1375
|
+
topic policy has to name the principal, and that principal's own account has to allow the action.
|
|
1376
|
+
Either one on its own is a denial.
|
|
1377
|
+
|
|
1378
|
+
The policy is validated when it is set. A malformed document fails there, before anything has been
|
|
1379
|
+
authorized against it. It has to be a JSON policy document whose statements each carry an `Effect` of
|
|
1380
|
+
`Allow` or `Deny`, an `Action` or `NotAction`, and a `Resource` or `NotResource`. Anything else fails
|
|
1381
|
+
with `InvalidParameterException`.
|
|
1382
|
+
|
|
1383
|
+
`GetTopicAttributes` reports `Policy` back as the string it was set with. Setting the attribute with
|
|
1384
|
+
no value takes the policy off the topic, the only way back to a topic without one.
|
|
1385
|
+
|
|
1386
|
+
## Taking S3 Object events
|
|
1387
|
+
|
|
1388
|
+
A simulated S3 Bucket can publish its event notifications to a topic. The topic then fans them out to
|
|
1389
|
+
its own subscribers, and one Bucket configuration reaches every queue subscribed to the topic.
|
|
1390
|
+
|
|
1391
|
+
Set up the topic policy first. S3 asks the topic whether it may publish when the notification
|
|
1392
|
+
configuration is applied, and asks again for every event. A policy that leaves `s3.amazonaws.com` out
|
|
1393
|
+
for that Bucket refuses the configuration outright.
|
|
1394
|
+
|
|
1395
|
+
```typescript sim-sns-s3-events
|
|
1396
|
+
/**
|
|
1397
|
+
* A topic taking S3 Object events and fanning them out to two queues.
|
|
1398
|
+
*/
|
|
1399
|
+
|
|
1400
|
+
import {
|
|
1401
|
+
CreateBucketCommand,
|
|
1402
|
+
PutBucketNotificationConfigurationCommand,
|
|
1403
|
+
PutObjectCommand,
|
|
1404
|
+
} from "@aws-sdk/client-s3";
|
|
1405
|
+
import {
|
|
1406
|
+
CreateTopicCommand,
|
|
1407
|
+
SetTopicAttributesCommand,
|
|
1408
|
+
SubscribeCommand,
|
|
1409
|
+
} from "@aws-sdk/client-sns";
|
|
1410
|
+
import {
|
|
1411
|
+
CreateQueueCommand,
|
|
1412
|
+
ReceiveMessageCommand,
|
|
1413
|
+
SetQueueAttributesCommand,
|
|
1414
|
+
} from "@aws-sdk/client-sqs";
|
|
1415
|
+
import { SimAws } from "@kensio/yulin";
|
|
1416
|
+
|
|
1417
|
+
interface SnsEnvelope {
|
|
1418
|
+
Message: string;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
interface S3EventDocument {
|
|
1422
|
+
Records: [{ s3: { object: { key: string } } }];
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const simAws = new SimAws();
|
|
1426
|
+
const { defaultRegionName: region, defaultAccountId: account } = simAws;
|
|
1427
|
+
const topicArn = `arn:aws:sns:${region}:${account}:uploads`;
|
|
1428
|
+
|
|
1429
|
+
const { TopicArn } = await simAws
|
|
1430
|
+
.sns()
|
|
1431
|
+
.createTopic(new CreateTopicCommand({ Name: "uploads" }));
|
|
1432
|
+
|
|
1433
|
+
// S3 owns no identity policies, so the topic policy is the whole decision.
|
|
1434
|
+
// The Bucket it is publishing for arrives as aws:SourceArn.
|
|
1435
|
+
await simAws.sns().setTopicAttributes(
|
|
1436
|
+
new SetTopicAttributesCommand({
|
|
1437
|
+
TopicArn,
|
|
1438
|
+
AttributeName: "Policy",
|
|
1439
|
+
AttributeValue: JSON.stringify({
|
|
1440
|
+
Version: "2012-10-17",
|
|
1441
|
+
Statement: [
|
|
1442
|
+
{
|
|
1443
|
+
Effect: "Allow",
|
|
1444
|
+
Principal: { Service: "s3.amazonaws.com" },
|
|
1445
|
+
Action: "sns:Publish",
|
|
1446
|
+
Resource: topicArn,
|
|
1447
|
+
Condition: { ArnLike: { "aws:SourceArn": "arn:aws:s3:::uploads" } },
|
|
1448
|
+
},
|
|
1449
|
+
],
|
|
1450
|
+
}),
|
|
1451
|
+
}),
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Subscribe a queue that admits SNS to send to it for this topic.
|
|
1456
|
+
*/
|
|
1457
|
+
async function subscribeQueue(queueName: string): Promise<string> {
|
|
1458
|
+
const { QueueUrl } = await simAws
|
|
1459
|
+
.sqs()
|
|
1460
|
+
.createQueue(new CreateQueueCommand({ QueueName: queueName }));
|
|
1461
|
+
const queueArn = `arn:aws:sqs:${region}:${account}:${queueName}`;
|
|
1462
|
+
|
|
1463
|
+
await simAws.sqs().setQueueAttributes(
|
|
1464
|
+
new SetQueueAttributesCommand({
|
|
1465
|
+
QueueUrl,
|
|
1466
|
+
Attributes: {
|
|
1467
|
+
Policy: JSON.stringify({
|
|
1468
|
+
Version: "2012-10-17",
|
|
1469
|
+
Statement: [
|
|
1470
|
+
{
|
|
1471
|
+
Effect: "Allow",
|
|
1472
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
1473
|
+
Action: "sqs:SendMessage",
|
|
1474
|
+
Resource: queueArn,
|
|
1475
|
+
Condition: { ArnLike: { "aws:SourceArn": topicArn } },
|
|
1476
|
+
},
|
|
1477
|
+
],
|
|
1478
|
+
}),
|
|
1479
|
+
},
|
|
1480
|
+
}),
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
await simAws
|
|
1484
|
+
.sns()
|
|
1485
|
+
.subscribe(
|
|
1486
|
+
new SubscribeCommand({ TopicArn, Protocol: "sqs", Endpoint: queueArn }),
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
return QueueUrl ?? "";
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const thumbnails = await subscribeQueue("thumbnails");
|
|
1493
|
+
const audit = await subscribeQueue("audit");
|
|
1494
|
+
|
|
1495
|
+
await simAws.s3().createBucket(new CreateBucketCommand({ Bucket: "uploads" }));
|
|
1496
|
+
|
|
1497
|
+
await simAws.s3().putBucketNotificationConfiguration(
|
|
1498
|
+
new PutBucketNotificationConfigurationCommand({
|
|
1499
|
+
Bucket: "uploads",
|
|
1500
|
+
NotificationConfiguration: {
|
|
1501
|
+
TopicConfigurations: [
|
|
1502
|
+
{ Id: "uploads", Events: ["s3:ObjectCreated:*"], TopicArn },
|
|
1503
|
+
],
|
|
1504
|
+
},
|
|
1505
|
+
}),
|
|
1506
|
+
);
|
|
1507
|
+
|
|
1508
|
+
await simAws.s3().putObject(
|
|
1509
|
+
new PutObjectCommand({
|
|
1510
|
+
Bucket: "uploads",
|
|
1511
|
+
Key: "raw/cat.jpg",
|
|
1512
|
+
Body: "cat picture",
|
|
1513
|
+
}),
|
|
1514
|
+
);
|
|
1515
|
+
|
|
1516
|
+
await simAws.backgroundTasksComplete();
|
|
1517
|
+
|
|
1518
|
+
for (const QueueUrl of [thumbnails, audit]) {
|
|
1519
|
+
const received = await simAws
|
|
1520
|
+
.sqs()
|
|
1521
|
+
.receiveMessage(new ReceiveMessageCommand({ QueueUrl }));
|
|
1522
|
+
const envelope = JSON.parse(
|
|
1523
|
+
received.Messages?.[0]?.Body ?? "",
|
|
1524
|
+
) as SnsEnvelope;
|
|
1525
|
+
const event = JSON.parse(envelope.Message) as S3EventDocument;
|
|
1526
|
+
|
|
1527
|
+
console.log(event.Records[0].s3.object.key); // "raw/cat.jpg"
|
|
1528
|
+
}
|
|
1529
|
+
```
|
|
1530
|
+
|
|
1531
|
+
The published `Message` is the S3 `Records` document as text, and the `Subject` is
|
|
1532
|
+
`Amazon S3 Notification`. Those are what real S3 publishes. A subscribed queue receiving the SNS
|
|
1533
|
+
envelope therefore has two layers to parse. `RawMessageDelivery` on the subscription takes one of
|
|
1534
|
+
them away, leaving the S3 event document as the message body.
|
|
1535
|
+
|
|
1536
|
+
The topic has to be in the Bucket's Region, as real S3 requires. A subscription is looser, and a
|
|
1537
|
+
topic delivers to a queue in any Region. The Accounts can differ on both hops.
|
|
1538
|
+
|
|
1539
|
+
See [simulated S3](https://yulinsim.dev/services/s3/#event-notifications "Simulated S3 event notification docs") for the
|
|
1540
|
+
rest of the Bucket side, including the object key filters and what a record carries.
|
|
1541
|
+
|
|
1542
|
+
## Scoping
|
|
1543
|
+
|
|
1544
|
+
Topics belong to an account and a region, as they do on real AWS. A topic name is unique within one
|
|
1545
|
+
account and region and nowhere wider. The same name can name two different topics in two regions.
|
|
1546
|
+
|
|
1547
|
+
```typescript sim-sns-scoping
|
|
1548
|
+
/**
|
|
1549
|
+
* Simulated topics are scoped to an account and region.
|
|
1550
|
+
*/
|
|
1551
|
+
|
|
1552
|
+
import { CreateTopicCommand, PublishCommand } from "@aws-sdk/client-sns";
|
|
1553
|
+
|
|
1554
|
+
import { SimAws } from "@kensio/yulin";
|
|
1555
|
+
import { SimSnsNotFoundException } from "@kensio/yulin/sns";
|
|
1556
|
+
|
|
1557
|
+
const simAws = new SimAws();
|
|
1558
|
+
|
|
1559
|
+
const { TopicArn } = await simAws
|
|
1560
|
+
.account("222222222222")
|
|
1561
|
+
.region("eu-west-2")
|
|
1562
|
+
.sns()
|
|
1563
|
+
.createTopic(new CreateTopicCommand({ Name: "orders" }));
|
|
1564
|
+
|
|
1565
|
+
// A topic ARN naming another Region reaches nothing.
|
|
1566
|
+
try {
|
|
1567
|
+
await simAws
|
|
1568
|
+
.account("222222222222")
|
|
1569
|
+
.region("us-east-1")
|
|
1570
|
+
.sns()
|
|
1571
|
+
.publish(new PublishCommand({ TopicArn, Message: "order-1" }));
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
console.log(error instanceof SimSnsNotFoundException); // true
|
|
1574
|
+
}
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
A topic ARN naming another account is refused the same way, rather than being answered by a local
|
|
1578
|
+
topic of the same name. A topic policy admits another account's principal to a topic here, and leaves
|
|
1579
|
+
that account's own topics unreachable from this one.
|
|
1580
|
+
|
|
1581
|
+
## Inside a simulated Lambda handler
|
|
1582
|
+
|
|
1583
|
+
Function code requiring `@aws-sdk/client-sns` is routed into the same simulated AWS environment, with
|
|
1584
|
+
the function's execution role as the caller. A handler publishing to a topic therefore has to be
|
|
1585
|
+
allowed to, by that role's policy, the same as on real AWS. See
|
|
1586
|
+
[simulated Lambda](https://yulinsim.dev/services/lambda/ "Simulated Lambda docs") for how function code and execution roles work.
|
|
1587
|
+
|
|
1588
|
+
`SimSdk` interception works the same way. Intercepting `SNSClient` routes ordinary SDK code into the
|
|
1589
|
+
simulation with nothing touching the network, covered under
|
|
1590
|
+
[AWS SDK interception](https://yulinsim.dev/sdk/ "Simulated AWS SDK docs").
|
|
1591
|
+
|
|
1592
|
+
## Deploying a topic from CloudFormation
|
|
1593
|
+
|
|
1594
|
+
Simulated CloudFormation creates a topic from an `AWS::SNS::Topic` resource, in the stack's account
|
|
1595
|
+
and region. The topic is created through `CreateTopic`. A template-created topic gets the same name
|
|
1596
|
+
validation, the same attributes and the same ARN as one an SDK caller creates.
|
|
1597
|
+
|
|
1598
|
+
`Ref` on the resource gives the topic ARN, as it does on real AWS, and it can be handed straight to
|
|
1599
|
+
`Publish` or `Subscribe`. `Fn::GetAtt … TopicArn` gives the same string, and `Fn::GetAtt … TopicName`
|
|
1600
|
+
gives the name without the account and region around it.
|
|
1601
|
+
|
|
1602
|
+
`AWS::SNS::Subscription` subscribes through `Subscribe`. Its `Protocol` has to be one of the three
|
|
1603
|
+
simulated, and its `Endpoint` has to be something that protocol can reach. `RawMessageDelivery`,
|
|
1604
|
+
`FilterPolicy` and `FilterPolicyScope` are carried through as subscription attributes, and a filter
|
|
1605
|
+
policy written in a template is read exactly as one set through the SDK. The policy is an object in
|
|
1606
|
+
the template where the API takes a JSON string, and the conversion happens on the way in. `Ref` on
|
|
1607
|
+
the resource gives the subscription ARN, and `Fn::GetAtt … Arn` gives the same string, since the ARN
|
|
1608
|
+
is the resource's physical id.
|
|
1609
|
+
|
|
1610
|
+
```typescript sim-sns-cloudformation-topic
|
|
1611
|
+
/**
|
|
1612
|
+
* Deploying a topic and a queue subscription from a CloudFormation template.
|
|
1613
|
+
*/
|
|
1614
|
+
|
|
1615
|
+
import { PublishCommand } from "@aws-sdk/client-sns";
|
|
1616
|
+
import { ReceiveMessageCommand } from "@aws-sdk/client-sqs";
|
|
1617
|
+
|
|
1618
|
+
import { SimAws } from "@kensio/yulin";
|
|
1619
|
+
|
|
1620
|
+
const simAws = new SimAws();
|
|
1621
|
+
|
|
1622
|
+
const stack = await simAws.cloudFormation().deployTemplate({
|
|
1623
|
+
stackName: "orders-stack",
|
|
1624
|
+
template: {
|
|
1625
|
+
Resources: {
|
|
1626
|
+
OrdersTopic: {
|
|
1627
|
+
Type: "AWS::SNS::Topic",
|
|
1628
|
+
Properties: { TopicName: "orders", DisplayName: "Orders" },
|
|
1629
|
+
},
|
|
1630
|
+
FulfilmentQueue: {
|
|
1631
|
+
Type: "AWS::SQS::Queue",
|
|
1632
|
+
Properties: { QueueName: "fulfilment" },
|
|
1633
|
+
},
|
|
1634
|
+
// The queue policy is what lets SNS deliver to the queue. It is checked
|
|
1635
|
+
// on every message, as it is on real AWS.
|
|
1636
|
+
FulfilmentQueuePolicy: {
|
|
1637
|
+
Type: "AWS::SQS::QueuePolicy",
|
|
1638
|
+
Properties: {
|
|
1639
|
+
Queues: [{ Ref: "FulfilmentQueue" }],
|
|
1640
|
+
PolicyDocument: {
|
|
1641
|
+
Version: "2012-10-17",
|
|
1642
|
+
Statement: [
|
|
1643
|
+
{
|
|
1644
|
+
Effect: "Allow",
|
|
1645
|
+
Principal: { Service: "sns.amazonaws.com" },
|
|
1646
|
+
Action: "sqs:SendMessage",
|
|
1647
|
+
Resource: { "Fn::GetAtt": ["FulfilmentQueue", "Arn"] },
|
|
1648
|
+
Condition: {
|
|
1649
|
+
ArnEquals: { "aws:SourceArn": { Ref: "OrdersTopic" } },
|
|
1650
|
+
},
|
|
1651
|
+
},
|
|
1652
|
+
],
|
|
1653
|
+
},
|
|
1654
|
+
},
|
|
1655
|
+
},
|
|
1656
|
+
FulfilmentSubscription: {
|
|
1657
|
+
Type: "AWS::SNS::Subscription",
|
|
1658
|
+
Properties: {
|
|
1659
|
+
TopicArn: { Ref: "OrdersTopic" },
|
|
1660
|
+
Protocol: "sqs",
|
|
1661
|
+
Endpoint: { "Fn::GetAtt": ["FulfilmentQueue", "Arn"] },
|
|
1662
|
+
RawMessageDelivery: true,
|
|
1663
|
+
},
|
|
1664
|
+
},
|
|
1665
|
+
},
|
|
1666
|
+
Outputs: {
|
|
1667
|
+
OrdersTopicArn: { Value: { Ref: "OrdersTopic" } },
|
|
1668
|
+
FulfilmentQueueUrl: { Value: { Ref: "FulfilmentQueue" } },
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
});
|
|
1672
|
+
|
|
1673
|
+
// Ref on a topic resolves to its ARN, so it works as a Publish TopicArn.
|
|
1674
|
+
const topicArn = stack.output("OrdersTopicArn");
|
|
1675
|
+
|
|
1676
|
+
await simAws
|
|
1677
|
+
.sns()
|
|
1678
|
+
.publish(new PublishCommand({ TopicArn: topicArn, Message: "order-1" }));
|
|
1679
|
+
|
|
1680
|
+
// Delivery happens after the publish is answered, as it does on real SNS.
|
|
1681
|
+
await simAws.backgroundTasksComplete();
|
|
1682
|
+
|
|
1683
|
+
const QueueUrl = stack.output("FulfilmentQueueUrl");
|
|
1684
|
+
const { Messages } = await simAws
|
|
1685
|
+
.sqs()
|
|
1686
|
+
.receiveMessage(new ReceiveMessageCommand({ QueueUrl }));
|
|
1687
|
+
|
|
1688
|
+
console.log(Messages?.[0]?.Body); // "order-1"
|
|
1689
|
+
```
|
|
1690
|
+
|
|
1691
|
+
A topic with no `TopicName` is named from the stack name and the logical ID. The topic above with its
|
|
1692
|
+
name left out would be `orders-stack-OrdersTopic`. Real CloudFormation adds random characters to
|
|
1693
|
+
that, which a template cannot predict either way. The generated name is trimmed to the 256 characters
|
|
1694
|
+
a topic name allows, ending in a hash of the untrimmed name so two long names that start the same
|
|
1695
|
+
stay apart.
|
|
1696
|
+
|
|
1697
|
+
A topic can also declare its subscriptions inside itself, with the `Subscription` property. That is
|
|
1698
|
+
how a hand-written template usually writes them. Each entry is a `Protocol` and an `Endpoint`, and
|
|
1699
|
+
each goes through `Subscribe` the same way a separate resource does. A `Protocol` and an `Endpoint`
|
|
1700
|
+
are all real CloudFormation lets an entry carry, and an entry carrying anything else fails the
|
|
1701
|
+
resource. A filter policy or raw message delivery needs the separate resource.
|
|
1702
|
+
|
|
1703
|
+
```typescript
|
|
1704
|
+
{
|
|
1705
|
+
Type: "AWS::SNS::Topic",
|
|
1706
|
+
Properties: {
|
|
1707
|
+
TopicName: "orders",
|
|
1708
|
+
Subscription: [
|
|
1709
|
+
{
|
|
1710
|
+
Protocol: "sqs",
|
|
1711
|
+
Endpoint: { "Fn::GetAtt": ["FulfilmentQueue", "Arn"] },
|
|
1712
|
+
},
|
|
1713
|
+
],
|
|
1714
|
+
},
|
|
1715
|
+
}
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
`AWS::SNS::TopicPolicy` deploys the policy it names onto each topic in its `Topics` list, through
|
|
1719
|
+
`SetTopicAttributes`. A policy declared in a template is therefore validated and enforced exactly as
|
|
1720
|
+
one set through the SDK, and a document SNS would refuse fails the resource. `Topics` carries topic
|
|
1721
|
+
ARNs, and `Ref` on an `AWS::SNS::Topic` gives one.
|
|
1722
|
+
|
|
1723
|
+
```typescript
|
|
1724
|
+
{
|
|
1725
|
+
Type: "AWS::SNS::TopicPolicy",
|
|
1726
|
+
Properties: {
|
|
1727
|
+
Topics: [{ Ref: "OrdersTopic" }],
|
|
1728
|
+
PolicyDocument: {
|
|
1729
|
+
Version: "2012-10-17",
|
|
1730
|
+
Statement: [
|
|
1731
|
+
{
|
|
1732
|
+
Effect: "Allow",
|
|
1733
|
+
Principal: { Service: "s3.amazonaws.com" },
|
|
1734
|
+
Action: "sns:Publish",
|
|
1735
|
+
Resource: { Ref: "OrdersTopic" },
|
|
1736
|
+
},
|
|
1737
|
+
],
|
|
1738
|
+
},
|
|
1739
|
+
},
|
|
1740
|
+
}
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
A property with no simulated behaviour fails the resource rather than being dropped. That covers
|
|
1744
|
+
`FifoTopic`, `ContentBasedDeduplication`, `FifoThroughputScope`, `KmsMasterKeyId`,
|
|
1745
|
+
`SignatureVersion`, `TracingConfig`, `ArchivePolicy`, `DeliveryStatusLogging`, `DataProtectionPolicy`
|
|
1746
|
+
and `Tags` on a topic, and `DeliveryPolicy`, `RedrivePolicy`, `ReplayPolicy`, `SubscriptionRoleArn`
|
|
1747
|
+
and `Region` on a subscription. Most of them are refused by simulated SNS itself, since they are
|
|
1748
|
+
topic or subscription attributes of the same name, and the reason is the same one an SDK caller gets.
|
|
1749
|
+
A property the resource type never had is refused too. The failure is worded as an invalid resource,
|
|
1750
|
+
which fails the resource where an unsupported one would be
|
|
1751
|
+
[skipped](https://yulinsim.dev/services/cloudformation/#values-from-a-skipped-resource). A topic that cannot be created
|
|
1752
|
+
as the template asked for it would otherwise leave a stack that looks deployed with no publisher
|
|
1753
|
+
behind it.
|
|
1754
|
+
|
|
1755
|
+
CDK works without hand-editing. `topic.addSubscription(new subscriptions.SqsSubscription(queue))`
|
|
1756
|
+
synthesises an `AWS::SNS::Subscription` alongside the `AWS::SQS::QueuePolicy` that authorizes the
|
|
1757
|
+
delivery, and both deploy. `new subscriptions.LambdaSubscription(fn)` does the same with the
|
|
1758
|
+
`AWS::Lambda::Permission` beside it.
|
|
1759
|
+
|
|
1760
|
+
## Available functionality
|
|
1761
|
+
|
|
1762
|
+
Sim SNS currently supports:
|
|
1763
|
+
|
|
1764
|
+
- `CreateTopicCommand`, idempotent for a name already taken, and `DeleteTopicCommand`
|
|
1765
|
+
- `ListTopicsCommand`, paged at a hundred topics with a `NextToken`
|
|
1766
|
+
- `GetTopicAttributesCommand` and `SetTopicAttributesCommand`, for `DisplayName` and `Policy`
|
|
1767
|
+
- `PublishCommand` and `PublishBatchCommand`, with message attributes, a subject and the 256 KB size
|
|
1768
|
+
limit
|
|
1769
|
+
- `PublishCommand` to a `PhoneNumber`, recorded as an SMS that `sentSmsMessages()` reads back, with
|
|
1770
|
+
the `AWS.SNS.SMS.SenderID` and `AWS.SNS.SMS.SMSType` attributes
|
|
1771
|
+
- The phone number opt-out list, with `CheckIfPhoneNumberIsOptedOutCommand`,
|
|
1772
|
+
`ListPhoneNumbersOptedOutCommand` and `OptInPhoneNumberCommand`, and `optOutPhoneNumber()` standing
|
|
1773
|
+
in for a recipient replying STOP
|
|
1774
|
+
- `SubscribeCommand` over the `sqs`, `lambda` and `sms` protocols, confirmed at once, and
|
|
1775
|
+
`UnsubscribeCommand`
|
|
1776
|
+
- `ListSubscriptionsCommand` and `ListSubscriptionsByTopicCommand`, paged at a hundred subscriptions
|
|
1777
|
+
with a `NextToken`
|
|
1778
|
+
- `GetSubscriptionAttributesCommand` and `SetSubscriptionAttributesCommand`, for
|
|
1779
|
+
`RawMessageDelivery`, `FilterPolicy` and `FilterPolicyScope`
|
|
1780
|
+
- Subscription filter policies over the message attributes or the message body, with the string,
|
|
1781
|
+
numeric, `exists`, `anything-but` and `$or` operators, applied per subscription
|
|
1782
|
+
- Delivery of a published message to every subscribed queue, including a queue in another account or
|
|
1783
|
+
another region, authorized by that queue's own policy on every message
|
|
1784
|
+
- Invocation of every subscribed Lambda function, including a function in another account or another
|
|
1785
|
+
region, authorized by that function's own resource policy on every message
|
|
1786
|
+
- An SMS recorded for every subscribed phone number, carrying the topic ARN and the subscription ARN
|
|
1787
|
+
that produced it, and marked `suppressed` for a number on the opt-out list
|
|
1788
|
+
- The SNS envelope, with a real RSA signature a verifier can check against the certificate the
|
|
1789
|
+
message names, and `RawMessageDelivery` for the published message on its own
|
|
1790
|
+
- The SNS Lambda event, with one `Records` entry per published message and the message attributes in
|
|
1791
|
+
it
|
|
1792
|
+
- Authorization of every operation by simulated IAM, against the real IAM action and topic ARN
|
|
1793
|
+
- The `Policy` attribute as the topic's resource policy, admitting another account's principal or a
|
|
1794
|
+
service principal, with `aws:SourceArn` and `aws:SourceAccount` conditions honoured
|
|
1795
|
+
- Simulated S3 Bucket event notifications published to a topic, authorized by the topic policy when
|
|
1796
|
+
the configuration is applied and again on every event
|
|
1797
|
+
- Calls made from inside a simulated Lambda handler, authorized as the function's execution role
|
|
1798
|
+
- `SNSClient` interception, routing ordinary SDK code into the simulation
|
|
1799
|
+
- `AWS::SNS::Topic`, `AWS::SNS::Subscription` and `AWS::SNS::TopicPolicy` deployed from a
|
|
1800
|
+
CloudFormation template, each through the SDK command an SDK caller would reach, with the inline
|
|
1801
|
+
`Subscription` property on a topic and `Ref` and `Fn::GetAtt` over the deployed resources
|
|
1802
|
+
|
|
1803
|
+
## Limitations
|
|
1804
|
+
|
|
1805
|
+
Current documented limitations:
|
|
1806
|
+
|
|
1807
|
+
- Only the `sqs`, `lambda` and `sms` subscription protocols are simulated. A queue, a function and a
|
|
1808
|
+
phone number are the only things a topic can deliver to. `http`, `https`, `email`, `email-json`,
|
|
1809
|
+
`application` and `firehose` are refused at `Subscribe` time.
|
|
1810
|
+
- A subscribed function is invoked with `Subject: null` and `MessageAttributes: {}` where the publish
|
|
1811
|
+
carried neither of them, as real SNS sends it. The envelope a queue receives leaves both fields
|
|
1812
|
+
out.
|
|
1813
|
+
- `RawMessageDelivery` is accepted on a `lambda` subscription and has no effect on it, as it has none
|
|
1814
|
+
on real SNS.
|
|
1815
|
+
- `SigningCertURL` and `UnsubscribeURL` name `sns.<region>.yulin.invalid` rather than
|
|
1816
|
+
`sns.<region>.amazonaws.com`, and both are unfetchable, since simulated SNS has no HTTP endpoint.
|
|
1817
|
+
The certificate is handed out in process by `simAws.sns().signingCertificate(url)`. A real SNS
|
|
1818
|
+
signature verifier such as `sns-validator` hard-codes an `amazonaws.com` certificate host and
|
|
1819
|
+
fetches the URL itself, which leaves it unable to verify a simulated message as it stands. Verify
|
|
1820
|
+
with `node:crypto` against the certificate the simulator hands over instead.
|
|
1821
|
+
- A delivery failure goes unreported to the publisher, as it does on real SNS. It is recorded on
|
|
1822
|
+
`simAws.sns().deliveryFailures`, and anything other than an endpoint policy refusal is also warned
|
|
1823
|
+
about once on the console.
|
|
1824
|
+
- Delivery retry policies, subscription dead-letter queues and delivery status logging are left out.
|
|
1825
|
+
A message an endpoint refuses is delivered once and recorded as a failure, with no retry behind it.
|
|
1826
|
+
- `ConfirmSubscription` is absent. No protocol simulated needs a confirmation, so there is no token
|
|
1827
|
+
to confirm.
|
|
1828
|
+
- The `cidr` filter policy operator is left out. A policy holding one is refused when it is set,
|
|
1829
|
+
naming the operator. Accepting it would leave a policy that silently matches no message.
|
|
1830
|
+
- A filter policy is reported back as the string it was set with. Real SNS re-serialises the
|
|
1831
|
+
document, so what comes back there differs from what went in.
|
|
1832
|
+
- A nested filter policy key is refused under the `MessageAttributes` scope, since message attributes
|
|
1833
|
+
are a flat set of names and such a policy could never match.
|
|
1834
|
+
- A message body that parses to something other than a JSON object matches no `MessageBody` filter
|
|
1835
|
+
policy, and the publish still succeeds. A key holding `null`, an object or an empty list
|
|
1836
|
+
holds no value to match, so `{"exists": false}` matches it, as long as the body holds some other
|
|
1837
|
+
key.
|
|
1838
|
+
- An `$or` holding fewer than two objects, or naming a reserved keyword, is refused when the policy
|
|
1839
|
+
is set. Real SNS reads it as an attribute named `$or`, which matches no message.
|
|
1840
|
+
- Subscription delivery retry policies, subscription dead-letter queues and message replay are left
|
|
1841
|
+
out, and `DeliveryPolicy`, `RedrivePolicy`, `SubscriptionRoleArn` and `ReplayPolicy` are refused.
|
|
1842
|
+
- `GetSubscriptionAttributes` reports `SubscriptionArn`, `TopicArn`, `Protocol`, `Endpoint`, `Owner`,
|
|
1843
|
+
`ConfirmationWasAuthenticated`, `PendingConfirmation` and `RawMessageDelivery`, with `FilterPolicy`
|
|
1844
|
+
and `FilterPolicyScope` once a policy is set. `EffectiveDeliveryPolicy` is left out, since delivery
|
|
1845
|
+
retry policies are absent.
|
|
1846
|
+
- A queue whose name ends in `.fifo` is refused as a subscription endpoint. Only a FIFO topic
|
|
1847
|
+
delivers to a FIFO queue, and there are no FIFO topics here.
|
|
1848
|
+
- Standard topics only. A topic name ending in `.fifo` is refused, as are the `FifoTopic`,
|
|
1849
|
+
`FifoThroughputScope` and `ContentBasedDeduplication` attributes, and the `MessageGroupId` and
|
|
1850
|
+
`MessageDeduplicationId` publish inputs.
|
|
1851
|
+
- `MessageStructure` is refused. A `json` structure picks a different message body per protocol, and
|
|
1852
|
+
that picking is left out. Accepting it would mean a body chosen by a rule that never ran.
|
|
1853
|
+
- Publishing to a `TargetArn` is refused. Mobile application endpoints and push notifications are
|
|
1854
|
+
left out, and a publish reaches a topic or a phone number.
|
|
1855
|
+
- An SMS is recorded and never delivered, and what a carrier would do with one is left out. There is
|
|
1856
|
+
no message part splitting, no delivery receipt, no price and no throughput limit.
|
|
1857
|
+
- `AWS.SNS.SMS.MaxPrice`, `AWS.MM.SMS.OriginationNumber`, `AWS.MM.SMS.EntityId` and
|
|
1858
|
+
`AWS.MM.SMS.TemplateId` are refused rather than recorded. Each one changes what real SNS does with
|
|
1859
|
+
a message, and accepting an inert copy of it would misrepresent the send.
|
|
1860
|
+
- `SetSMSAttributes` and `GetSMSAttributes`, which carry the account-wide SMS defaults and the spend
|
|
1861
|
+
limit, are absent.
|
|
1862
|
+
- The SMS sandbox is left out. Real SNS only texts verified destination numbers until an account
|
|
1863
|
+
leaves it. Yulin is a sandbox already, so every number here is reachable without verifying it
|
|
1864
|
+
first.
|
|
1865
|
+
- The thirty day limit real SNS puts on opting one number back in is absent.
|
|
1866
|
+
- Message attributes count against the 256 KB publish limit alongside the message body, as they do on
|
|
1867
|
+
real SNS. AWS documents no exact accounting for one attribute, so this counts the bytes of the
|
|
1868
|
+
attribute's name, its data type and its value. That is stricter than counting the body alone.
|
|
1869
|
+
- A `Subject` is held to the contract real SNS states, UTF-8 text with no line breaks or control
|
|
1870
|
+
characters and fewer than 100 characters. Older AWS documentation described it as ASCII text
|
|
1871
|
+
beginning with a letter, number or punctuation mark. That wording is superseded, and a subject
|
|
1872
|
+
beginning with a space is accepted here.
|
|
1873
|
+
- `GetTopicAttributes` reports `TopicArn`, `Owner`, `DisplayName`, `SubscriptionsConfirmed`,
|
|
1874
|
+
`SubscriptionsPending`, `SubscriptionsDeleted` and `Policy` when one is set. `DeliveryPolicy` and
|
|
1875
|
+
`EffectiveDeliveryPolicy` are left out, since delivery retry policies are absent.
|
|
1876
|
+
- `GetTopicAttributes` reports the `Policy` string that was set. Real SNS re-serialises the document
|
|
1877
|
+
and adds an `Id` and a `Sid`, so what comes back there differs from what went in.
|
|
1878
|
+
- A topic policy is set through the `Policy` attribute only. `AddPermission` and `RemovePermission`,
|
|
1879
|
+
shorthands for writing one statement of it, are absent.
|
|
1880
|
+
- Encryption is left out. `KmsMasterKeyId` is refused, and message bodies are held in process memory
|
|
1881
|
+
as they were published. Anything sharing the process can read them.
|
|
1882
|
+
- Tags are left out. `TagResource`, `UntagResource` and `ListTagsForResource` are absent, and
|
|
1883
|
+
`CreateTopic` refuses a `Tags` parameter rather than dropping it.
|
|
1884
|
+
- Data protection policies are left out. `PutDataProtectionPolicy` and `GetDataProtectionPolicy` are
|
|
1885
|
+
absent, and `CreateTopic` refuses a `DataProtectionPolicy` rather than creating a topic that
|
|
1886
|
+
redacts nothing.
|
|
1887
|
+
- Message archiving and replay are left out, and `ArchivePolicy` is refused.
|
|
1888
|
+
- Delivery status logging is left out, and the feedback role and sample rate attributes are refused.
|
|
1889
|
+
Nothing here writes a delivery record to a simulated CloudWatch Logs group.
|
|
1890
|
+
- Platform applications and endpoints, and subscribing over `http`, `https`, `email`, `email-json`,
|
|
1891
|
+
`application` and `firehose`, are absent.
|
|
1892
|
+
- SNS condition keys such as `sns:Endpoint` and `sns:Protocol` are left out, and a policy relying on
|
|
1893
|
+
one matches nothing. Ordinary condition operators on values sim IAM does supply work as usual.
|
|
1894
|
+
- `FifoTopic: false` in a template fails the resource rather than deploying a standard topic. It is
|
|
1895
|
+
passed to `CreateTopic` as the attribute of the same name, which simulated SNS refuses by name
|
|
1896
|
+
whatever the value is. CDK leaves the property out for a standard topic, so this only reaches a
|
|
1897
|
+
hand-written template.
|
|
1898
|
+
- `AWS::SNS::Topic` and `AWS::SNS::Subscription` are replaced rather than updated in place when a
|
|
1899
|
+
stack update changes one, as every simulated CloudFormation resource is. A replaced topic is a new
|
|
1900
|
+
topic, so anything the old one held is gone.
|
|
1901
|
+
- An S3 event notification published to a topic carries no message attributes, since real S3 publishes
|
|
1902
|
+
none. The only thing on the message besides the event document is the `Amazon S3 Notification`
|
|
1903
|
+
subject.
|
|
1904
|
+
- SNS is not served as an HTTP API by `serveSimAws`.
|