@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.
Files changed (77) hide show
  1. package/README.md +594 -481
  2. package/bin/aws-local-simulator.js +63 -63
  3. package/package.json +11 -10
  4. package/src/config/config-loader.js +114 -114
  5. package/src/config/default-config.js +68 -68
  6. package/src/config/env-loader.js +68 -68
  7. package/src/index.js +146 -146
  8. package/src/index.mjs +123 -123
  9. package/src/server.js +227 -227
  10. package/src/services/apigateway/index.js +73 -73
  11. package/src/services/apigateway/server.js +507 -507
  12. package/src/services/apigateway/simulator.js +1261 -1261
  13. package/src/services/athena/index.js +75 -75
  14. package/src/services/athena/server.js +101 -101
  15. package/src/services/athena/simulador.js +998 -998
  16. package/src/services/athena/simulator.js +346 -346
  17. package/src/services/cloudformation/index.js +106 -106
  18. package/src/services/cloudformation/server.js +417 -417
  19. package/src/services/cloudformation/simulador.js +1045 -1045
  20. package/src/services/cloudtrail/index.js +84 -84
  21. package/src/services/cloudtrail/server.js +235 -235
  22. package/src/services/cloudtrail/simulador.js +719 -719
  23. package/src/services/cloudwatch/index.js +84 -84
  24. package/src/services/cloudwatch/server.js +366 -366
  25. package/src/services/cloudwatch/simulador.js +1173 -1173
  26. package/src/services/cognito/index.js +79 -70
  27. package/src/services/cognito/server.js +301 -279
  28. package/src/services/cognito/simulator.js +1655 -1119
  29. package/src/services/config/index.js +96 -96
  30. package/src/services/config/server.js +215 -215
  31. package/src/services/config/simulador.js +1260 -1260
  32. package/src/services/dynamodb/index.js +74 -74
  33. package/src/services/dynamodb/server.js +125 -123
  34. package/src/services/dynamodb/simulator.js +630 -630
  35. package/src/services/ecs/index.js +65 -65
  36. package/src/services/ecs/server.js +235 -233
  37. package/src/services/ecs/simulator.js +844 -844
  38. package/src/services/eventbridge/index.js +89 -89
  39. package/src/services/eventbridge/server.js +209 -209
  40. package/src/services/eventbridge/simulator.js +684 -684
  41. package/src/services/index.js +45 -45
  42. package/src/services/kms/index.js +75 -75
  43. package/src/services/kms/server.js +67 -67
  44. package/src/services/kms/simulator.js +324 -324
  45. package/src/services/lambda/handler-loader.js +183 -183
  46. package/src/services/lambda/index.js +78 -78
  47. package/src/services/lambda/route-registry.js +274 -274
  48. package/src/services/lambda/server.js +145 -145
  49. package/src/services/lambda/simulator.js +199 -182
  50. package/src/services/parameter-store/index.js +80 -80
  51. package/src/services/parameter-store/server.js +50 -50
  52. package/src/services/parameter-store/simulator.js +201 -201
  53. package/src/services/s3/index.js +73 -73
  54. package/src/services/s3/server.js +329 -245
  55. package/src/services/s3/simulator.js +565 -496
  56. package/src/services/secret-manager/index.js +80 -80
  57. package/src/services/secret-manager/server.js +50 -50
  58. package/src/services/secret-manager/simulator.js +171 -171
  59. package/src/services/sns/index.js +89 -89
  60. package/src/services/sns/server.js +580 -580
  61. package/src/services/sns/simulator.js +1482 -1482
  62. package/src/services/sqs/index.js +93 -93
  63. package/src/services/sqs/server.js +349 -347
  64. package/src/services/sqs/simulator.js +441 -441
  65. package/src/services/sts/index.js +37 -37
  66. package/src/services/sts/server.js +144 -142
  67. package/src/services/sts/simulator.js +69 -69
  68. package/src/services/xray/index.js +83 -83
  69. package/src/services/xray/server.js +308 -308
  70. package/src/services/xray/simulador.js +994 -994
  71. package/src/template/aws-config-template.js +87 -87
  72. package/src/template/aws-config-template.mjs +90 -90
  73. package/src/template/config-template.json +203 -203
  74. package/src/utils/aws-config.js +91 -91
  75. package/src/utils/cloudtrail-audit.js +129 -129
  76. package/src/utils/local-store.js +83 -83
  77. 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, '&amp;')
428
- .replace(/</g, '&lt;')
429
- .replace(/>/g, '&gt;')
430
- .replace(/"/g, '&quot;')
431
- .replace(/'/g, '&apos;');
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, '&amp;')
428
+ .replace(/</g, '&lt;')
429
+ .replace(/>/g, '&gt;')
430
+ .replace(/"/g, '&quot;')
431
+ .replace(/'/g, '&apos;');
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 };