@gugananuvem/aws-local-simulator 1.0.31 → 1.0.34

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