@gugananuvem/aws-local-simulator 1.0.14 → 1.0.15
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 +594 -481
- package/bin/aws-local-simulator.js +63 -63
- package/package.json +11 -10
- package/src/config/config-loader.js +114 -114
- package/src/config/default-config.js +68 -68
- package/src/config/env-loader.js +68 -68
- package/src/index.js +146 -146
- package/src/index.mjs +123 -123
- package/src/server.js +227 -227
- package/src/services/apigateway/index.js +73 -73
- package/src/services/apigateway/server.js +507 -507
- package/src/services/apigateway/simulator.js +1261 -1261
- package/src/services/athena/index.js +75 -75
- package/src/services/athena/server.js +101 -101
- package/src/services/athena/simulador.js +998 -998
- package/src/services/athena/simulator.js +346 -346
- package/src/services/cloudformation/index.js +106 -106
- package/src/services/cloudformation/server.js +417 -417
- package/src/services/cloudformation/simulador.js +1045 -1045
- package/src/services/cloudtrail/index.js +84 -84
- package/src/services/cloudtrail/server.js +235 -235
- package/src/services/cloudtrail/simulador.js +719 -719
- package/src/services/cloudwatch/index.js +84 -84
- package/src/services/cloudwatch/server.js +366 -366
- package/src/services/cloudwatch/simulador.js +1173 -1173
- package/src/services/cognito/index.js +79 -70
- package/src/services/cognito/server.js +301 -279
- package/src/services/cognito/simulator.js +1655 -1119
- package/src/services/config/index.js +96 -96
- package/src/services/config/server.js +215 -215
- package/src/services/config/simulador.js +1260 -1260
- package/src/services/dynamodb/index.js +74 -74
- package/src/services/dynamodb/server.js +125 -123
- package/src/services/dynamodb/simulator.js +630 -630
- package/src/services/ecs/index.js +65 -65
- package/src/services/ecs/server.js +235 -233
- package/src/services/ecs/simulator.js +844 -844
- package/src/services/eventbridge/index.js +89 -89
- package/src/services/eventbridge/server.js +209 -209
- package/src/services/eventbridge/simulator.js +684 -684
- package/src/services/index.js +45 -45
- package/src/services/kms/index.js +75 -75
- package/src/services/kms/server.js +67 -67
- package/src/services/kms/simulator.js +324 -324
- package/src/services/lambda/handler-loader.js +183 -183
- package/src/services/lambda/index.js +78 -78
- package/src/services/lambda/route-registry.js +274 -274
- package/src/services/lambda/server.js +145 -145
- package/src/services/lambda/simulator.js +199 -182
- package/src/services/parameter-store/index.js +80 -80
- package/src/services/parameter-store/server.js +50 -50
- package/src/services/parameter-store/simulator.js +201 -201
- package/src/services/s3/index.js +73 -73
- package/src/services/s3/server.js +329 -245
- package/src/services/s3/simulator.js +565 -496
- package/src/services/secret-manager/index.js +80 -80
- package/src/services/secret-manager/server.js +50 -50
- package/src/services/secret-manager/simulator.js +171 -171
- package/src/services/sns/index.js +89 -89
- package/src/services/sns/server.js +580 -580
- package/src/services/sns/simulator.js +1482 -1482
- package/src/services/sqs/index.js +93 -93
- package/src/services/sqs/server.js +349 -347
- package/src/services/sqs/simulator.js +441 -441
- package/src/services/sts/index.js +37 -37
- package/src/services/sts/server.js +144 -142
- package/src/services/sts/simulator.js +69 -69
- package/src/services/xray/index.js +83 -83
- package/src/services/xray/server.js +308 -308
- package/src/services/xray/simulador.js +994 -994
- package/src/template/aws-config-template.js +87 -87
- package/src/template/aws-config-template.mjs +90 -90
- package/src/template/config-template.json +203 -203
- package/src/utils/aws-config.js +91 -91
- package/src/utils/cloudtrail-audit.js +129 -129
- package/src/utils/local-store.js +83 -83
- package/src/utils/logger.js +59 -59
|
@@ -1,580 +1,580 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview SNS HTTP Server — Query Protocol completo
|
|
3
|
-
* Compatível com AWS SDK v3 SNS Client
|
|
4
|
-
*
|
|
5
|
-
* Wire Protocol: Query (application/x-www-form-urlencoded) + respostas XML
|
|
6
|
-
* Endpoint principal: POST /
|
|
7
|
-
* Ações suportadas: CreateTopic, DeleteTopic, ListTopics, GetTopicAttributes,
|
|
8
|
-
* SetTopicAttributes, Subscribe, Unsubscribe, ConfirmSubscription,
|
|
9
|
-
* ListSubscriptions, ListSubscriptionsByTopic, GetSubscriptionAttributes,
|
|
10
|
-
* SetSubscriptionAttributes, Publish, PublishBatch,
|
|
11
|
-
* TagResource, UntagResource, ListTagsForResource,
|
|
12
|
-
* CreatePlatformApplication, DeletePlatformApplication, ListPlatformApplications,
|
|
13
|
-
* CreatePlatformEndpoint, DeleteEndpoint, GetEndpointAttributes,
|
|
14
|
-
* SetEndpointAttributes, ListEndpointsByPlatformApplication,
|
|
15
|
-
* CheckIfPhoneNumberIsOptedOut, ListPhoneNumbersOptedOut, OptInPhoneNumber,
|
|
16
|
-
* SetSMSAttributes, GetSMSAttributes
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
'use strict';
|
|
20
|
-
|
|
21
|
-
const express = require('express');
|
|
22
|
-
const cors = require('cors');
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Cria Express application do SNS
|
|
26
|
-
* @param {Object} simulator - SNSSimulator instance
|
|
27
|
-
* @param {Object} config - Service configuration
|
|
28
|
-
* @param {Object} logger - Logger instance
|
|
29
|
-
* @returns {import('express').Application}
|
|
30
|
-
*/
|
|
31
|
-
function createSNSServer(simulator, config, logger) {
|
|
32
|
-
const app = express();
|
|
33
|
-
|
|
34
|
-
// ── Middlewares globais ──────────────────────────────────────
|
|
35
|
-
if (config.cors?.enabled !== false) {
|
|
36
|
-
app.use(cors({ origin: config.cors?.origin || '*' }));
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
40
|
-
app.use(express.json({ limit: '10mb' }));
|
|
41
|
-
|
|
42
|
-
app.use((req, _res, next) => {
|
|
43
|
-
const action = req.body?.Action || req.query?.Action;
|
|
44
|
-
if (action) logger.debug('SNS', `${req.method} ${req.path} Action=${action}`);
|
|
45
|
-
next();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// ── Endpoint principal (Query Protocol) ─────────────────────
|
|
49
|
-
app.post('/', async (req, res) => {
|
|
50
|
-
const body = req.body || {};
|
|
51
|
-
const action = body.Action || req.query.Action;
|
|
52
|
-
|
|
53
|
-
if (!action) {
|
|
54
|
-
return res.status(400).set('Content-Type', 'text/xml').send(
|
|
55
|
-
xmlError('MissingAction', 'Action is required')
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
return await handleAction(action, body, simulator, res, logger);
|
|
61
|
-
} catch (err) {
|
|
62
|
-
logger.error('SNS', `[${action}] ${err.message}`);
|
|
63
|
-
const status = err.statusCode || 400;
|
|
64
|
-
return res.status(status).set('Content-Type', 'text/xml').send(
|
|
65
|
-
xmlError(err.code || 'InternalFailure', err.message)
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── Admin routes ─────────────────────────────────────────────
|
|
71
|
-
app.get('/__admin/health', (_req, res) => {
|
|
72
|
-
res.json({
|
|
73
|
-
status: 'healthy',
|
|
74
|
-
service: 'sns',
|
|
75
|
-
topics: simulator.topics.size,
|
|
76
|
-
subscriptions: simulator.subscriptions.size,
|
|
77
|
-
platformApps: simulator.platformApps.size,
|
|
78
|
-
endpoints: simulator.platformEndpoints.size,
|
|
79
|
-
timestamp: new Date().toISOString()
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
app.get('/__admin/topics', (_req, res) => {
|
|
84
|
-
res.json({ topics: Array.from(simulator.topics.values()) });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
app.get('/__admin/subscriptions', (_req, res) => {
|
|
88
|
-
res.json({ subscriptions: Array.from(simulator.subscriptions.values()) });
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
app.get('/__admin/subscriptions/:topicName', (req, res) => {
|
|
92
|
-
const arn = `arn:aws:sns:us-east-1:123456789012:${req.params.topicName}`;
|
|
93
|
-
const subs = Array.from(simulator.subscriptions.values()).filter(s => s.TopicArn === arn);
|
|
94
|
-
res.json({ subscriptions: subs });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
app.get('/__admin/publish-log', (_req, res) => {
|
|
98
|
-
res.json({ messages: simulator.publishLog });
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
app.get('/__admin/platform-apps', (_req, res) => {
|
|
102
|
-
res.json({ platformApplications: Array.from(simulator.platformApps.values()) });
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
app.delete('/__admin/topics/:topicName', async (req, res) => {
|
|
106
|
-
try {
|
|
107
|
-
const arn = `arn:aws:sns:us-east-1:123456789012:${req.params.topicName}`;
|
|
108
|
-
await simulator.deleteTopic({ TopicArn: arn });
|
|
109
|
-
res.json({ message: `Topic ${req.params.topicName} deleted` });
|
|
110
|
-
} catch (err) {
|
|
111
|
-
res.status(404).json({ error: err.message });
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
app.post('/__admin/reset', async (_req, res) => {
|
|
116
|
-
await simulator.reset();
|
|
117
|
-
res.json({ message: 'SNS data reset complete' });
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ── Catch-all ───────────────────────────────────────────────
|
|
121
|
-
app.use((req, res) => {
|
|
122
|
-
res.status(404).json({ error: `Route not found: ${req.method} ${req.path}` });
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
return app;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ─────────────────────────────────────────────────────────────
|
|
129
|
-
// Action dispatcher
|
|
130
|
-
// ─────────────────────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Despacha a ação SNS para o método correto do simulador
|
|
134
|
-
* @param {string} action
|
|
135
|
-
* @param {Object} body - request body (urlencoded)
|
|
136
|
-
* @param {Object} simulator - SNSSimulator
|
|
137
|
-
* @param {Object} res - Express response
|
|
138
|
-
* @param {Object} logger
|
|
139
|
-
* @returns {Promise<void>}
|
|
140
|
-
*/
|
|
141
|
-
async function handleAction(action, body, simulator, res, logger) {
|
|
142
|
-
const xml = (tag, content) => wrapResponse(action, tag, content, res);
|
|
143
|
-
|
|
144
|
-
switch (action) {
|
|
145
|
-
|
|
146
|
-
// ── Topics ────────────────────────────────────────────────
|
|
147
|
-
case 'CreateTopic': {
|
|
148
|
-
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
149
|
-
const tags = parseTagList(body, 'Tags.member');
|
|
150
|
-
const r = await simulator.createTopic({ ...parseParams(body), Attributes: attrs, Tags: tags });
|
|
151
|
-
return xml('CreateTopicResult', `<TopicArn>${r.TopicArn}</TopicArn>`);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
case 'DeleteTopic':
|
|
155
|
-
await simulator.deleteTopic(parseParams(body));
|
|
156
|
-
return xml('DeleteTopicResult', '');
|
|
157
|
-
|
|
158
|
-
case 'ListTopics': {
|
|
159
|
-
const r = simulator.listTopics(parseParams(body));
|
|
160
|
-
const topicXml = (r.Topics || []).map(t => `<member><TopicArn>${t.TopicArn}</TopicArn></member>`).join('');
|
|
161
|
-
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
162
|
-
return xml('ListTopicsResult', `<Topics>${topicXml}</Topics>${nt}`);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
case 'GetTopicAttributes': {
|
|
166
|
-
const r = simulator.getTopicAttributes(parseParams(body));
|
|
167
|
-
const attrsXml = buildAttributesXml(r.Attributes);
|
|
168
|
-
return xml('GetTopicAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
case 'SetTopicAttributes':
|
|
172
|
-
await simulator.setTopicAttributes(parseParams(body));
|
|
173
|
-
return xml('SetTopicAttributesResult', '');
|
|
174
|
-
|
|
175
|
-
// ── Subscriptions ──────────────────────────────────────────
|
|
176
|
-
case 'Subscribe': {
|
|
177
|
-
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
178
|
-
const r = await simulator.subscribe({ ...parseParams(body), Attributes: attrs });
|
|
179
|
-
return xml('SubscribeResult', `<SubscriptionArn>${r.SubscriptionArn}</SubscriptionArn>`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
case 'ConfirmSubscription': {
|
|
183
|
-
const r = await simulator.confirmSubscription(parseParams(body));
|
|
184
|
-
return xml('ConfirmSubscriptionResult', `<SubscriptionArn>${r.SubscriptionArn}</SubscriptionArn>`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
case 'Unsubscribe':
|
|
188
|
-
await simulator.unsubscribe(parseParams(body));
|
|
189
|
-
return xml('UnsubscribeResult', '');
|
|
190
|
-
|
|
191
|
-
case 'ListSubscriptions': {
|
|
192
|
-
const r = simulator.listSubscriptions(parseParams(body));
|
|
193
|
-
const subsXml = formatSubscriptionsXml(r.Subscriptions || []);
|
|
194
|
-
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
195
|
-
return xml('ListSubscriptionsResult', `<Subscriptions>${subsXml}</Subscriptions>${nt}`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
case 'ListSubscriptionsByTopic': {
|
|
199
|
-
const r = simulator.listSubscriptionsByTopic(parseParams(body));
|
|
200
|
-
const subsXml = formatSubscriptionsXml(r.Subscriptions || []);
|
|
201
|
-
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
202
|
-
return xml('ListSubscriptionsByTopicResult', `<Subscriptions>${subsXml}</Subscriptions>${nt}`);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
case 'GetSubscriptionAttributes': {
|
|
206
|
-
const r = simulator.getSubscriptionAttributes(parseParams(body));
|
|
207
|
-
const attrsXml = buildAttributesXml(r.Attributes);
|
|
208
|
-
return xml('GetSubscriptionAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
case 'SetSubscriptionAttributes':
|
|
212
|
-
await simulator.setSubscriptionAttributes(parseParams(body));
|
|
213
|
-
return xml('SetSubscriptionAttributesResult', '');
|
|
214
|
-
|
|
215
|
-
// ── Publish ────────────────────────────────────────────────
|
|
216
|
-
case 'Publish': {
|
|
217
|
-
const msgAttrs = parseMessageAttributes(body);
|
|
218
|
-
const r = await simulator.publish({ ...parseParams(body), MessageAttributes: msgAttrs });
|
|
219
|
-
const seqXml = r.SequenceNumber ? `<SequenceNumber>${r.SequenceNumber}</SequenceNumber>` : '';
|
|
220
|
-
return xml('PublishResult', `<MessageId>${r.MessageId}</MessageId>${seqXml}`);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
case 'PublishBatch': {
|
|
224
|
-
const entries = parsePublishBatchEntries(body);
|
|
225
|
-
const r = await simulator.publishBatch({ TopicArn: body.TopicArn, PublishBatchRequestEntries: entries });
|
|
226
|
-
const succXml = (r.Successful || []).map(s =>
|
|
227
|
-
`<member><Id>${s.Id}</Id><MessageId>${s.MessageId}</MessageId>${s.SequenceNumber ? `<SequenceNumber>${s.SequenceNumber}</SequenceNumber>` : ''}</member>`
|
|
228
|
-
).join('');
|
|
229
|
-
const failXml = (r.Failed || []).map(f =>
|
|
230
|
-
`<member><Id>${f.Id}</Id><Code>${f.Code}</Code><Message>${escapeXml(f.Message)}</Message><SenderFault>${f.SenderFault}</SenderFault></member>`
|
|
231
|
-
).join('');
|
|
232
|
-
return xml('PublishBatchResult',
|
|
233
|
-
`<Successful>${succXml}</Successful><Failed>${failXml}</Failed>`
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Tags ───────────────────────────────────────────────────
|
|
238
|
-
case 'TagResource': {
|
|
239
|
-
const tags = parseTagList(body, 'Tags.member');
|
|
240
|
-
await simulator.tagResource({ ResourceArn: body.ResourceArn, Tags: tags });
|
|
241
|
-
return xml('TagResourceResult', '');
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
case 'UntagResource': {
|
|
245
|
-
const tagKeys = parseTagKeys(body);
|
|
246
|
-
await simulator.untagResource({ ResourceArn: body.ResourceArn, TagKeys: tagKeys });
|
|
247
|
-
return xml('UntagResourceResult', '');
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
case 'ListTagsForResource': {
|
|
251
|
-
const r = simulator.listTagsForResource(parseParams(body));
|
|
252
|
-
const tagXml = (r.Tags || []).map(t =>
|
|
253
|
-
`<member><Key>${escapeXml(t.Key)}</Key><Value>${escapeXml(t.Value)}</Value></member>`
|
|
254
|
-
).join('');
|
|
255
|
-
return xml('ListTagsForResourceResult', `<Tags>${tagXml}</Tags>`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// ── Platform Applications ──────────────────────────────────
|
|
259
|
-
case 'CreatePlatformApplication': {
|
|
260
|
-
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
261
|
-
const r = await simulator.createPlatformApplication({ ...parseParams(body), Attributes: attrs });
|
|
262
|
-
return xml('CreatePlatformApplicationResult', `<PlatformApplicationArn>${r.PlatformApplicationArn}</PlatformApplicationArn>`);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
case 'DeletePlatformApplication':
|
|
266
|
-
await simulator.deletePlatformApplication(parseParams(body));
|
|
267
|
-
return xml('DeletePlatformApplicationResult', '');
|
|
268
|
-
|
|
269
|
-
case 'ListPlatformApplications': {
|
|
270
|
-
const r = simulator.listPlatformApplications(parseParams(body));
|
|
271
|
-
const apXml = (r.PlatformApplications || []).map(a =>
|
|
272
|
-
`<member><PlatformApplicationArn>${a.PlatformApplicationArn}</PlatformApplicationArn>${buildAttributesXml(a.Attributes)}</member>`
|
|
273
|
-
).join('');
|
|
274
|
-
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
275
|
-
return xml('ListPlatformApplicationsResult', `<PlatformApplications>${apXml}</PlatformApplications>${nt}`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
case 'CreatePlatformEndpoint': {
|
|
279
|
-
const r = await simulator.createPlatformEndpoint(parseParams(body));
|
|
280
|
-
return xml('CreatePlatformEndpointResult', `<EndpointArn>${r.EndpointArn}</EndpointArn>`);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
case 'DeleteEndpoint':
|
|
284
|
-
await simulator.deleteEndpoint(parseParams(body));
|
|
285
|
-
return xml('DeleteEndpointResult', '');
|
|
286
|
-
|
|
287
|
-
case 'GetEndpointAttributes': {
|
|
288
|
-
const r = simulator.getEndpointAttributes(parseParams(body));
|
|
289
|
-
const attrsXml = buildAttributesXml(r.Attributes);
|
|
290
|
-
return xml('GetEndpointAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
case 'SetEndpointAttributes': {
|
|
294
|
-
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
295
|
-
await simulator.setEndpointAttributes({ EndpointArn: body.EndpointArn, Attributes: attrs });
|
|
296
|
-
return xml('SetEndpointAttributesResult', '');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
case 'ListEndpointsByPlatformApplication': {
|
|
300
|
-
const r = simulator.listEndpointsByPlatformApplication(parseParams(body));
|
|
301
|
-
const epXml = (r.Endpoints || []).map(e =>
|
|
302
|
-
`<member><EndpointArn>${e.EndpointArn}</EndpointArn>${buildAttributesXml(e.Attributes)}</member>`
|
|
303
|
-
).join('');
|
|
304
|
-
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
305
|
-
return xml('ListEndpointsByPlatformApplicationResult', `<Endpoints>${epXml}</Endpoints>${nt}`);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ── SMS Opt-out ────────────────────────────────────────────
|
|
309
|
-
case 'CheckIfPhoneNumberIsOptedOut': {
|
|
310
|
-
const r = simulator.checkIfPhoneNumberIsOptedOut(parseParams(body));
|
|
311
|
-
return xml('CheckIfPhoneNumberIsOptedOutResult', `<isOptedOut>${r.isOptedOut}</isOptedOut>`);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
case 'ListPhoneNumbersOptedOut': {
|
|
315
|
-
const r = simulator.listPhoneNumbersOptedOut();
|
|
316
|
-
const phonesXml = (r.phoneNumbers || []).map(p => `<member>${escapeXml(p)}</member>`).join('');
|
|
317
|
-
return xml('ListPhoneNumbersOptedOutResult', `<phoneNumbers>${phonesXml}</phoneNumbers>`);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
case 'OptInPhoneNumber':
|
|
321
|
-
await simulator.optInPhoneNumber(parseParams(body));
|
|
322
|
-
return xml('OptInPhoneNumberResult', '');
|
|
323
|
-
|
|
324
|
-
// ── SMS Attributes ─────────────────────────────────────────
|
|
325
|
-
case 'SetSMSAttributes': {
|
|
326
|
-
const attrs = parseNumberedParams(body, 'attributes.entry');
|
|
327
|
-
await simulator.setSmsAttributes({ attributes: attrs });
|
|
328
|
-
return xml('SetSMSAttributesResult', '');
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
case 'GetSMSAttributes': {
|
|
332
|
-
const attrKeys = Object.entries(body)
|
|
333
|
-
.filter(([k]) => k.startsWith('attributes.member.'))
|
|
334
|
-
.map(([, v]) => v);
|
|
335
|
-
const r = simulator.getSmsAttributes({ attributes: attrKeys });
|
|
336
|
-
const attrsXml = buildAttributesXml(r.attributes);
|
|
337
|
-
return xml('GetSMSAttributesResult', `<attributes>${attrsXml}</attributes>`);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
default:
|
|
341
|
-
logger.warn('SNS', `Unknown action: ${action}`);
|
|
342
|
-
return res.status(400).set('Content-Type', 'text/xml').send(
|
|
343
|
-
xmlError('InvalidAction', `Action not supported: ${action}`)
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// ─────────────────────────────────────────────────────────────
|
|
349
|
-
// XML Helpers
|
|
350
|
-
// ─────────────────────────────────────────────────────────────
|
|
351
|
-
|
|
352
|
-
/**
|
|
353
|
-
* Envolve resultado em envelope XML SNS e envia resposta
|
|
354
|
-
* @param {string} action
|
|
355
|
-
* @param {string} resultTag
|
|
356
|
-
* @param {string} content
|
|
357
|
-
* @param {Object} res - Express response
|
|
358
|
-
*/
|
|
359
|
-
function wrapResponse(action, resultTag, content, res) {
|
|
360
|
-
const requestId = require('crypto').randomUUID();
|
|
361
|
-
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
362
|
-
<${action}Response xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
363
|
-
<${resultTag}>
|
|
364
|
-
${content}
|
|
365
|
-
</${resultTag}>
|
|
366
|
-
<ResponseMetadata>
|
|
367
|
-
<RequestId>${requestId}</RequestId>
|
|
368
|
-
</ResponseMetadata>
|
|
369
|
-
</${action}Response>`;
|
|
370
|
-
|
|
371
|
-
return res.set('Content-Type', 'text/xml').send(xml);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/**
|
|
375
|
-
* Cria resposta de erro XML
|
|
376
|
-
* @param {string} code
|
|
377
|
-
* @param {string} message
|
|
378
|
-
* @returns {string}
|
|
379
|
-
*/
|
|
380
|
-
function xmlError(code, message) {
|
|
381
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
382
|
-
<ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
383
|
-
<Error>
|
|
384
|
-
<Type>Sender</Type>
|
|
385
|
-
<Code>${escapeXml(code)}</Code>
|
|
386
|
-
<Message>${escapeXml(message)}</Message>
|
|
387
|
-
</Error>
|
|
388
|
-
<RequestId>${require('crypto').randomUUID()}</RequestId>
|
|
389
|
-
</ErrorResponse>`;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Formata lista de subscriptions como XML
|
|
394
|
-
* @param {Array} subs
|
|
395
|
-
* @returns {string}
|
|
396
|
-
*/
|
|
397
|
-
function formatSubscriptionsXml(subs) {
|
|
398
|
-
return subs.map(s => `
|
|
399
|
-
<member>
|
|
400
|
-
<TopicArn>${escapeXml(s.TopicArn)}</TopicArn>
|
|
401
|
-
<Protocol>${escapeXml(s.Protocol)}</Protocol>
|
|
402
|
-
<SubscriptionArn>${escapeXml(s.SubscriptionArn)}</SubscriptionArn>
|
|
403
|
-
<Owner>${escapeXml(s.Owner)}</Owner>
|
|
404
|
-
<Endpoint>${escapeXml(s.Endpoint)}</Endpoint>
|
|
405
|
-
</member>`).join('');
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/**
|
|
409
|
-
* Constrói XML de atributos (entry key/value)
|
|
410
|
-
* @param {Object} attrs
|
|
411
|
-
* @returns {string}
|
|
412
|
-
*/
|
|
413
|
-
function buildAttributesXml(attrs = {}) {
|
|
414
|
-
return Object.entries(attrs)
|
|
415
|
-
.filter(([, v]) => v !== undefined && v !== null)
|
|
416
|
-
.map(([k, v]) => `<entry><key>${escapeXml(k)}</key><value>${escapeXml(String(v))}</value></entry>`)
|
|
417
|
-
.join('');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Escapa caracteres especiais XML
|
|
422
|
-
* @param {string} str
|
|
423
|
-
* @returns {string}
|
|
424
|
-
*/
|
|
425
|
-
function escapeXml(str) {
|
|
426
|
-
return String(str)
|
|
427
|
-
.replace(/&/g, '&')
|
|
428
|
-
.replace(/</g, '<')
|
|
429
|
-
.replace(/>/g, '>')
|
|
430
|
-
.replace(/"/g, '"')
|
|
431
|
-
.replace(/'/g, ''');
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// ─────────────────────────────────────────────────────────────
|
|
435
|
-
// Parameter Parsers
|
|
436
|
-
// ─────────────────────────────────────────────────────────────
|
|
437
|
-
|
|
438
|
-
/**
|
|
439
|
-
* Extrai parâmetros simples do body (exclui Action/Version)
|
|
440
|
-
* @param {Object} body
|
|
441
|
-
* @returns {Object}
|
|
442
|
-
*/
|
|
443
|
-
function parseParams(body) {
|
|
444
|
-
const skip = new Set(['Action', 'Version', 'AWSAccessKeyId', 'Signature', 'SignatureMethod', 'SignatureVersion', 'Timestamp']);
|
|
445
|
-
const result = {};
|
|
446
|
-
|
|
447
|
-
for (const [key, value] of Object.entries(body)) {
|
|
448
|
-
if (skip.has(key) || key.includes('.')) continue;
|
|
449
|
-
result[key] = value;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
return result;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* Parseia entradas numeradas tipo "Attributes.entry.N.key / .value"
|
|
457
|
-
* @param {Object} body
|
|
458
|
-
* @param {string} prefix - ex: 'Attributes.entry'
|
|
459
|
-
* @returns {Object}
|
|
460
|
-
*/
|
|
461
|
-
function parseNumberedParams(body, prefix) {
|
|
462
|
-
const result = {};
|
|
463
|
-
const entries = {};
|
|
464
|
-
|
|
465
|
-
for (const [key, value] of Object.entries(body)) {
|
|
466
|
-
if (!key.startsWith(prefix + '.')) continue;
|
|
467
|
-
|
|
468
|
-
const rest = key.slice(prefix.length + 1); // '1.key' ou '1.value'
|
|
469
|
-
const parts = rest.split('.');
|
|
470
|
-
const idx = parts[0];
|
|
471
|
-
const field = parts.slice(1).join('.');
|
|
472
|
-
|
|
473
|
-
if (!entries[idx]) entries[idx] = {};
|
|
474
|
-
entries[idx][field] = value;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
for (const entry of Object.values(entries)) {
|
|
478
|
-
if (entry.key && entry.value !== undefined) {
|
|
479
|
-
result[entry.key] = entry.value;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
return result;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* Parseia lista de tags no formato "Tags.member.N.Key / .Value"
|
|
488
|
-
* @param {Object} body
|
|
489
|
-
* @param {string} prefix - ex: 'Tags.member'
|
|
490
|
-
* @returns {Array<{Key:string, Value:string}>}
|
|
491
|
-
*/
|
|
492
|
-
function parseTagList(body, prefix) {
|
|
493
|
-
const entries = {};
|
|
494
|
-
|
|
495
|
-
for (const [key, value] of Object.entries(body)) {
|
|
496
|
-
if (!key.startsWith(prefix + '.')) continue;
|
|
497
|
-
|
|
498
|
-
const rest = key.slice(prefix.length + 1);
|
|
499
|
-
const parts = rest.split('.');
|
|
500
|
-
const idx = parts[0];
|
|
501
|
-
const field = parts[1]; // 'Key' ou 'Value'
|
|
502
|
-
|
|
503
|
-
if (!entries[idx]) entries[idx] = {};
|
|
504
|
-
entries[idx][field] = value;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return Object.values(entries).filter(e => e.Key).map(e => ({ Key: e.Key, Value: e.Value || '' }));
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* Parseia lista de chaves de tag no formato "TagKeys.member.N"
|
|
512
|
-
* @param {Object} body
|
|
513
|
-
* @returns {string[]}
|
|
514
|
-
*/
|
|
515
|
-
function parseTagKeys(body) {
|
|
516
|
-
const keys = [];
|
|
517
|
-
|
|
518
|
-
for (const [key, value] of Object.entries(body)) {
|
|
519
|
-
if (/^TagKeys\.member\.\d+$/.test(key)) keys.push(value);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
return keys;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Parseia MessageAttributes do body SNS
|
|
527
|
-
* Formato: MessageAttributes.entry.N.Name / .Value.DataType / .Value.StringValue
|
|
528
|
-
* @param {Object} body
|
|
529
|
-
* @returns {Object}
|
|
530
|
-
*/
|
|
531
|
-
function parseMessageAttributes(body) {
|
|
532
|
-
const entries = {};
|
|
533
|
-
|
|
534
|
-
for (const [key, value] of Object.entries(body)) {
|
|
535
|
-
const m = key.match(/^MessageAttributes\.entry\.(\d+)\.(Name|Value\.(DataType|StringValue|BinaryValue))$/);
|
|
536
|
-
if (!m) continue;
|
|
537
|
-
|
|
538
|
-
const idx = m[1];
|
|
539
|
-
const field = m[2];
|
|
540
|
-
|
|
541
|
-
if (!entries[idx]) entries[idx] = { Value: {} };
|
|
542
|
-
|
|
543
|
-
if (field === 'Name') entries[idx].Name = value;
|
|
544
|
-
else if (field === 'Value.DataType') entries[idx].Value.DataType = value;
|
|
545
|
-
else if (field === 'Value.StringValue') entries[idx].Value.StringValue = value;
|
|
546
|
-
else if (field === 'Value.BinaryValue') entries[idx].Value.BinaryValue = value;
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const result = {};
|
|
550
|
-
for (const entry of Object.values(entries)) {
|
|
551
|
-
if (entry.Name) result[entry.Name] = entry.Value;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
return result;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
/**
|
|
558
|
-
* Parseia entradas de PublishBatch
|
|
559
|
-
* Formato: PublishBatchRequestEntries.member.N.Id / .Message / etc.
|
|
560
|
-
* @param {Object} body
|
|
561
|
-
* @returns {Array}
|
|
562
|
-
*/
|
|
563
|
-
function parsePublishBatchEntries(body) {
|
|
564
|
-
const entries = {};
|
|
565
|
-
|
|
566
|
-
for (const [key, value] of Object.entries(body)) {
|
|
567
|
-
const m = key.match(/^PublishBatchRequestEntries\.member\.(\d+)\.(.+)$/);
|
|
568
|
-
if (!m) continue;
|
|
569
|
-
|
|
570
|
-
const idx = m[1];
|
|
571
|
-
const field = m[2];
|
|
572
|
-
|
|
573
|
-
if (!entries[idx]) entries[idx] = {};
|
|
574
|
-
entries[idx][field] = value;
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
return Object.values(entries);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
module.exports = { createSNSServer };
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SNS HTTP Server — Query Protocol completo
|
|
3
|
+
* Compatível com AWS SDK v3 SNS Client
|
|
4
|
+
*
|
|
5
|
+
* Wire Protocol: Query (application/x-www-form-urlencoded) + respostas XML
|
|
6
|
+
* Endpoint principal: POST /
|
|
7
|
+
* Ações suportadas: CreateTopic, DeleteTopic, ListTopics, GetTopicAttributes,
|
|
8
|
+
* SetTopicAttributes, Subscribe, Unsubscribe, ConfirmSubscription,
|
|
9
|
+
* ListSubscriptions, ListSubscriptionsByTopic, GetSubscriptionAttributes,
|
|
10
|
+
* SetSubscriptionAttributes, Publish, PublishBatch,
|
|
11
|
+
* TagResource, UntagResource, ListTagsForResource,
|
|
12
|
+
* CreatePlatformApplication, DeletePlatformApplication, ListPlatformApplications,
|
|
13
|
+
* CreatePlatformEndpoint, DeleteEndpoint, GetEndpointAttributes,
|
|
14
|
+
* SetEndpointAttributes, ListEndpointsByPlatformApplication,
|
|
15
|
+
* CheckIfPhoneNumberIsOptedOut, ListPhoneNumbersOptedOut, OptInPhoneNumber,
|
|
16
|
+
* SetSMSAttributes, GetSMSAttributes
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const express = require('express');
|
|
22
|
+
const cors = require('cors');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cria Express application do SNS
|
|
26
|
+
* @param {Object} simulator - SNSSimulator instance
|
|
27
|
+
* @param {Object} config - Service configuration
|
|
28
|
+
* @param {Object} logger - Logger instance
|
|
29
|
+
* @returns {import('express').Application}
|
|
30
|
+
*/
|
|
31
|
+
function createSNSServer(simulator, config, logger) {
|
|
32
|
+
const app = express();
|
|
33
|
+
|
|
34
|
+
// ── Middlewares globais ──────────────────────────────────────
|
|
35
|
+
if (config.cors?.enabled !== false) {
|
|
36
|
+
app.use(cors({ origin: config.cors?.origin || '*' }));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
40
|
+
app.use(express.json({ limit: '10mb' }));
|
|
41
|
+
|
|
42
|
+
app.use((req, _res, next) => {
|
|
43
|
+
const action = req.body?.Action || req.query?.Action;
|
|
44
|
+
if (action) logger.debug('SNS', `${req.method} ${req.path} Action=${action}`);
|
|
45
|
+
next();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── Endpoint principal (Query Protocol) ─────────────────────
|
|
49
|
+
app.post('/', async (req, res) => {
|
|
50
|
+
const body = req.body || {};
|
|
51
|
+
const action = body.Action || req.query.Action;
|
|
52
|
+
|
|
53
|
+
if (!action) {
|
|
54
|
+
return res.status(400).set('Content-Type', 'text/xml').send(
|
|
55
|
+
xmlError('MissingAction', 'Action is required')
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
return await handleAction(action, body, simulator, res, logger);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.error('SNS', `[${action}] ${err.message}`);
|
|
63
|
+
const status = err.statusCode || 400;
|
|
64
|
+
return res.status(status).set('Content-Type', 'text/xml').send(
|
|
65
|
+
xmlError(err.code || 'InternalFailure', err.message)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Admin routes ─────────────────────────────────────────────
|
|
71
|
+
app.get('/__admin/health', (_req, res) => {
|
|
72
|
+
res.json({
|
|
73
|
+
status: 'healthy',
|
|
74
|
+
service: 'sns',
|
|
75
|
+
topics: simulator.topics.size,
|
|
76
|
+
subscriptions: simulator.subscriptions.size,
|
|
77
|
+
platformApps: simulator.platformApps.size,
|
|
78
|
+
endpoints: simulator.platformEndpoints.size,
|
|
79
|
+
timestamp: new Date().toISOString()
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.get('/__admin/topics', (_req, res) => {
|
|
84
|
+
res.json({ topics: Array.from(simulator.topics.values()) });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
app.get('/__admin/subscriptions', (_req, res) => {
|
|
88
|
+
res.json({ subscriptions: Array.from(simulator.subscriptions.values()) });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
app.get('/__admin/subscriptions/:topicName', (req, res) => {
|
|
92
|
+
const arn = `arn:aws:sns:us-east-1:123456789012:${req.params.topicName}`;
|
|
93
|
+
const subs = Array.from(simulator.subscriptions.values()).filter(s => s.TopicArn === arn);
|
|
94
|
+
res.json({ subscriptions: subs });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.get('/__admin/publish-log', (_req, res) => {
|
|
98
|
+
res.json({ messages: simulator.publishLog });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
app.get('/__admin/platform-apps', (_req, res) => {
|
|
102
|
+
res.json({ platformApplications: Array.from(simulator.platformApps.values()) });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.delete('/__admin/topics/:topicName', async (req, res) => {
|
|
106
|
+
try {
|
|
107
|
+
const arn = `arn:aws:sns:us-east-1:123456789012:${req.params.topicName}`;
|
|
108
|
+
await simulator.deleteTopic({ TopicArn: arn });
|
|
109
|
+
res.json({ message: `Topic ${req.params.topicName} deleted` });
|
|
110
|
+
} catch (err) {
|
|
111
|
+
res.status(404).json({ error: err.message });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
app.post('/__admin/reset', async (_req, res) => {
|
|
116
|
+
await simulator.reset();
|
|
117
|
+
res.json({ message: 'SNS data reset complete' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Catch-all ───────────────────────────────────────────────
|
|
121
|
+
app.use((req, res) => {
|
|
122
|
+
res.status(404).json({ error: `Route not found: ${req.method} ${req.path}` });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return app;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────
|
|
129
|
+
// Action dispatcher
|
|
130
|
+
// ─────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Despacha a ação SNS para o método correto do simulador
|
|
134
|
+
* @param {string} action
|
|
135
|
+
* @param {Object} body - request body (urlencoded)
|
|
136
|
+
* @param {Object} simulator - SNSSimulator
|
|
137
|
+
* @param {Object} res - Express response
|
|
138
|
+
* @param {Object} logger
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async function handleAction(action, body, simulator, res, logger) {
|
|
142
|
+
const xml = (tag, content) => wrapResponse(action, tag, content, res);
|
|
143
|
+
|
|
144
|
+
switch (action) {
|
|
145
|
+
|
|
146
|
+
// ── Topics ────────────────────────────────────────────────
|
|
147
|
+
case 'CreateTopic': {
|
|
148
|
+
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
149
|
+
const tags = parseTagList(body, 'Tags.member');
|
|
150
|
+
const r = await simulator.createTopic({ ...parseParams(body), Attributes: attrs, Tags: tags });
|
|
151
|
+
return xml('CreateTopicResult', `<TopicArn>${r.TopicArn}</TopicArn>`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case 'DeleteTopic':
|
|
155
|
+
await simulator.deleteTopic(parseParams(body));
|
|
156
|
+
return xml('DeleteTopicResult', '');
|
|
157
|
+
|
|
158
|
+
case 'ListTopics': {
|
|
159
|
+
const r = simulator.listTopics(parseParams(body));
|
|
160
|
+
const topicXml = (r.Topics || []).map(t => `<member><TopicArn>${t.TopicArn}</TopicArn></member>`).join('');
|
|
161
|
+
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
162
|
+
return xml('ListTopicsResult', `<Topics>${topicXml}</Topics>${nt}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
case 'GetTopicAttributes': {
|
|
166
|
+
const r = simulator.getTopicAttributes(parseParams(body));
|
|
167
|
+
const attrsXml = buildAttributesXml(r.Attributes);
|
|
168
|
+
return xml('GetTopicAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case 'SetTopicAttributes':
|
|
172
|
+
await simulator.setTopicAttributes(parseParams(body));
|
|
173
|
+
return xml('SetTopicAttributesResult', '');
|
|
174
|
+
|
|
175
|
+
// ── Subscriptions ──────────────────────────────────────────
|
|
176
|
+
case 'Subscribe': {
|
|
177
|
+
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
178
|
+
const r = await simulator.subscribe({ ...parseParams(body), Attributes: attrs });
|
|
179
|
+
return xml('SubscribeResult', `<SubscriptionArn>${r.SubscriptionArn}</SubscriptionArn>`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'ConfirmSubscription': {
|
|
183
|
+
const r = await simulator.confirmSubscription(parseParams(body));
|
|
184
|
+
return xml('ConfirmSubscriptionResult', `<SubscriptionArn>${r.SubscriptionArn}</SubscriptionArn>`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'Unsubscribe':
|
|
188
|
+
await simulator.unsubscribe(parseParams(body));
|
|
189
|
+
return xml('UnsubscribeResult', '');
|
|
190
|
+
|
|
191
|
+
case 'ListSubscriptions': {
|
|
192
|
+
const r = simulator.listSubscriptions(parseParams(body));
|
|
193
|
+
const subsXml = formatSubscriptionsXml(r.Subscriptions || []);
|
|
194
|
+
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
195
|
+
return xml('ListSubscriptionsResult', `<Subscriptions>${subsXml}</Subscriptions>${nt}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
case 'ListSubscriptionsByTopic': {
|
|
199
|
+
const r = simulator.listSubscriptionsByTopic(parseParams(body));
|
|
200
|
+
const subsXml = formatSubscriptionsXml(r.Subscriptions || []);
|
|
201
|
+
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
202
|
+
return xml('ListSubscriptionsByTopicResult', `<Subscriptions>${subsXml}</Subscriptions>${nt}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
case 'GetSubscriptionAttributes': {
|
|
206
|
+
const r = simulator.getSubscriptionAttributes(parseParams(body));
|
|
207
|
+
const attrsXml = buildAttributesXml(r.Attributes);
|
|
208
|
+
return xml('GetSubscriptionAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'SetSubscriptionAttributes':
|
|
212
|
+
await simulator.setSubscriptionAttributes(parseParams(body));
|
|
213
|
+
return xml('SetSubscriptionAttributesResult', '');
|
|
214
|
+
|
|
215
|
+
// ── Publish ────────────────────────────────────────────────
|
|
216
|
+
case 'Publish': {
|
|
217
|
+
const msgAttrs = parseMessageAttributes(body);
|
|
218
|
+
const r = await simulator.publish({ ...parseParams(body), MessageAttributes: msgAttrs });
|
|
219
|
+
const seqXml = r.SequenceNumber ? `<SequenceNumber>${r.SequenceNumber}</SequenceNumber>` : '';
|
|
220
|
+
return xml('PublishResult', `<MessageId>${r.MessageId}</MessageId>${seqXml}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'PublishBatch': {
|
|
224
|
+
const entries = parsePublishBatchEntries(body);
|
|
225
|
+
const r = await simulator.publishBatch({ TopicArn: body.TopicArn, PublishBatchRequestEntries: entries });
|
|
226
|
+
const succXml = (r.Successful || []).map(s =>
|
|
227
|
+
`<member><Id>${s.Id}</Id><MessageId>${s.MessageId}</MessageId>${s.SequenceNumber ? `<SequenceNumber>${s.SequenceNumber}</SequenceNumber>` : ''}</member>`
|
|
228
|
+
).join('');
|
|
229
|
+
const failXml = (r.Failed || []).map(f =>
|
|
230
|
+
`<member><Id>${f.Id}</Id><Code>${f.Code}</Code><Message>${escapeXml(f.Message)}</Message><SenderFault>${f.SenderFault}</SenderFault></member>`
|
|
231
|
+
).join('');
|
|
232
|
+
return xml('PublishBatchResult',
|
|
233
|
+
`<Successful>${succXml}</Successful><Failed>${failXml}</Failed>`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── Tags ───────────────────────────────────────────────────
|
|
238
|
+
case 'TagResource': {
|
|
239
|
+
const tags = parseTagList(body, 'Tags.member');
|
|
240
|
+
await simulator.tagResource({ ResourceArn: body.ResourceArn, Tags: tags });
|
|
241
|
+
return xml('TagResourceResult', '');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'UntagResource': {
|
|
245
|
+
const tagKeys = parseTagKeys(body);
|
|
246
|
+
await simulator.untagResource({ ResourceArn: body.ResourceArn, TagKeys: tagKeys });
|
|
247
|
+
return xml('UntagResourceResult', '');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'ListTagsForResource': {
|
|
251
|
+
const r = simulator.listTagsForResource(parseParams(body));
|
|
252
|
+
const tagXml = (r.Tags || []).map(t =>
|
|
253
|
+
`<member><Key>${escapeXml(t.Key)}</Key><Value>${escapeXml(t.Value)}</Value></member>`
|
|
254
|
+
).join('');
|
|
255
|
+
return xml('ListTagsForResourceResult', `<Tags>${tagXml}</Tags>`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Platform Applications ──────────────────────────────────
|
|
259
|
+
case 'CreatePlatformApplication': {
|
|
260
|
+
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
261
|
+
const r = await simulator.createPlatformApplication({ ...parseParams(body), Attributes: attrs });
|
|
262
|
+
return xml('CreatePlatformApplicationResult', `<PlatformApplicationArn>${r.PlatformApplicationArn}</PlatformApplicationArn>`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case 'DeletePlatformApplication':
|
|
266
|
+
await simulator.deletePlatformApplication(parseParams(body));
|
|
267
|
+
return xml('DeletePlatformApplicationResult', '');
|
|
268
|
+
|
|
269
|
+
case 'ListPlatformApplications': {
|
|
270
|
+
const r = simulator.listPlatformApplications(parseParams(body));
|
|
271
|
+
const apXml = (r.PlatformApplications || []).map(a =>
|
|
272
|
+
`<member><PlatformApplicationArn>${a.PlatformApplicationArn}</PlatformApplicationArn>${buildAttributesXml(a.Attributes)}</member>`
|
|
273
|
+
).join('');
|
|
274
|
+
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
275
|
+
return xml('ListPlatformApplicationsResult', `<PlatformApplications>${apXml}</PlatformApplications>${nt}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case 'CreatePlatformEndpoint': {
|
|
279
|
+
const r = await simulator.createPlatformEndpoint(parseParams(body));
|
|
280
|
+
return xml('CreatePlatformEndpointResult', `<EndpointArn>${r.EndpointArn}</EndpointArn>`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case 'DeleteEndpoint':
|
|
284
|
+
await simulator.deleteEndpoint(parseParams(body));
|
|
285
|
+
return xml('DeleteEndpointResult', '');
|
|
286
|
+
|
|
287
|
+
case 'GetEndpointAttributes': {
|
|
288
|
+
const r = simulator.getEndpointAttributes(parseParams(body));
|
|
289
|
+
const attrsXml = buildAttributesXml(r.Attributes);
|
|
290
|
+
return xml('GetEndpointAttributesResult', `<Attributes>${attrsXml}</Attributes>`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case 'SetEndpointAttributes': {
|
|
294
|
+
const attrs = parseNumberedParams(body, 'Attributes.entry');
|
|
295
|
+
await simulator.setEndpointAttributes({ EndpointArn: body.EndpointArn, Attributes: attrs });
|
|
296
|
+
return xml('SetEndpointAttributesResult', '');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case 'ListEndpointsByPlatformApplication': {
|
|
300
|
+
const r = simulator.listEndpointsByPlatformApplication(parseParams(body));
|
|
301
|
+
const epXml = (r.Endpoints || []).map(e =>
|
|
302
|
+
`<member><EndpointArn>${e.EndpointArn}</EndpointArn>${buildAttributesXml(e.Attributes)}</member>`
|
|
303
|
+
).join('');
|
|
304
|
+
const nt = r.NextToken ? `<NextToken>${r.NextToken}</NextToken>` : '';
|
|
305
|
+
return xml('ListEndpointsByPlatformApplicationResult', `<Endpoints>${epXml}</Endpoints>${nt}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── SMS Opt-out ────────────────────────────────────────────
|
|
309
|
+
case 'CheckIfPhoneNumberIsOptedOut': {
|
|
310
|
+
const r = simulator.checkIfPhoneNumberIsOptedOut(parseParams(body));
|
|
311
|
+
return xml('CheckIfPhoneNumberIsOptedOutResult', `<isOptedOut>${r.isOptedOut}</isOptedOut>`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'ListPhoneNumbersOptedOut': {
|
|
315
|
+
const r = simulator.listPhoneNumbersOptedOut();
|
|
316
|
+
const phonesXml = (r.phoneNumbers || []).map(p => `<member>${escapeXml(p)}</member>`).join('');
|
|
317
|
+
return xml('ListPhoneNumbersOptedOutResult', `<phoneNumbers>${phonesXml}</phoneNumbers>`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'OptInPhoneNumber':
|
|
321
|
+
await simulator.optInPhoneNumber(parseParams(body));
|
|
322
|
+
return xml('OptInPhoneNumberResult', '');
|
|
323
|
+
|
|
324
|
+
// ── SMS Attributes ─────────────────────────────────────────
|
|
325
|
+
case 'SetSMSAttributes': {
|
|
326
|
+
const attrs = parseNumberedParams(body, 'attributes.entry');
|
|
327
|
+
await simulator.setSmsAttributes({ attributes: attrs });
|
|
328
|
+
return xml('SetSMSAttributesResult', '');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
case 'GetSMSAttributes': {
|
|
332
|
+
const attrKeys = Object.entries(body)
|
|
333
|
+
.filter(([k]) => k.startsWith('attributes.member.'))
|
|
334
|
+
.map(([, v]) => v);
|
|
335
|
+
const r = simulator.getSmsAttributes({ attributes: attrKeys });
|
|
336
|
+
const attrsXml = buildAttributesXml(r.attributes);
|
|
337
|
+
return xml('GetSMSAttributesResult', `<attributes>${attrsXml}</attributes>`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
default:
|
|
341
|
+
logger.warn('SNS', `Unknown action: ${action}`);
|
|
342
|
+
return res.status(400).set('Content-Type', 'text/xml').send(
|
|
343
|
+
xmlError('InvalidAction', `Action not supported: ${action}`)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ─────────────────────────────────────────────────────────────
|
|
349
|
+
// XML Helpers
|
|
350
|
+
// ─────────────────────────────────────────────────────────────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Envolve resultado em envelope XML SNS e envia resposta
|
|
354
|
+
* @param {string} action
|
|
355
|
+
* @param {string} resultTag
|
|
356
|
+
* @param {string} content
|
|
357
|
+
* @param {Object} res - Express response
|
|
358
|
+
*/
|
|
359
|
+
function wrapResponse(action, resultTag, content, res) {
|
|
360
|
+
const requestId = require('crypto').randomUUID();
|
|
361
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
362
|
+
<${action}Response xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
363
|
+
<${resultTag}>
|
|
364
|
+
${content}
|
|
365
|
+
</${resultTag}>
|
|
366
|
+
<ResponseMetadata>
|
|
367
|
+
<RequestId>${requestId}</RequestId>
|
|
368
|
+
</ResponseMetadata>
|
|
369
|
+
</${action}Response>`;
|
|
370
|
+
|
|
371
|
+
return res.set('Content-Type', 'text/xml').send(xml);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Cria resposta de erro XML
|
|
376
|
+
* @param {string} code
|
|
377
|
+
* @param {string} message
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
function xmlError(code, message) {
|
|
381
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
382
|
+
<ErrorResponse xmlns="http://sns.amazonaws.com/doc/2010-03-31/">
|
|
383
|
+
<Error>
|
|
384
|
+
<Type>Sender</Type>
|
|
385
|
+
<Code>${escapeXml(code)}</Code>
|
|
386
|
+
<Message>${escapeXml(message)}</Message>
|
|
387
|
+
</Error>
|
|
388
|
+
<RequestId>${require('crypto').randomUUID()}</RequestId>
|
|
389
|
+
</ErrorResponse>`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Formata lista de subscriptions como XML
|
|
394
|
+
* @param {Array} subs
|
|
395
|
+
* @returns {string}
|
|
396
|
+
*/
|
|
397
|
+
function formatSubscriptionsXml(subs) {
|
|
398
|
+
return subs.map(s => `
|
|
399
|
+
<member>
|
|
400
|
+
<TopicArn>${escapeXml(s.TopicArn)}</TopicArn>
|
|
401
|
+
<Protocol>${escapeXml(s.Protocol)}</Protocol>
|
|
402
|
+
<SubscriptionArn>${escapeXml(s.SubscriptionArn)}</SubscriptionArn>
|
|
403
|
+
<Owner>${escapeXml(s.Owner)}</Owner>
|
|
404
|
+
<Endpoint>${escapeXml(s.Endpoint)}</Endpoint>
|
|
405
|
+
</member>`).join('');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Constrói XML de atributos (entry key/value)
|
|
410
|
+
* @param {Object} attrs
|
|
411
|
+
* @returns {string}
|
|
412
|
+
*/
|
|
413
|
+
function buildAttributesXml(attrs = {}) {
|
|
414
|
+
return Object.entries(attrs)
|
|
415
|
+
.filter(([, v]) => v !== undefined && v !== null)
|
|
416
|
+
.map(([k, v]) => `<entry><key>${escapeXml(k)}</key><value>${escapeXml(String(v))}</value></entry>`)
|
|
417
|
+
.join('');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Escapa caracteres especiais XML
|
|
422
|
+
* @param {string} str
|
|
423
|
+
* @returns {string}
|
|
424
|
+
*/
|
|
425
|
+
function escapeXml(str) {
|
|
426
|
+
return String(str)
|
|
427
|
+
.replace(/&/g, '&')
|
|
428
|
+
.replace(/</g, '<')
|
|
429
|
+
.replace(/>/g, '>')
|
|
430
|
+
.replace(/"/g, '"')
|
|
431
|
+
.replace(/'/g, ''');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ─────────────────────────────────────────────────────────────
|
|
435
|
+
// Parameter Parsers
|
|
436
|
+
// ─────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Extrai parâmetros simples do body (exclui Action/Version)
|
|
440
|
+
* @param {Object} body
|
|
441
|
+
* @returns {Object}
|
|
442
|
+
*/
|
|
443
|
+
function parseParams(body) {
|
|
444
|
+
const skip = new Set(['Action', 'Version', 'AWSAccessKeyId', 'Signature', 'SignatureMethod', 'SignatureVersion', 'Timestamp']);
|
|
445
|
+
const result = {};
|
|
446
|
+
|
|
447
|
+
for (const [key, value] of Object.entries(body)) {
|
|
448
|
+
if (skip.has(key) || key.includes('.')) continue;
|
|
449
|
+
result[key] = value;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Parseia entradas numeradas tipo "Attributes.entry.N.key / .value"
|
|
457
|
+
* @param {Object} body
|
|
458
|
+
* @param {string} prefix - ex: 'Attributes.entry'
|
|
459
|
+
* @returns {Object}
|
|
460
|
+
*/
|
|
461
|
+
function parseNumberedParams(body, prefix) {
|
|
462
|
+
const result = {};
|
|
463
|
+
const entries = {};
|
|
464
|
+
|
|
465
|
+
for (const [key, value] of Object.entries(body)) {
|
|
466
|
+
if (!key.startsWith(prefix + '.')) continue;
|
|
467
|
+
|
|
468
|
+
const rest = key.slice(prefix.length + 1); // '1.key' ou '1.value'
|
|
469
|
+
const parts = rest.split('.');
|
|
470
|
+
const idx = parts[0];
|
|
471
|
+
const field = parts.slice(1).join('.');
|
|
472
|
+
|
|
473
|
+
if (!entries[idx]) entries[idx] = {};
|
|
474
|
+
entries[idx][field] = value;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
for (const entry of Object.values(entries)) {
|
|
478
|
+
if (entry.key && entry.value !== undefined) {
|
|
479
|
+
result[entry.key] = entry.value;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Parseia lista de tags no formato "Tags.member.N.Key / .Value"
|
|
488
|
+
* @param {Object} body
|
|
489
|
+
* @param {string} prefix - ex: 'Tags.member'
|
|
490
|
+
* @returns {Array<{Key:string, Value:string}>}
|
|
491
|
+
*/
|
|
492
|
+
function parseTagList(body, prefix) {
|
|
493
|
+
const entries = {};
|
|
494
|
+
|
|
495
|
+
for (const [key, value] of Object.entries(body)) {
|
|
496
|
+
if (!key.startsWith(prefix + '.')) continue;
|
|
497
|
+
|
|
498
|
+
const rest = key.slice(prefix.length + 1);
|
|
499
|
+
const parts = rest.split('.');
|
|
500
|
+
const idx = parts[0];
|
|
501
|
+
const field = parts[1]; // 'Key' ou 'Value'
|
|
502
|
+
|
|
503
|
+
if (!entries[idx]) entries[idx] = {};
|
|
504
|
+
entries[idx][field] = value;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return Object.values(entries).filter(e => e.Key).map(e => ({ Key: e.Key, Value: e.Value || '' }));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Parseia lista de chaves de tag no formato "TagKeys.member.N"
|
|
512
|
+
* @param {Object} body
|
|
513
|
+
* @returns {string[]}
|
|
514
|
+
*/
|
|
515
|
+
function parseTagKeys(body) {
|
|
516
|
+
const keys = [];
|
|
517
|
+
|
|
518
|
+
for (const [key, value] of Object.entries(body)) {
|
|
519
|
+
if (/^TagKeys\.member\.\d+$/.test(key)) keys.push(value);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return keys;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Parseia MessageAttributes do body SNS
|
|
527
|
+
* Formato: MessageAttributes.entry.N.Name / .Value.DataType / .Value.StringValue
|
|
528
|
+
* @param {Object} body
|
|
529
|
+
* @returns {Object}
|
|
530
|
+
*/
|
|
531
|
+
function parseMessageAttributes(body) {
|
|
532
|
+
const entries = {};
|
|
533
|
+
|
|
534
|
+
for (const [key, value] of Object.entries(body)) {
|
|
535
|
+
const m = key.match(/^MessageAttributes\.entry\.(\d+)\.(Name|Value\.(DataType|StringValue|BinaryValue))$/);
|
|
536
|
+
if (!m) continue;
|
|
537
|
+
|
|
538
|
+
const idx = m[1];
|
|
539
|
+
const field = m[2];
|
|
540
|
+
|
|
541
|
+
if (!entries[idx]) entries[idx] = { Value: {} };
|
|
542
|
+
|
|
543
|
+
if (field === 'Name') entries[idx].Name = value;
|
|
544
|
+
else if (field === 'Value.DataType') entries[idx].Value.DataType = value;
|
|
545
|
+
else if (field === 'Value.StringValue') entries[idx].Value.StringValue = value;
|
|
546
|
+
else if (field === 'Value.BinaryValue') entries[idx].Value.BinaryValue = value;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const result = {};
|
|
550
|
+
for (const entry of Object.values(entries)) {
|
|
551
|
+
if (entry.Name) result[entry.Name] = entry.Value;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Parseia entradas de PublishBatch
|
|
559
|
+
* Formato: PublishBatchRequestEntries.member.N.Id / .Message / etc.
|
|
560
|
+
* @param {Object} body
|
|
561
|
+
* @returns {Array}
|
|
562
|
+
*/
|
|
563
|
+
function parsePublishBatchEntries(body) {
|
|
564
|
+
const entries = {};
|
|
565
|
+
|
|
566
|
+
for (const [key, value] of Object.entries(body)) {
|
|
567
|
+
const m = key.match(/^PublishBatchRequestEntries\.member\.(\d+)\.(.+)$/);
|
|
568
|
+
if (!m) continue;
|
|
569
|
+
|
|
570
|
+
const idx = m[1];
|
|
571
|
+
const field = m[2];
|
|
572
|
+
|
|
573
|
+
if (!entries[idx]) entries[idx] = {};
|
|
574
|
+
entries[idx][field] = value;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return Object.values(entries);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
module.exports = { createSNSServer };
|