@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,1482 +1,1482 @@
1
- /**
2
- * @fileoverview SNS Simulator - Completo
3
- * Simula o Amazon Simple Notification Service com todas as operações
4
- *
5
- * Operações implementadas:
6
- * - Topics: CreateTopic, DeleteTopic, ListTopics, GetTopicAttributes, SetTopicAttributes
7
- * - Subscriptions: Subscribe, Unsubscribe, ConfirmSubscription, ListSubscriptions,
8
- * ListSubscriptionsByTopic, GetSubscriptionAttributes, SetSubscriptionAttributes
9
- * - Publish: Publish, PublishBatch
10
- * - Tags: TagResource, UntagResource, ListTagsForResource
11
- * - Platform: CreatePlatformApplication, DeletePlatformApplication,
12
- * CreatePlatformEndpoint, DeleteEndpoint
13
- * - Opt-in: CheckIfPhoneNumberIsOptedOut, ListPhoneNumbersOptedOut, OptInPhoneNumber
14
- * - SMS: SetSMSAttributes, GetSMSAttributes
15
- * - Wire Protocol: Query XML (compatível AWS SDK v3)
16
- */
17
-
18
- 'use strict';
19
-
20
- const crypto = require('crypto');
21
- const { CloudTrailAudit } = require('../../utils/cloudtrail-audit');
22
-
23
- /**
24
- * SNS Simulator completo
25
- */
26
- class SNSSimulator {
27
- /**
28
- * @param {Object} config - Service configuration
29
- * @param {Object} store - LocalStore instance
30
- * @param {Object} logger - Logger instance
31
- */
32
- constructor(config, store, logger) {
33
- this.config = config;
34
- this.store = store;
35
- this.logger = logger;
36
-
37
- /** @type {Map<string,Object>} Topics indexados por ARN */
38
- this.topics = new Map();
39
-
40
- /** @type {Map<string,Object>} Subscriptions indexadas por ARN */
41
- this.subscriptions = new Map();
42
-
43
- /** @type {Map<string,string>} Tokens de confirmação pendentes → SubscriptionArn */
44
- this.pendingTokens = new Map();
45
-
46
- /** @type {Map<string,Object>} Platform applications por ARN */
47
- this.platformApps = new Map();
48
-
49
- /** @type {Map<string,Object>} Platform endpoints por ARN */
50
- this.platformEndpoints = new Map();
51
-
52
- /** @type {Map<string,string[]>} Tags por ARN de recurso */
53
- this.tags = new Map();
54
-
55
- /** @type {Object} SMS attributes globais */
56
- this.smsAttributes = { DefaultSMSType: 'Transactional', MonthlySpendLimit: '1' };
57
-
58
- /** @type {Set<string>} Números optados por sair */
59
- this.optedOutNumbers = new Set();
60
-
61
- /** @type {Object|null} Lambda service para protocolo lambda */
62
- this.lambdaService = null;
63
-
64
- /** @type {Object|null} SQS service para protocolo sqs */
65
- this.sqsService = null;
66
-
67
- /** @type {Array} Log de mensagens publicadas (últimas 500) */
68
- this.publishLog = [];
69
-
70
- this.region = 'us-east-1';
71
- this.accountId = '123456789012';
72
-
73
- this.audit = new CloudTrailAudit('sns.amazonaws.com');
74
- }
75
-
76
- // ─────────────────────────────────────────────
77
- // Injeção de dependências
78
- // ─────────────────────────────────────────────
79
-
80
- /** @param {Object} lambdaService */
81
- setLambdaService(lambdaService) { this.lambdaService = lambdaService; }
82
-
83
- /** @param {Object} sqsService */
84
- setSqsService(sqsService) { this.sqsService = sqsService; }
85
-
86
- // ─────────────────────────────────────────────
87
- // Persistência
88
- // ─────────────────────────────────────────────
89
-
90
- /** Carrega dados persistidos */
91
- async load() {
92
- try {
93
- const topics = await this.store.read('sns/topics');
94
- if (Array.isArray(topics)) topics.forEach(t => this.topics.set(t.TopicArn, t));
95
-
96
- const subs = await this.store.read('sns/subscriptions');
97
- if (Array.isArray(subs)) subs.forEach(s => this.subscriptions.set(s.SubscriptionArn, s));
98
-
99
- const apps = await this.store.read('sns/platform-apps');
100
- if (Array.isArray(apps)) apps.forEach(a => this.platformApps.set(a.PlatformApplicationArn, a));
101
-
102
- const endpoints = await this.store.read('sns/platform-endpoints');
103
- if (Array.isArray(endpoints)) endpoints.forEach(e => this.platformEndpoints.set(e.EndpointArn, e));
104
-
105
- const tagsData = await this.store.read('sns/tags');
106
- if (tagsData && typeof tagsData === 'object' && !Array.isArray(tagsData)) {
107
- Object.entries(tagsData).forEach(([k, v]) => this.tags.set(k, v));
108
- }
109
-
110
- this.logger.debug('SNS', `Loaded ${this.topics.size} topics, ${this.subscriptions.size} subscriptions`);
111
- } catch {
112
- this.logger.debug('SNS', 'No persisted data, starting fresh');
113
- }
114
- }
115
-
116
- /** Persiste todos os dados */
117
- async _save() {
118
- await Promise.all([
119
- this.store.write('sns/topics', null, Array.from(this.topics.values())),
120
- this.store.write('sns/subscriptions', null, Array.from(this.subscriptions.values())),
121
- this.store.write('sns/platform-apps', null, Array.from(this.platformApps.values())),
122
- this.store.write('sns/platform-endpoints',null, Array.from(this.platformEndpoints.values())),
123
- this.store.write('sns/tags', null, Object.fromEntries(this.tags.entries()))
124
- ]);
125
- }
126
-
127
- // ─────────────────────────────────────────────
128
- // Helpers
129
- // ─────────────────────────────────────────────
130
-
131
- /** @param {string} name @returns {string} */
132
- _topicArn(name) {
133
- return `arn:aws:sns:${this.region}:${this.accountId}:${name}`;
134
- }
135
-
136
- /** @param {string} topicArn @returns {string} */
137
- _subscriptionArn(topicArn) {
138
- return `${topicArn}:${crypto.randomUUID()}`;
139
- }
140
-
141
- /**
142
- * Cria erro no padrão AWS
143
- * @param {string} code
144
- * @param {string} message
145
- * @returns {Error}
146
- */
147
- _error(code, message) {
148
- const err = new Error(message);
149
- err.code = code;
150
- err.__type = code;
151
- err.statusCode = code === 'NotFound' || code === 'ResourceNotFoundException' ? 404 : 400;
152
- return err;
153
- }
154
-
155
- /**
156
- * Valida nome de tópico
157
- * @param {string} name
158
- */
159
- _validateTopicName(name) {
160
- if (!name) throw this._error('InvalidParameter', 'Topic name is required');
161
- if (!/^[a-zA-Z0-9_-]{1,256}$/.test(name.replace(/\.fifo$/, ''))) {
162
- throw this._error('InvalidParameter', 'Invalid topic name. Use alphanumeric, hyphens, underscores (max 256 chars)');
163
- }
164
- }
165
-
166
- // ─────────────────────────────────────────────
167
- // Topic Operations
168
- // ─────────────────────────────────────────────
169
-
170
- /**
171
- * CreateTopic — cria um tópico SNS
172
- * @param {Object} params
173
- * @param {string} params.Name
174
- * @param {Object} [params.Attributes]
175
- * @param {Array} [params.Tags]
176
- * @returns {Promise<{TopicArn: string}>}
177
- */
178
- async createTopic(params) {
179
- const { Name, Attributes = {}, Tags = [] } = params;
180
-
181
- this._validateTopicName(Name);
182
-
183
- const isFifo = Name.endsWith('.fifo');
184
- const topicArn = this._topicArn(Name);
185
-
186
- // Idempotente: retorna ARN existente
187
- if (this.topics.has(topicArn)) {
188
- return { TopicArn: topicArn };
189
- }
190
-
191
- const now = new Date().toISOString();
192
-
193
- const topic = {
194
- TopicArn: topicArn,
195
- Name,
196
- Attributes: {
197
- TopicArn: topicArn,
198
- Owner: this.accountId,
199
- Policy: JSON.stringify({ Version: '2012-10-17', Statement: [] }),
200
- DisplayName: Attributes.DisplayName || '',
201
- SubscriptionsPending: '0',
202
- SubscriptionsConfirmed: '0',
203
- SubscriptionsDeleted: '0',
204
- DeliveryPolicy: '{}',
205
- EffectiveDeliveryPolicy: '{}',
206
- KmsMasterKeyId: Attributes.KmsMasterKeyId || '',
207
- FifoTopic: String(isFifo),
208
- ContentBasedDeduplication: Attributes.ContentBasedDeduplication || 'false',
209
- ArchivePolicy: Attributes.ArchivePolicy || '',
210
- BeginningArchiveTime: '',
211
- SignatureVersion: '1',
212
- TracingConfig: Attributes.TracingConfig || 'PassThrough',
213
- ...Attributes
214
- },
215
- Tags,
216
- CreatedAt: now
217
- };
218
-
219
- this.topics.set(topicArn, topic);
220
-
221
- // Persistir tags separadamente
222
- if (Tags.length > 0) {
223
- this.tags.set(topicArn, Tags);
224
- }
225
-
226
- await this._save();
227
-
228
- this.logger.info('SNS', `Created topic: ${Name}${isFifo ? ' (FIFO)' : ''}`);
229
- this.audit.record({
230
- eventName: 'CreateTopic',
231
- readOnly: false,
232
- resources: [{ ARN: topicArn, type: 'AWS::SNS::Topic' }],
233
- requestParameters: { name: Name },
234
- });
235
- return { TopicArn: topicArn };
236
- }
237
-
238
- /**
239
- * DeleteTopic — remove um tópico e todas as suas subscriptions
240
- * @param {Object} params
241
- * @param {string} params.TopicArn
242
- * @returns {Promise<void>}
243
- */
244
- async deleteTopic(params) {
245
- const { TopicArn } = params;
246
-
247
- if (!this.topics.has(TopicArn)) {
248
- throw this._error('NotFound', `Topic not found: ${TopicArn}`);
249
- }
250
-
251
- // Remove subscriptions associadas
252
- let removedSubs = 0;
253
- for (const [arn, sub] of this.subscriptions.entries()) {
254
- if (sub.TopicArn === TopicArn) {
255
- this.subscriptions.delete(arn);
256
- removedSubs++;
257
- }
258
- }
259
-
260
- // Remove tokens pendentes
261
- for (const [token, subArn] of this.pendingTokens.entries()) {
262
- const sub = this.subscriptions.get(subArn);
263
- if (!sub || sub.TopicArn === TopicArn) {
264
- this.pendingTokens.delete(token);
265
- }
266
- }
267
-
268
- this.topics.delete(TopicArn);
269
- this.tags.delete(TopicArn);
270
-
271
- await this._save();
272
-
273
- this.logger.info('SNS', `Deleted topic: ${TopicArn} (removed ${removedSubs} subscriptions)`);
274
- this.audit.record({
275
- eventName: 'DeleteTopic',
276
- readOnly: false,
277
- resources: [{ ARN: TopicArn, type: 'AWS::SNS::Topic' }],
278
- requestParameters: { topicArn: TopicArn },
279
- });
280
- }
281
-
282
- /**
283
- * ListTopics — lista tópicos paginados (100 por página)
284
- * @param {Object} [params]
285
- * @param {string} [params.NextToken]
286
- * @returns {{Topics: Array, NextToken?: string}}
287
- */
288
- listTopics(params = {}) {
289
- const { NextToken } = params;
290
- const allTopics = Array.from(this.topics.values());
291
- const pageSize = 100;
292
- let startIdx = 0;
293
-
294
- if (NextToken) {
295
- try {
296
- startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0;
297
- } catch { startIdx = 0; }
298
- }
299
-
300
- const page = allTopics.slice(startIdx, startIdx + pageSize);
301
- const nextIdx = startIdx + pageSize;
302
- const nextToken = nextIdx < allTopics.length
303
- ? Buffer.from(String(nextIdx)).toString('base64')
304
- : undefined;
305
-
306
- return {
307
- Topics: page.map(t => ({ TopicArn: t.TopicArn })),
308
- ...(nextToken && { NextToken: nextToken })
309
- };
310
- }
311
-
312
- /**
313
- * GetTopicAttributes — retorna atributos de um tópico
314
- * @param {Object} params
315
- * @param {string} params.TopicArn
316
- * @returns {{Attributes: Object}}
317
- */
318
- getTopicAttributes(params) {
319
- const { TopicArn } = params;
320
- const topic = this.topics.get(TopicArn);
321
- if (!topic) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
322
-
323
- // Calcular contadores em tempo real
324
- const subs = Array.from(this.subscriptions.values()).filter(s => s.TopicArn === TopicArn);
325
- const confirmed = subs.filter(s => s.PendingConfirmation === 'false').length;
326
- const pending = subs.filter(s => s.PendingConfirmation === 'true').length;
327
-
328
- return {
329
- Attributes: {
330
- ...topic.Attributes,
331
- SubscriptionsConfirmed: String(confirmed),
332
- SubscriptionsPending: String(pending),
333
- SubscriptionsDeleted: topic.Attributes.SubscriptionsDeleted || '0'
334
- }
335
- };
336
- }
337
-
338
- /**
339
- * SetTopicAttributes — atualiza um atributo do tópico
340
- * @param {Object} params
341
- * @param {string} params.TopicArn
342
- * @param {string} params.AttributeName
343
- * @param {string} params.AttributeValue
344
- * @returns {Promise<void>}
345
- */
346
- async setTopicAttributes(params) {
347
- const { TopicArn, AttributeName, AttributeValue } = params;
348
- const topic = this.topics.get(TopicArn);
349
- if (!topic) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
350
-
351
- const readOnly = ['TopicArn', 'Owner', 'SubscriptionsConfirmed', 'SubscriptionsPending', 'SubscriptionsDeleted'];
352
- if (readOnly.includes(AttributeName)) {
353
- throw this._error('InvalidParameter', `Attribute ${AttributeName} is read-only`);
354
- }
355
-
356
- topic.Attributes[AttributeName] = AttributeValue;
357
- await this._save();
358
-
359
- this.logger.debug('SNS', `SetTopicAttributes: ${AttributeName}=${AttributeValue} on ${TopicArn}`);
360
- }
361
-
362
- // ─────────────────────────────────────────────
363
- // Subscription Operations
364
- // ─────────────────────────────────────────────
365
-
366
- /**
367
- * Subscribe — inscreve um endpoint em um tópico
368
- * @param {Object} params
369
- * @param {string} params.TopicArn
370
- * @param {string} params.Protocol
371
- * @param {string} [params.Endpoint]
372
- * @param {Object} [params.Attributes]
373
- * @param {boolean} [params.ReturnSubscriptionArn]
374
- * @returns {Promise<{SubscriptionArn: string}>}
375
- */
376
- async subscribe(params) {
377
- const { TopicArn, Protocol, Endpoint = '', Attributes = {}, ReturnSubscriptionArn = false } = params;
378
-
379
- if (!this.topics.has(TopicArn)) {
380
- throw this._error('NotFound', `Topic not found: ${TopicArn}`);
381
- }
382
-
383
- const validProtocols = ['lambda', 'sqs', 'http', 'https', 'email', 'email-json', 'sms', 'application', 'firehose'];
384
- if (!validProtocols.includes(Protocol)) {
385
- throw this._error('InvalidParameter', `Invalid protocol: ${Protocol}. Must be one of: ${validProtocols.join(', ')}`);
386
- }
387
-
388
- // Verifica se já existe subscrição idêntica
389
- for (const sub of this.subscriptions.values()) {
390
- if (sub.TopicArn === TopicArn && sub.Protocol === Protocol && sub.Endpoint === Endpoint) {
391
- return { SubscriptionArn: sub.SubscriptionArn };
392
- }
393
- }
394
-
395
- const subscriptionArn = this._subscriptionArn(TopicArn);
396
- const now = new Date().toISOString();
397
-
398
- // Protocolos que não requerem confirmação
399
- const autoConfirmed = ['lambda', 'sqs', 'application', 'firehose'];
400
- const pendingConfirmation = autoConfirmed.includes(Protocol) ? 'false' : 'true';
401
-
402
- let filterPolicy = null;
403
- if (Attributes.FilterPolicy) {
404
- try { filterPolicy = JSON.parse(Attributes.FilterPolicy); }
405
- catch { throw this._error('InvalidParameter', 'FilterPolicy must be valid JSON'); }
406
- }
407
-
408
- let filterPolicyScope = Attributes.FilterPolicyScope || 'MessageAttributes';
409
-
410
- const subscription = {
411
- SubscriptionArn: subscriptionArn,
412
- TopicArn,
413
- Protocol,
414
- Endpoint,
415
- Owner: this.accountId,
416
- ConfirmationWasAuthenticated: 'true',
417
- PendingConfirmation: pendingConfirmation,
418
- FilterPolicy: filterPolicy,
419
- FilterPolicyScope: filterPolicyScope,
420
- RawMessageDelivery: Attributes.RawMessageDelivery || 'false',
421
- RedrivePolicy: Attributes.RedrivePolicy || null,
422
- DeliveryPolicy: Attributes.DeliveryPolicy || null,
423
- SubscriptionRoleArn: Attributes.SubscriptionRoleArn || '',
424
- CreatedAt: now
425
- };
426
-
427
- this.subscriptions.set(subscriptionArn, subscription);
428
-
429
- // Gerar token de confirmação para protocolos http/https/email/sms
430
- if (pendingConfirmation === 'true') {
431
- const token = crypto.randomBytes(64).toString('hex');
432
- this.pendingTokens.set(token, subscriptionArn);
433
- this.logger.info('SNS', `[CONFIRMATION MOCK] Token for ${Protocol}:${Endpoint}: ${token.substring(0, 16)}...`);
434
- }
435
-
436
- await this._save();
437
-
438
- this.logger.info('SNS', `Subscribed ${Protocol}:${Endpoint} → ${TopicArn}`);
439
-
440
- // Se ReturnSubscriptionArn=true OU auto-confirmado → retorna ARN real
441
- // Senão retorna 'PendingConfirmation'
442
- const returnArn = (ReturnSubscriptionArn || pendingConfirmation === 'false')
443
- ? subscriptionArn
444
- : 'PendingConfirmation';
445
-
446
- return { SubscriptionArn: returnArn };
447
- }
448
-
449
- /**
450
- * ConfirmSubscription — confirma uma subscrição via token
451
- * @param {Object} params
452
- * @param {string} params.TopicArn
453
- * @param {string} params.Token
454
- * @param {string} [params.AuthenticateOnUnsubscribe]
455
- * @returns {{SubscriptionArn: string}}
456
- */
457
- async confirmSubscription(params) {
458
- const { TopicArn, Token } = params;
459
-
460
- if (!this.topics.has(TopicArn)) {
461
- throw this._error('NotFound', `Topic not found: ${TopicArn}`);
462
- }
463
-
464
- const subscriptionArn = this.pendingTokens.get(Token);
465
- if (!subscriptionArn) {
466
- throw this._error('InvalidParameter', 'Invalid confirmation token');
467
- }
468
-
469
- const sub = this.subscriptions.get(subscriptionArn);
470
- if (!sub || sub.TopicArn !== TopicArn) {
471
- throw this._error('InvalidParameter', 'Token does not match topic');
472
- }
473
-
474
- sub.PendingConfirmation = 'false';
475
- sub.ConfirmationWasAuthenticated = 'true';
476
- this.pendingTokens.delete(Token);
477
-
478
- await this._save();
479
-
480
- this.logger.info('SNS', `Confirmed subscription: ${subscriptionArn}`);
481
- return { SubscriptionArn: subscriptionArn };
482
- }
483
-
484
- /**
485
- * Unsubscribe — remove uma subscrição
486
- * @param {Object} params
487
- * @param {string} params.SubscriptionArn
488
- * @returns {Promise<void>}
489
- */
490
- async unsubscribe(params) {
491
- const { SubscriptionArn } = params;
492
-
493
- const sub = this.subscriptions.get(SubscriptionArn);
494
- if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
495
-
496
- this.subscriptions.delete(SubscriptionArn);
497
-
498
- // Incrementar contador de deletadas no tópico
499
- const topic = this.topics.get(sub.TopicArn);
500
- if (topic) {
501
- const deleted = parseInt(topic.Attributes.SubscriptionsDeleted || '0');
502
- topic.Attributes.SubscriptionsDeleted = String(deleted + 1);
503
- }
504
-
505
- await this._save();
506
-
507
- this.logger.info('SNS', `Unsubscribed: ${SubscriptionArn}`);
508
- }
509
-
510
- /**
511
- * ListSubscriptions — lista todas as subscrições paginadas
512
- * @param {Object} [params]
513
- * @returns {{Subscriptions: Array, NextToken?: string}}
514
- */
515
- listSubscriptions(params = {}) {
516
- return this._paginateSubscriptions(Array.from(this.subscriptions.values()), params.NextToken);
517
- }
518
-
519
- /**
520
- * ListSubscriptionsByTopic — lista subscrições de um tópico
521
- * @param {Object} params
522
- * @param {string} params.TopicArn
523
- * @returns {{Subscriptions: Array, NextToken?: string}}
524
- */
525
- listSubscriptionsByTopic(params) {
526
- const { TopicArn, NextToken } = params;
527
- if (!this.topics.has(TopicArn)) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
528
-
529
- const subs = Array.from(this.subscriptions.values()).filter(s => s.TopicArn === TopicArn);
530
- return this._paginateSubscriptions(subs, NextToken);
531
- }
532
-
533
- /**
534
- * Pagina lista de subscriptions
535
- * @param {Array} subs
536
- * @param {string} [nextToken]
537
- * @returns {{Subscriptions: Array, NextToken?: string}}
538
- * @private
539
- */
540
- _paginateSubscriptions(subs, nextToken) {
541
- const pageSize = 100;
542
- let startIdx = 0;
543
-
544
- if (nextToken) {
545
- try { startIdx = parseInt(Buffer.from(nextToken, 'base64').toString('utf8'), 10) || 0; }
546
- catch { startIdx = 0; }
547
- }
548
-
549
- const page = subs.slice(startIdx, startIdx + pageSize);
550
- const nextIdx = startIdx + pageSize;
551
- const token = nextIdx < subs.length
552
- ? Buffer.from(String(nextIdx)).toString('base64')
553
- : undefined;
554
-
555
- return {
556
- Subscriptions: page.map(s => ({
557
- SubscriptionArn: s.SubscriptionArn,
558
- TopicArn: s.TopicArn,
559
- Protocol: s.Protocol,
560
- Endpoint: s.Endpoint,
561
- Owner: s.Owner
562
- })),
563
- ...(token && { NextToken: token })
564
- };
565
- }
566
-
567
- /**
568
- * GetSubscriptionAttributes — retorna atributos de uma subscrição
569
- * @param {Object} params
570
- * @param {string} params.SubscriptionArn
571
- * @returns {{Attributes: Object}}
572
- */
573
- getSubscriptionAttributes(params) {
574
- const { SubscriptionArn } = params;
575
- const sub = this.subscriptions.get(SubscriptionArn);
576
- if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
577
-
578
- return {
579
- Attributes: {
580
- SubscriptionArn: sub.SubscriptionArn,
581
- TopicArn: sub.TopicArn,
582
- Protocol: sub.Protocol,
583
- Endpoint: sub.Endpoint,
584
- Owner: sub.Owner,
585
- FilterPolicy: sub.FilterPolicy ? JSON.stringify(sub.FilterPolicy) : '',
586
- FilterPolicyScope: sub.FilterPolicyScope || 'MessageAttributes',
587
- RawMessageDelivery: sub.RawMessageDelivery,
588
- ConfirmationWasAuthenticated: sub.ConfirmationWasAuthenticated,
589
- PendingConfirmation: sub.PendingConfirmation,
590
- RedrivePolicy: sub.RedrivePolicy ? JSON.stringify(sub.RedrivePolicy) : '',
591
- DeliveryPolicy: sub.DeliveryPolicy ? JSON.stringify(sub.DeliveryPolicy) : '',
592
- SubscriptionRoleArn: sub.SubscriptionRoleArn || ''
593
- }
594
- };
595
- }
596
-
597
- /**
598
- * SetSubscriptionAttributes — atualiza atributos de uma subscrição
599
- * @param {Object} params
600
- * @param {string} params.SubscriptionArn
601
- * @param {string} params.AttributeName
602
- * @param {string} [params.AttributeValue]
603
- * @returns {Promise<void>}
604
- */
605
- async setSubscriptionAttributes(params) {
606
- const { SubscriptionArn, AttributeName, AttributeValue } = params;
607
- const sub = this.subscriptions.get(SubscriptionArn);
608
- if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
609
-
610
- const editableAttrs = ['FilterPolicy', 'FilterPolicyScope', 'RawMessageDelivery', 'RedrivePolicy', 'DeliveryPolicy', 'SubscriptionRoleArn'];
611
- if (!editableAttrs.includes(AttributeName)) {
612
- throw this._error('InvalidParameter', `Attribute ${AttributeName} is not editable`);
613
- }
614
-
615
- if (AttributeName === 'FilterPolicy') {
616
- try { sub.FilterPolicy = AttributeValue ? JSON.parse(AttributeValue) : null; }
617
- catch { throw this._error('InvalidParameter', 'FilterPolicy must be valid JSON'); }
618
- } else {
619
- sub[AttributeName] = AttributeValue || '';
620
- }
621
-
622
- await this._save();
623
- this.logger.debug('SNS', `SetSubscriptionAttributes: ${AttributeName} on ${SubscriptionArn}`);
624
- }
625
-
626
- // ─────────────────────────────────────────────
627
- // Publish Operations
628
- // ─────────────────────────────────────────────
629
-
630
- /**
631
- * Publish — publica uma mensagem em um tópico ou endpoint
632
- * @param {Object} params
633
- * @param {string} [params.TopicArn]
634
- * @param {string} [params.TargetArn]
635
- * @param {string} [params.PhoneNumber]
636
- * @param {string} params.Message
637
- * @param {string} [params.Subject]
638
- * @param {string} [params.MessageStructure]
639
- * @param {Object} [params.MessageAttributes]
640
- * @param {string} [params.MessageGroupId]
641
- * @param {string} [params.MessageDeduplicationId]
642
- * @returns {Promise<{MessageId: string, SequenceNumber?: string}>}
643
- */
644
- async publish(params) {
645
- const {
646
- TopicArn, TargetArn, PhoneNumber,
647
- Message, Subject,
648
- MessageStructure, MessageAttributes = {},
649
- MessageGroupId, MessageDeduplicationId
650
- } = params;
651
-
652
- // SMS direto para número
653
- if (PhoneNumber) {
654
- if (this.optedOutNumbers.has(PhoneNumber)) {
655
- throw this._error('OptedOut', `Number ${PhoneNumber} has opted out`);
656
- }
657
- const messageId = crypto.randomUUID();
658
- this.logger.info('SNS', `[SMS MOCK] To: ${PhoneNumber}, Message: ${String(Message).substring(0, 160)}`);
659
- this._logPublish(null, messageId, Message, PhoneNumber);
660
- return { MessageId: messageId };
661
- }
662
-
663
- const arn = TopicArn || TargetArn;
664
- if (!arn) throw this._error('InvalidParameter', 'TopicArn, TargetArn, or PhoneNumber is required');
665
- if (!Message) throw this._error('InvalidParameter', 'Message is required');
666
- if (Message.length > 262144) throw this._error('InvalidParameter', 'Message too large (max 256KB)');
667
-
668
- // Entrega para endpoint de plataforma
669
- if (TargetArn && !TopicArn) {
670
- const endpoint = this.platformEndpoints.get(TargetArn);
671
- if (!endpoint) throw this._error('NotFound', `Endpoint not found: ${TargetArn}`);
672
- const messageId = crypto.randomUUID();
673
- this.logger.info('SNS', `[PLATFORM MOCK] Endpoint: ${TargetArn}, Message: ${String(Message).substring(0, 100)}`);
674
- this._logPublish(TargetArn, messageId, Message);
675
- return { MessageId: messageId };
676
- }
677
-
678
- const topic = this.topics.get(arn);
679
- if (!topic) throw this._error('NotFound', `Topic not found: ${arn}`);
680
-
681
- const messageId = crypto.randomUUID();
682
-
683
- // Parse de mensagem estruturada
684
- let messagePayload = Message;
685
- if (MessageStructure === 'json') {
686
- try { messagePayload = JSON.parse(Message); }
687
- catch { throw this._error('InvalidParameter', 'Message is not valid JSON for MessageStructure=json'); }
688
- }
689
-
690
- this._logPublish(arn, messageId, Message, null, MessageAttributes);
691
-
692
- this.logger.debug('SNS', `Publishing messageId=${messageId} to ${arn} (${topic.Name})`);
693
- this.audit.record({
694
- eventName: 'Publish',
695
- readOnly: false,
696
- resources: [{ ARN: arn, type: 'AWS::SNS::Topic' }],
697
- requestParameters: { topicArn: arn },
698
- });
699
-
700
- // Coletar subscriptions confirmadas do tópico
701
- const topicSubs = Array.from(this.subscriptions.values())
702
- .filter(s => s.TopicArn === arn && s.PendingConfirmation === 'false');
703
-
704
- // Entrega assíncrona (fire and forget)
705
- Promise.all(
706
- topicSubs.map(sub =>
707
- this._deliver(sub, messagePayload, MessageStructure, Subject, MessageAttributes, messageId)
708
- .catch(err => this.logger.warn('SNS', `Delivery failed [${sub.Protocol}:${sub.Endpoint}]: ${err.message}`))
709
- )
710
- );
711
-
712
- return {
713
- MessageId: messageId,
714
- ...(MessageGroupId && { SequenceNumber: String(Date.now()).padStart(20, '0') })
715
- };
716
- }
717
-
718
- /**
719
- * PublishBatch — publica múltiplas mensagens em um tópico
720
- * @param {Object} params
721
- * @param {string} params.TopicArn
722
- * @param {Array} params.PublishBatchRequestEntries
723
- * @returns {Promise<{Successful: Array, Failed: Array}>}
724
- */
725
- async publishBatch(params) {
726
- const { TopicArn, PublishBatchRequestEntries = [] } = params;
727
-
728
- if (!this.topics.has(TopicArn)) {
729
- throw this._error('NotFound', `Topic not found: ${TopicArn}`);
730
- }
731
-
732
- if (PublishBatchRequestEntries.length === 0) {
733
- throw this._error('InvalidParameter', 'At least one entry is required');
734
- }
735
-
736
- if (PublishBatchRequestEntries.length > 10) {
737
- throw this._error('TooManyEntriesInBatchRequest', 'Maximum 10 entries per batch');
738
- }
739
-
740
- const successful = [];
741
- const failed = [];
742
-
743
- for (const entry of PublishBatchRequestEntries) {
744
- try {
745
- const result = await this.publish({
746
- TopicArn,
747
- Message: entry.Message,
748
- Subject: entry.Subject,
749
- MessageStructure: entry.MessageStructure,
750
- MessageAttributes: entry.MessageAttributes,
751
- MessageGroupId: entry.MessageGroupId,
752
- MessageDeduplicationId: entry.MessageDeduplicationId
753
- });
754
-
755
- successful.push({
756
- Id: entry.Id,
757
- MessageId: result.MessageId,
758
- SequenceNumber: result.SequenceNumber
759
- });
760
- } catch (err) {
761
- failed.push({
762
- Id: entry.Id,
763
- Code: err.code || 'InternalFailure',
764
- Message: err.message,
765
- SenderFault: true
766
- });
767
- }
768
- }
769
-
770
- return { Successful: successful, Failed: failed };
771
- }
772
-
773
- // ─────────────────────────────────────────────
774
- // Delivery Engine
775
- // ─────────────────────────────────────────────
776
-
777
- /**
778
- * Entrega mensagem para um subscriber
779
- * @param {Object} subscription
780
- * @param {*} message
781
- * @param {string} [messageStructure]
782
- * @param {string} [subject]
783
- * @param {Object} [messageAttributes]
784
- * @param {string} messageId
785
- * @returns {Promise<void>}
786
- * @private
787
- */
788
- async _deliver(subscription, message, messageStructure, subject, messageAttributes = {}, messageId) {
789
- // Aplicar FilterPolicy
790
- if (subscription.FilterPolicy) {
791
- const scope = subscription.FilterPolicyScope || 'MessageAttributes';
792
- const matchData = scope === 'MessageBody'
793
- ? (typeof message === 'string' ? JSON.parse(message) : message)
794
- : messageAttributes;
795
-
796
- if (!this._matchesFilterPolicy(subscription.FilterPolicy, matchData, scope)) {
797
- this.logger.debug('SNS', `Message filtered for ${subscription.SubscriptionArn}`);
798
- return;
799
- }
800
- }
801
-
802
- // Resolver conteúdo para o protocolo (MessageStructure=json)
803
- let content = message;
804
- if (messageStructure === 'json' && typeof message === 'object') {
805
- content = message[subscription.Protocol] || message.default || '';
806
- }
807
-
808
- // Construir envelope SNS
809
- const envelope = subscription.RawMessageDelivery === 'true'
810
- ? (typeof content === 'string' ? content : JSON.stringify(content))
811
- : {
812
- Type: 'Notification',
813
- MessageId: messageId,
814
- TopicArn: subscription.TopicArn,
815
- Subject: subject || 'Amazon SNS',
816
- Message: typeof content === 'string' ? content : JSON.stringify(content),
817
- Timestamp: new Date().toISOString(),
818
- SignatureVersion: '1',
819
- Signature: 'LOCAL_SIMULATOR_NO_SIGNATURE',
820
- SigningCertURL: `https://sns.${this.region}.amazonaws.com/SimpleNotificationService.pem`,
821
- UnsubscribeURL: `http://localhost:${this.config.services?.sns?.port || 9911}/?Action=Unsubscribe&SubscriptionArn=${subscription.SubscriptionArn}`,
822
- MessageAttributes: messageAttributes
823
- };
824
-
825
- switch (subscription.Protocol) {
826
- case 'lambda':
827
- await this._deliverToLambda(subscription.Endpoint, envelope, messageId, subscription.SubscriptionArn);
828
- break;
829
-
830
- case 'sqs':
831
- await this._deliverToSqs(subscription.Endpoint, envelope, subscription.RawMessageDelivery === 'true');
832
- break;
833
-
834
- case 'http':
835
- case 'https':
836
- await this._deliverToHttp(subscription.Endpoint, envelope, subscription.Protocol);
837
- break;
838
-
839
- case 'email':
840
- this.logger.info('SNS', `[EMAIL MOCK] To: ${subscription.Endpoint} | Subject: ${subject || 'No subject'} | Body: ${String(content).substring(0, 200)}`);
841
- break;
842
-
843
- case 'email-json':
844
- this.logger.info('SNS', `[EMAIL-JSON MOCK] To: ${subscription.Endpoint} | Payload: ${JSON.stringify(envelope).substring(0, 200)}`);
845
- break;
846
-
847
- case 'sms':
848
- if (!this.optedOutNumbers.has(subscription.Endpoint)) {
849
- this.logger.info('SNS', `[SMS MOCK] To: ${subscription.Endpoint} | Message: ${String(content).substring(0, 160)}`);
850
- }
851
- break;
852
-
853
- case 'application':
854
- this.logger.info('SNS', `[PUSH MOCK] Endpoint: ${subscription.Endpoint} | Payload: ${String(content).substring(0, 200)}`);
855
- break;
856
-
857
- default:
858
- this.logger.warn('SNS', `Unsupported protocol: ${subscription.Protocol}`);
859
- }
860
- }
861
-
862
- /**
863
- * Entrega para função Lambda via SNS Records
864
- * @param {string} endpoint - Lambda ARN
865
- * @param {Object|string} envelope
866
- * @param {string} messageId
867
- * @param {string} subscriptionArn
868
- * @returns {Promise<void>}
869
- * @private
870
- */
871
- async _deliverToLambda(endpoint, envelope, messageId, subscriptionArn) {
872
- if (!this.lambdaService) {
873
- this.logger.warn('SNS', 'Lambda service not available for delivery');
874
- return;
875
- }
876
-
877
- const funcMatch = endpoint.match(/function:([^:]+)/);
878
- if (!funcMatch) {
879
- this.logger.warn('SNS', `Cannot parse Lambda ARN: ${endpoint}`);
880
- return;
881
- }
882
-
883
- const functionName = funcMatch[1];
884
- const isRaw = typeof envelope === 'string';
885
- const snsPayload = isRaw ? { Message: envelope } : envelope;
886
-
887
- const event = {
888
- Records: [{
889
- EventSource: 'aws:sns',
890
- EventVersion: '1.0',
891
- EventSubscriptionArn: subscriptionArn,
892
- Sns: {
893
- Type: snsPayload.Type || 'Notification',
894
- MessageId: messageId,
895
- TopicArn: snsPayload.TopicArn || endpoint,
896
- Subject: snsPayload.Subject || '',
897
- Message: snsPayload.Message || '',
898
- Timestamp: snsPayload.Timestamp || new Date().toISOString(),
899
- SignatureVersion: snsPayload.SignatureVersion || '1',
900
- Signature: snsPayload.Signature || 'LOCAL_SIMULATOR',
901
- MessageAttributes: snsPayload.MessageAttributes || {}
902
- }
903
- }]
904
- };
905
-
906
- try {
907
- await this.lambdaService.simulator.invokeFunction(functionName, event);
908
- this.logger.debug('SNS', `Delivered to Lambda: ${functionName}`);
909
- } catch (err) {
910
- this.logger.error('SNS', `Lambda delivery failed [${functionName}]: ${err.message}`);
911
- throw err;
912
- }
913
- }
914
-
915
- /**
916
- * Entrega mensagem para fila SQS
917
- * @param {string} endpoint - SQS URL ou ARN
918
- * @param {Object|string} envelope
919
- * @param {boolean} rawDelivery
920
- * @returns {Promise<void>}
921
- * @private
922
- */
923
- async _deliverToSqs(endpoint, envelope, rawDelivery) {
924
- if (!this.sqsService) {
925
- this.logger.warn('SNS', 'SQS service not available for delivery');
926
- return;
927
- }
928
-
929
- const messageBody = rawDelivery
930
- ? (typeof envelope === 'string' ? envelope : JSON.stringify(envelope))
931
- : JSON.stringify(envelope);
932
-
933
- try {
934
- await this.sqsService.simulator.sendMessage({ QueueUrl: endpoint, MessageBody: messageBody });
935
- this.logger.debug('SNS', `Delivered to SQS: ${endpoint}`);
936
- } catch (err) {
937
- this.logger.error('SNS', `SQS delivery failed [${endpoint}]: ${err.message}`);
938
- throw err;
939
- }
940
- }
941
-
942
- /**
943
- * Entrega mensagem via HTTP/HTTPS
944
- * @param {string} endpoint
945
- * @param {Object|string} envelope
946
- * @param {string} protocol
947
- * @returns {Promise<void>}
948
- * @private
949
- */
950
- async _deliverToHttp(endpoint, envelope, protocol) {
951
- const httpModule = require(protocol === 'https' ? 'https' : 'http');
952
- const body = typeof envelope === 'string' ? envelope : JSON.stringify(envelope);
953
-
954
- return new Promise((resolve) => {
955
- try {
956
- const url = new URL(endpoint);
957
- const req = httpModule.request({
958
- hostname: url.hostname,
959
- port: url.port || (protocol === 'https' ? 443 : 80),
960
- path: url.pathname + url.search,
961
- method: 'POST',
962
- headers: {
963
- 'Content-Type': 'application/json',
964
- 'Content-Length': Buffer.byteLength(body),
965
- 'x-amz-sns-message-type': 'Notification',
966
- 'x-amz-sns-topic-arn': envelope.TopicArn || '',
967
- 'x-amz-sns-message-id': envelope.MessageId || ''
968
- }
969
- }, (res) => {
970
- this.logger.debug('SNS', `HTTP delivery to ${endpoint}: HTTP ${res.statusCode}`);
971
- resolve();
972
- });
973
-
974
- req.on('error', (err) => {
975
- this.logger.warn('SNS', `HTTP delivery failed [${endpoint}]: ${err.message}`);
976
- resolve(); // não propagar erro
977
- });
978
-
979
- req.setTimeout(5000, () => {
980
- req.destroy();
981
- this.logger.warn('SNS', `HTTP delivery timeout [${endpoint}]`);
982
- resolve();
983
- });
984
-
985
- req.write(body);
986
- req.end();
987
- } catch (err) {
988
- this.logger.warn('SNS', `HTTP delivery error: ${err.message}`);
989
- resolve();
990
- }
991
- });
992
- }
993
-
994
- // ─────────────────────────────────────────────
995
- // Filter Policy
996
- // ─────────────────────────────────────────────
997
-
998
- /**
999
- * Verifica se os atributos/body da mensagem correspondem ao FilterPolicy
1000
- * @param {Object} filterPolicy
1001
- * @param {Object} data - MessageAttributes ou body da mensagem
1002
- * @param {string} scope - 'MessageAttributes' | 'MessageBody'
1003
- * @returns {boolean}
1004
- * @private
1005
- */
1006
- _matchesFilterPolicy(filterPolicy, data, scope = 'MessageAttributes') {
1007
- for (const [key, conditions] of Object.entries(filterPolicy)) {
1008
- let value;
1009
-
1010
- if (scope === 'MessageBody') {
1011
- value = this._getNestedValue(data, key);
1012
- } else {
1013
- const attr = data[key];
1014
- if (!attr) return false;
1015
- value = attr.Value || attr.StringValue || attr.NumberValue;
1016
- }
1017
-
1018
- if (value === undefined || value === null) return false;
1019
-
1020
- if (!Array.isArray(conditions)) continue;
1021
-
1022
- const matched = conditions.some(cond => {
1023
- // Valor direto (string)
1024
- if (typeof cond === 'string') return String(value) === cond;
1025
-
1026
- // Valor nulo
1027
- if (cond === null) return value === null || value === undefined;
1028
-
1029
- // Objeto de condição
1030
- if (typeof cond === 'object') {
1031
- if ('prefix' in cond) return String(value).startsWith(cond.prefix);
1032
- if ('suffix' in cond) return String(value).endsWith(cond.suffix);
1033
- if ('numeric' in cond) return this._checkNumeric(parseFloat(value), cond.numeric);
1034
- if ('anything-but' in cond) return !cond['anything-but'].includes(value);
1035
- if ('exists' in cond) return cond.exists ? (value !== undefined) : (value === undefined);
1036
- }
1037
-
1038
- return false;
1039
- });
1040
-
1041
- if (!matched) return false;
1042
- }
1043
-
1044
- return true;
1045
- }
1046
-
1047
- /**
1048
- * Obtém valor aninhado em objeto usando notação de ponto
1049
- * @param {Object} obj
1050
- * @param {string} key
1051
- * @returns {*}
1052
- * @private
1053
- */
1054
- _getNestedValue(obj, key) {
1055
- return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
1056
- }
1057
-
1058
- /**
1059
- * Verifica condição numérica do FilterPolicy
1060
- * @param {number} value
1061
- * @param {Array} conditions - ex: ['>', 5, '<=', 10]
1062
- * @returns {boolean}
1063
- * @private
1064
- */
1065
- _checkNumeric(value, conditions) {
1066
- if (isNaN(value)) return false;
1067
-
1068
- for (let i = 0; i < conditions.length; i += 2) {
1069
- const op = conditions[i];
1070
- const threshold = conditions[i + 1];
1071
- if (op === '=' && value !== threshold) return false;
1072
- if (op === '>' && value <= threshold) return false;
1073
- if (op === '>=' && value < threshold) return false;
1074
- if (op === '<' && value >= threshold) return false;
1075
- if (op === '<=' && value > threshold) return false;
1076
- }
1077
-
1078
- return true;
1079
- }
1080
-
1081
- // ─────────────────────────────────────────────
1082
- // Tags
1083
- // ─────────────────────────────────────────────
1084
-
1085
- /**
1086
- * TagResource — adiciona tags a um recurso SNS
1087
- * @param {Object} params
1088
- * @param {string} params.ResourceArn
1089
- * @param {Array} params.Tags - [{Key, Value}]
1090
- * @returns {Promise<void>}
1091
- */
1092
- async tagResource(params) {
1093
- const { ResourceArn, Tags = [] } = params;
1094
-
1095
- if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1096
- throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1097
- }
1098
-
1099
- const existing = this.tags.get(ResourceArn) || [];
1100
- const merged = [...existing];
1101
-
1102
- for (const tag of Tags) {
1103
- const idx = merged.findIndex(t => t.Key === tag.Key);
1104
- if (idx >= 0) merged[idx] = tag;
1105
- else merged.push(tag);
1106
- }
1107
-
1108
- this.tags.set(ResourceArn, merged);
1109
- await this._save();
1110
-
1111
- this.logger.debug('SNS', `Tagged resource ${ResourceArn}: ${Tags.map(t => `${t.Key}=${t.Value}`).join(', ')}`);
1112
- }
1113
-
1114
- /**
1115
- * UntagResource — remove tags de um recurso SNS
1116
- * @param {Object} params
1117
- * @param {string} params.ResourceArn
1118
- * @param {Array} params.TagKeys
1119
- * @returns {Promise<void>}
1120
- */
1121
- async untagResource(params) {
1122
- const { ResourceArn, TagKeys = [] } = params;
1123
-
1124
- if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1125
- throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1126
- }
1127
-
1128
- const existing = this.tags.get(ResourceArn) || [];
1129
- this.tags.set(ResourceArn, existing.filter(t => !TagKeys.includes(t.Key)));
1130
- await this._save();
1131
-
1132
- this.logger.debug('SNS', `Untagged resource ${ResourceArn}: ${TagKeys.join(', ')}`);
1133
- }
1134
-
1135
- /**
1136
- * ListTagsForResource — lista tags de um recurso
1137
- * @param {Object} params
1138
- * @param {string} params.ResourceArn
1139
- * @returns {{Tags: Array}}
1140
- */
1141
- listTagsForResource(params) {
1142
- const { ResourceArn } = params;
1143
-
1144
- if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1145
- throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1146
- }
1147
-
1148
- return { Tags: this.tags.get(ResourceArn) || [] };
1149
- }
1150
-
1151
- // ─────────────────────────────────────────────
1152
- // Platform Applications (Push Notifications)
1153
- // ─────────────────────────────────────────────
1154
-
1155
- /**
1156
- * CreatePlatformApplication — cria aplicação de plataforma (APNs/FCM/GCM)
1157
- * @param {Object} params
1158
- * @returns {Promise<{PlatformApplicationArn: string}>}
1159
- */
1160
- async createPlatformApplication(params) {
1161
- const { Name, Platform, Attributes = {} } = params;
1162
-
1163
- if (!Name || !Platform) {
1164
- throw this._error('InvalidParameter', 'Name and Platform are required');
1165
- }
1166
-
1167
- const validPlatforms = ['ADM', 'APNS', 'APNS_SANDBOX', 'GCM', 'FCM', 'BAIDU', 'WNS', 'MPNS'];
1168
- if (!validPlatforms.includes(Platform)) {
1169
- throw this._error('InvalidParameter', `Invalid platform: ${Platform}`);
1170
- }
1171
-
1172
- const arn = `arn:aws:sns:${this.region}:${this.accountId}:app/${Platform}/${Name}`;
1173
-
1174
- const app = {
1175
- PlatformApplicationArn: arn,
1176
- Name,
1177
- Platform,
1178
- Attributes: {
1179
- Enabled: 'true',
1180
- SuccessFeedbackRoleArn: '',
1181
- FailureFeedbackRoleArn: '',
1182
- ...Attributes
1183
- },
1184
- CreatedAt: new Date().toISOString()
1185
- };
1186
-
1187
- this.platformApps.set(arn, app);
1188
- await this._save();
1189
-
1190
- this.logger.info('SNS', `Created platform application: ${Name} (${Platform})`);
1191
- return { PlatformApplicationArn: arn };
1192
- }
1193
-
1194
- /**
1195
- * DeletePlatformApplication — remove uma aplicação de plataforma
1196
- * @param {Object} params
1197
- * @param {string} params.PlatformApplicationArn
1198
- * @returns {Promise<void>}
1199
- */
1200
- async deletePlatformApplication(params) {
1201
- const { PlatformApplicationArn } = params;
1202
-
1203
- if (!this.platformApps.has(PlatformApplicationArn)) {
1204
- throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1205
- }
1206
-
1207
- // Remover endpoints associados
1208
- for (const [arn, ep] of this.platformEndpoints.entries()) {
1209
- if (ep.PlatformApplicationArn === PlatformApplicationArn) {
1210
- this.platformEndpoints.delete(arn);
1211
- }
1212
- }
1213
-
1214
- this.platformApps.delete(PlatformApplicationArn);
1215
- await this._save();
1216
-
1217
- this.logger.info('SNS', `Deleted platform application: ${PlatformApplicationArn}`);
1218
- }
1219
-
1220
- /**
1221
- * ListPlatformApplications — lista aplicações de plataforma
1222
- * @param {Object} [params]
1223
- * @returns {{PlatformApplications: Array, NextToken?: string}}
1224
- */
1225
- listPlatformApplications(params = {}) {
1226
- const apps = Array.from(this.platformApps.values());
1227
- const pageSize = 100;
1228
- let startIdx = 0;
1229
-
1230
- if (params.NextToken) {
1231
- try { startIdx = parseInt(Buffer.from(params.NextToken, 'base64').toString('utf8'), 10) || 0; }
1232
- catch { startIdx = 0; }
1233
- }
1234
-
1235
- const page = apps.slice(startIdx, startIdx + pageSize);
1236
- const nextIdx = startIdx + pageSize;
1237
- const token = nextIdx < apps.length ? Buffer.from(String(nextIdx)).toString('base64') : undefined;
1238
-
1239
- return {
1240
- PlatformApplications: page.map(a => ({
1241
- PlatformApplicationArn: a.PlatformApplicationArn,
1242
- Attributes: a.Attributes
1243
- })),
1244
- ...(token && { NextToken: token })
1245
- };
1246
- }
1247
-
1248
- /**
1249
- * CreatePlatformEndpoint — cria endpoint de dispositivo
1250
- * @param {Object} params
1251
- * @returns {Promise<{EndpointArn: string}>}
1252
- */
1253
- async createPlatformEndpoint(params) {
1254
- const { PlatformApplicationArn, Token, CustomUserData = '', Attributes = {} } = params;
1255
-
1256
- if (!this.platformApps.has(PlatformApplicationArn)) {
1257
- throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1258
- }
1259
-
1260
- if (!Token) throw this._error('InvalidParameter', 'Token is required');
1261
-
1262
- const endpointId = crypto.randomUUID();
1263
- const app = this.platformApps.get(PlatformApplicationArn);
1264
- const arn = `arn:aws:sns:${this.region}:${this.accountId}:endpoint/${app.Platform}/${app.Name}/${endpointId}`;
1265
-
1266
- const endpoint = {
1267
- EndpointArn: arn,
1268
- PlatformApplicationArn,
1269
- Token,
1270
- CustomUserData,
1271
- Attributes: {
1272
- Enabled: 'true',
1273
- Token,
1274
- CustomUserData,
1275
- ...Attributes
1276
- },
1277
- CreatedAt: new Date().toISOString()
1278
- };
1279
-
1280
- this.platformEndpoints.set(arn, endpoint);
1281
- await this._save();
1282
-
1283
- this.logger.info('SNS', `Created platform endpoint: ${arn}`);
1284
- return { EndpointArn: arn };
1285
- }
1286
-
1287
- /**
1288
- * DeleteEndpoint — remove endpoint de dispositivo
1289
- * @param {Object} params
1290
- * @param {string} params.EndpointArn
1291
- * @returns {Promise<void>}
1292
- */
1293
- async deleteEndpoint(params) {
1294
- const { EndpointArn } = params;
1295
-
1296
- if (!this.platformEndpoints.has(EndpointArn)) {
1297
- throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1298
- }
1299
-
1300
- this.platformEndpoints.delete(EndpointArn);
1301
- await this._save();
1302
-
1303
- this.logger.info('SNS', `Deleted endpoint: ${EndpointArn}`);
1304
- }
1305
-
1306
- /**
1307
- * GetEndpointAttributes — retorna atributos de um endpoint
1308
- * @param {Object} params
1309
- * @param {string} params.EndpointArn
1310
- * @returns {{Attributes: Object}}
1311
- */
1312
- getEndpointAttributes(params) {
1313
- const { EndpointArn } = params;
1314
- const ep = this.platformEndpoints.get(EndpointArn);
1315
- if (!ep) throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1316
- return { Attributes: ep.Attributes };
1317
- }
1318
-
1319
- /**
1320
- * SetEndpointAttributes — atualiza atributos de um endpoint
1321
- * @param {Object} params
1322
- * @returns {Promise<void>}
1323
- */
1324
- async setEndpointAttributes(params) {
1325
- const { EndpointArn, Attributes = {} } = params;
1326
- const ep = this.platformEndpoints.get(EndpointArn);
1327
- if (!ep) throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1328
-
1329
- Object.assign(ep.Attributes, Attributes);
1330
- await this._save();
1331
- }
1332
-
1333
- /**
1334
- * ListEndpointsByPlatformApplication — lista endpoints de uma plataforma
1335
- * @param {Object} params
1336
- * @returns {{Endpoints: Array, NextToken?: string}}
1337
- */
1338
- listEndpointsByPlatformApplication(params) {
1339
- const { PlatformApplicationArn, NextToken } = params;
1340
-
1341
- if (!this.platformApps.has(PlatformApplicationArn)) {
1342
- throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1343
- }
1344
-
1345
- const eps = Array.from(this.platformEndpoints.values())
1346
- .filter(e => e.PlatformApplicationArn === PlatformApplicationArn);
1347
- const pageSize = 100;
1348
- let startIdx = 0;
1349
-
1350
- if (NextToken) {
1351
- try { startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0; }
1352
- catch { startIdx = 0; }
1353
- }
1354
-
1355
- const page = eps.slice(startIdx, startIdx + pageSize);
1356
- const nextIdx = startIdx + pageSize;
1357
- const token = nextIdx < eps.length ? Buffer.from(String(nextIdx)).toString('base64') : undefined;
1358
-
1359
- return {
1360
- Endpoints: page.map(e => ({ EndpointArn: e.EndpointArn, Attributes: e.Attributes })),
1361
- ...(token && { NextToken: token })
1362
- };
1363
- }
1364
-
1365
- // ─────────────────────────────────────────────
1366
- // SMS Opt-out
1367
- // ─────────────────────────────────────────────
1368
-
1369
- /**
1370
- * CheckIfPhoneNumberIsOptedOut — verifica se número optou por sair
1371
- * @param {Object} params
1372
- * @param {string} params.phoneNumber
1373
- * @returns {{isOptedOut: boolean}}
1374
- */
1375
- checkIfPhoneNumberIsOptedOut(params) {
1376
- return { isOptedOut: this.optedOutNumbers.has(params.phoneNumber) };
1377
- }
1378
-
1379
- /**
1380
- * ListPhoneNumbersOptedOut — lista números que optaram por sair
1381
- * @returns {{phoneNumbers: Array}}
1382
- */
1383
- listPhoneNumbersOptedOut() {
1384
- return { phoneNumbers: Array.from(this.optedOutNumbers) };
1385
- }
1386
-
1387
- /**
1388
- * OptInPhoneNumber — reincluir número que optou por sair
1389
- * @param {Object} params
1390
- * @param {string} params.phoneNumber
1391
- * @returns {Promise<void>}
1392
- */
1393
- async optInPhoneNumber(params) {
1394
- this.optedOutNumbers.delete(params.phoneNumber);
1395
- this.logger.info('SNS', `Opted-in: ${params.phoneNumber}`);
1396
- }
1397
-
1398
- // ─────────────────────────────────────────────
1399
- // SMS Attributes
1400
- // ─────────────────────────────────────────────
1401
-
1402
- /**
1403
- * SetSMSAttributes — configura atributos SMS globais
1404
- * @param {Object} params
1405
- * @param {Object} params.attributes
1406
- * @returns {Promise<void>}
1407
- */
1408
- async setSmsAttributes(params) {
1409
- Object.assign(this.smsAttributes, params.attributes || {});
1410
- this.logger.debug('SNS', `SMS attributes updated`);
1411
- }
1412
-
1413
- /**
1414
- * GetSMSAttributes — retorna atributos SMS globais
1415
- * @param {Object} [params]
1416
- * @returns {{attributes: Object}}
1417
- */
1418
- getSmsAttributes(params = {}) {
1419
- const { attributes = [] } = params;
1420
-
1421
- if (!attributes.length) return { attributes: { ...this.smsAttributes } };
1422
-
1423
- const filtered = {};
1424
- for (const key of attributes) {
1425
- if (key in this.smsAttributes) filtered[key] = this.smsAttributes[key];
1426
- }
1427
-
1428
- return { attributes: filtered };
1429
- }
1430
-
1431
- // ─────────────────────────────────────────────
1432
- // Publish Log (Admin)
1433
- // ─────────────────────────────────────────────
1434
-
1435
- /**
1436
- * Registra publicação no log interno
1437
- * @param {string|null} topicArn
1438
- * @param {string} messageId
1439
- * @param {string} message
1440
- * @param {string|null} [phoneNumber]
1441
- * @param {Object} [messageAttributes]
1442
- * @private
1443
- */
1444
- _logPublish(topicArn, messageId, message, phoneNumber = null, messageAttributes = {}) {
1445
- this.publishLog.unshift({
1446
- messageId,
1447
- topicArn,
1448
- phoneNumber,
1449
- message: String(message).substring(0, 500),
1450
- messageAttributes,
1451
- timestamp: new Date().toISOString()
1452
- });
1453
-
1454
- // Manter apenas as últimas 500
1455
- if (this.publishLog.length > 500) this.publishLog.pop();
1456
- }
1457
-
1458
- // ─────────────────────────────────────────────
1459
- // Reset
1460
- // ─────────────────────────────────────────────
1461
-
1462
- /**
1463
- * Limpa todos os dados do simulador
1464
- * @returns {Promise<void>}
1465
- */
1466
- async reset() {
1467
- this.topics.clear();
1468
- this.subscriptions.clear();
1469
- this.pendingTokens.clear();
1470
- this.platformApps.clear();
1471
- this.platformEndpoints.clear();
1472
- this.tags.clear();
1473
- this.publishLog = [];
1474
- this.optedOutNumbers.clear();
1475
- this.smsAttributes = { DefaultSMSType: 'Transactional', MonthlySpendLimit: '1' };
1476
-
1477
- await this._save();
1478
- this.logger.info('SNS', 'Data reset complete');
1479
- }
1480
- }
1481
-
1482
- module.exports = { SNSSimulator };
1
+ /**
2
+ * @fileoverview SNS Simulator - Completo
3
+ * Simula o Amazon Simple Notification Service com todas as operações
4
+ *
5
+ * Operações implementadas:
6
+ * - Topics: CreateTopic, DeleteTopic, ListTopics, GetTopicAttributes, SetTopicAttributes
7
+ * - Subscriptions: Subscribe, Unsubscribe, ConfirmSubscription, ListSubscriptions,
8
+ * ListSubscriptionsByTopic, GetSubscriptionAttributes, SetSubscriptionAttributes
9
+ * - Publish: Publish, PublishBatch
10
+ * - Tags: TagResource, UntagResource, ListTagsForResource
11
+ * - Platform: CreatePlatformApplication, DeletePlatformApplication,
12
+ * CreatePlatformEndpoint, DeleteEndpoint
13
+ * - Opt-in: CheckIfPhoneNumberIsOptedOut, ListPhoneNumbersOptedOut, OptInPhoneNumber
14
+ * - SMS: SetSMSAttributes, GetSMSAttributes
15
+ * - Wire Protocol: Query XML (compatível AWS SDK v3)
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const crypto = require('crypto');
21
+ const { CloudTrailAudit } = require('../../utils/cloudtrail-audit');
22
+
23
+ /**
24
+ * SNS Simulator completo
25
+ */
26
+ class SNSSimulator {
27
+ /**
28
+ * @param {Object} config - Service configuration
29
+ * @param {Object} store - LocalStore instance
30
+ * @param {Object} logger - Logger instance
31
+ */
32
+ constructor(config, store, logger) {
33
+ this.config = config;
34
+ this.store = store;
35
+ this.logger = logger;
36
+
37
+ /** @type {Map<string,Object>} Topics indexados por ARN */
38
+ this.topics = new Map();
39
+
40
+ /** @type {Map<string,Object>} Subscriptions indexadas por ARN */
41
+ this.subscriptions = new Map();
42
+
43
+ /** @type {Map<string,string>} Tokens de confirmação pendentes → SubscriptionArn */
44
+ this.pendingTokens = new Map();
45
+
46
+ /** @type {Map<string,Object>} Platform applications por ARN */
47
+ this.platformApps = new Map();
48
+
49
+ /** @type {Map<string,Object>} Platform endpoints por ARN */
50
+ this.platformEndpoints = new Map();
51
+
52
+ /** @type {Map<string,string[]>} Tags por ARN de recurso */
53
+ this.tags = new Map();
54
+
55
+ /** @type {Object} SMS attributes globais */
56
+ this.smsAttributes = { DefaultSMSType: 'Transactional', MonthlySpendLimit: '1' };
57
+
58
+ /** @type {Set<string>} Números optados por sair */
59
+ this.optedOutNumbers = new Set();
60
+
61
+ /** @type {Object|null} Lambda service para protocolo lambda */
62
+ this.lambdaService = null;
63
+
64
+ /** @type {Object|null} SQS service para protocolo sqs */
65
+ this.sqsService = null;
66
+
67
+ /** @type {Array} Log de mensagens publicadas (últimas 500) */
68
+ this.publishLog = [];
69
+
70
+ this.region = 'us-east-1';
71
+ this.accountId = '123456789012';
72
+
73
+ this.audit = new CloudTrailAudit('sns.amazonaws.com');
74
+ }
75
+
76
+ // ─────────────────────────────────────────────
77
+ // Injeção de dependências
78
+ // ─────────────────────────────────────────────
79
+
80
+ /** @param {Object} lambdaService */
81
+ setLambdaService(lambdaService) { this.lambdaService = lambdaService; }
82
+
83
+ /** @param {Object} sqsService */
84
+ setSqsService(sqsService) { this.sqsService = sqsService; }
85
+
86
+ // ─────────────────────────────────────────────
87
+ // Persistência
88
+ // ─────────────────────────────────────────────
89
+
90
+ /** Carrega dados persistidos */
91
+ async load() {
92
+ try {
93
+ const topics = await this.store.read('sns/topics');
94
+ if (Array.isArray(topics)) topics.forEach(t => this.topics.set(t.TopicArn, t));
95
+
96
+ const subs = await this.store.read('sns/subscriptions');
97
+ if (Array.isArray(subs)) subs.forEach(s => this.subscriptions.set(s.SubscriptionArn, s));
98
+
99
+ const apps = await this.store.read('sns/platform-apps');
100
+ if (Array.isArray(apps)) apps.forEach(a => this.platformApps.set(a.PlatformApplicationArn, a));
101
+
102
+ const endpoints = await this.store.read('sns/platform-endpoints');
103
+ if (Array.isArray(endpoints)) endpoints.forEach(e => this.platformEndpoints.set(e.EndpointArn, e));
104
+
105
+ const tagsData = await this.store.read('sns/tags');
106
+ if (tagsData && typeof tagsData === 'object' && !Array.isArray(tagsData)) {
107
+ Object.entries(tagsData).forEach(([k, v]) => this.tags.set(k, v));
108
+ }
109
+
110
+ this.logger.debug('SNS', `Loaded ${this.topics.size} topics, ${this.subscriptions.size} subscriptions`);
111
+ } catch {
112
+ this.logger.debug('SNS', 'No persisted data, starting fresh');
113
+ }
114
+ }
115
+
116
+ /** Persiste todos os dados */
117
+ async _save() {
118
+ await Promise.all([
119
+ this.store.write('sns/topics', null, Array.from(this.topics.values())),
120
+ this.store.write('sns/subscriptions', null, Array.from(this.subscriptions.values())),
121
+ this.store.write('sns/platform-apps', null, Array.from(this.platformApps.values())),
122
+ this.store.write('sns/platform-endpoints',null, Array.from(this.platformEndpoints.values())),
123
+ this.store.write('sns/tags', null, Object.fromEntries(this.tags.entries()))
124
+ ]);
125
+ }
126
+
127
+ // ─────────────────────────────────────────────
128
+ // Helpers
129
+ // ─────────────────────────────────────────────
130
+
131
+ /** @param {string} name @returns {string} */
132
+ _topicArn(name) {
133
+ return `arn:aws:sns:${this.region}:${this.accountId}:${name}`;
134
+ }
135
+
136
+ /** @param {string} topicArn @returns {string} */
137
+ _subscriptionArn(topicArn) {
138
+ return `${topicArn}:${crypto.randomUUID()}`;
139
+ }
140
+
141
+ /**
142
+ * Cria erro no padrão AWS
143
+ * @param {string} code
144
+ * @param {string} message
145
+ * @returns {Error}
146
+ */
147
+ _error(code, message) {
148
+ const err = new Error(message);
149
+ err.code = code;
150
+ err.__type = code;
151
+ err.statusCode = code === 'NotFound' || code === 'ResourceNotFoundException' ? 404 : 400;
152
+ return err;
153
+ }
154
+
155
+ /**
156
+ * Valida nome de tópico
157
+ * @param {string} name
158
+ */
159
+ _validateTopicName(name) {
160
+ if (!name) throw this._error('InvalidParameter', 'Topic name is required');
161
+ if (!/^[a-zA-Z0-9_-]{1,256}$/.test(name.replace(/\.fifo$/, ''))) {
162
+ throw this._error('InvalidParameter', 'Invalid topic name. Use alphanumeric, hyphens, underscores (max 256 chars)');
163
+ }
164
+ }
165
+
166
+ // ─────────────────────────────────────────────
167
+ // Topic Operations
168
+ // ─────────────────────────────────────────────
169
+
170
+ /**
171
+ * CreateTopic — cria um tópico SNS
172
+ * @param {Object} params
173
+ * @param {string} params.Name
174
+ * @param {Object} [params.Attributes]
175
+ * @param {Array} [params.Tags]
176
+ * @returns {Promise<{TopicArn: string}>}
177
+ */
178
+ async createTopic(params) {
179
+ const { Name, Attributes = {}, Tags = [] } = params;
180
+
181
+ this._validateTopicName(Name);
182
+
183
+ const isFifo = Name.endsWith('.fifo');
184
+ const topicArn = this._topicArn(Name);
185
+
186
+ // Idempotente: retorna ARN existente
187
+ if (this.topics.has(topicArn)) {
188
+ return { TopicArn: topicArn };
189
+ }
190
+
191
+ const now = new Date().toISOString();
192
+
193
+ const topic = {
194
+ TopicArn: topicArn,
195
+ Name,
196
+ Attributes: {
197
+ TopicArn: topicArn,
198
+ Owner: this.accountId,
199
+ Policy: JSON.stringify({ Version: '2012-10-17', Statement: [] }),
200
+ DisplayName: Attributes.DisplayName || '',
201
+ SubscriptionsPending: '0',
202
+ SubscriptionsConfirmed: '0',
203
+ SubscriptionsDeleted: '0',
204
+ DeliveryPolicy: '{}',
205
+ EffectiveDeliveryPolicy: '{}',
206
+ KmsMasterKeyId: Attributes.KmsMasterKeyId || '',
207
+ FifoTopic: String(isFifo),
208
+ ContentBasedDeduplication: Attributes.ContentBasedDeduplication || 'false',
209
+ ArchivePolicy: Attributes.ArchivePolicy || '',
210
+ BeginningArchiveTime: '',
211
+ SignatureVersion: '1',
212
+ TracingConfig: Attributes.TracingConfig || 'PassThrough',
213
+ ...Attributes
214
+ },
215
+ Tags,
216
+ CreatedAt: now
217
+ };
218
+
219
+ this.topics.set(topicArn, topic);
220
+
221
+ // Persistir tags separadamente
222
+ if (Tags.length > 0) {
223
+ this.tags.set(topicArn, Tags);
224
+ }
225
+
226
+ await this._save();
227
+
228
+ this.logger.info('SNS', `Created topic: ${Name}${isFifo ? ' (FIFO)' : ''}`);
229
+ this.audit.record({
230
+ eventName: 'CreateTopic',
231
+ readOnly: false,
232
+ resources: [{ ARN: topicArn, type: 'AWS::SNS::Topic' }],
233
+ requestParameters: { name: Name },
234
+ });
235
+ return { TopicArn: topicArn };
236
+ }
237
+
238
+ /**
239
+ * DeleteTopic — remove um tópico e todas as suas subscriptions
240
+ * @param {Object} params
241
+ * @param {string} params.TopicArn
242
+ * @returns {Promise<void>}
243
+ */
244
+ async deleteTopic(params) {
245
+ const { TopicArn } = params;
246
+
247
+ if (!this.topics.has(TopicArn)) {
248
+ throw this._error('NotFound', `Topic not found: ${TopicArn}`);
249
+ }
250
+
251
+ // Remove subscriptions associadas
252
+ let removedSubs = 0;
253
+ for (const [arn, sub] of this.subscriptions.entries()) {
254
+ if (sub.TopicArn === TopicArn) {
255
+ this.subscriptions.delete(arn);
256
+ removedSubs++;
257
+ }
258
+ }
259
+
260
+ // Remove tokens pendentes
261
+ for (const [token, subArn] of this.pendingTokens.entries()) {
262
+ const sub = this.subscriptions.get(subArn);
263
+ if (!sub || sub.TopicArn === TopicArn) {
264
+ this.pendingTokens.delete(token);
265
+ }
266
+ }
267
+
268
+ this.topics.delete(TopicArn);
269
+ this.tags.delete(TopicArn);
270
+
271
+ await this._save();
272
+
273
+ this.logger.info('SNS', `Deleted topic: ${TopicArn} (removed ${removedSubs} subscriptions)`);
274
+ this.audit.record({
275
+ eventName: 'DeleteTopic',
276
+ readOnly: false,
277
+ resources: [{ ARN: TopicArn, type: 'AWS::SNS::Topic' }],
278
+ requestParameters: { topicArn: TopicArn },
279
+ });
280
+ }
281
+
282
+ /**
283
+ * ListTopics — lista tópicos paginados (100 por página)
284
+ * @param {Object} [params]
285
+ * @param {string} [params.NextToken]
286
+ * @returns {{Topics: Array, NextToken?: string}}
287
+ */
288
+ listTopics(params = {}) {
289
+ const { NextToken } = params;
290
+ const allTopics = Array.from(this.topics.values());
291
+ const pageSize = 100;
292
+ let startIdx = 0;
293
+
294
+ if (NextToken) {
295
+ try {
296
+ startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0;
297
+ } catch { startIdx = 0; }
298
+ }
299
+
300
+ const page = allTopics.slice(startIdx, startIdx + pageSize);
301
+ const nextIdx = startIdx + pageSize;
302
+ const nextToken = nextIdx < allTopics.length
303
+ ? Buffer.from(String(nextIdx)).toString('base64')
304
+ : undefined;
305
+
306
+ return {
307
+ Topics: page.map(t => ({ TopicArn: t.TopicArn })),
308
+ ...(nextToken && { NextToken: nextToken })
309
+ };
310
+ }
311
+
312
+ /**
313
+ * GetTopicAttributes — retorna atributos de um tópico
314
+ * @param {Object} params
315
+ * @param {string} params.TopicArn
316
+ * @returns {{Attributes: Object}}
317
+ */
318
+ getTopicAttributes(params) {
319
+ const { TopicArn } = params;
320
+ const topic = this.topics.get(TopicArn);
321
+ if (!topic) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
322
+
323
+ // Calcular contadores em tempo real
324
+ const subs = Array.from(this.subscriptions.values()).filter(s => s.TopicArn === TopicArn);
325
+ const confirmed = subs.filter(s => s.PendingConfirmation === 'false').length;
326
+ const pending = subs.filter(s => s.PendingConfirmation === 'true').length;
327
+
328
+ return {
329
+ Attributes: {
330
+ ...topic.Attributes,
331
+ SubscriptionsConfirmed: String(confirmed),
332
+ SubscriptionsPending: String(pending),
333
+ SubscriptionsDeleted: topic.Attributes.SubscriptionsDeleted || '0'
334
+ }
335
+ };
336
+ }
337
+
338
+ /**
339
+ * SetTopicAttributes — atualiza um atributo do tópico
340
+ * @param {Object} params
341
+ * @param {string} params.TopicArn
342
+ * @param {string} params.AttributeName
343
+ * @param {string} params.AttributeValue
344
+ * @returns {Promise<void>}
345
+ */
346
+ async setTopicAttributes(params) {
347
+ const { TopicArn, AttributeName, AttributeValue } = params;
348
+ const topic = this.topics.get(TopicArn);
349
+ if (!topic) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
350
+
351
+ const readOnly = ['TopicArn', 'Owner', 'SubscriptionsConfirmed', 'SubscriptionsPending', 'SubscriptionsDeleted'];
352
+ if (readOnly.includes(AttributeName)) {
353
+ throw this._error('InvalidParameter', `Attribute ${AttributeName} is read-only`);
354
+ }
355
+
356
+ topic.Attributes[AttributeName] = AttributeValue;
357
+ await this._save();
358
+
359
+ this.logger.debug('SNS', `SetTopicAttributes: ${AttributeName}=${AttributeValue} on ${TopicArn}`);
360
+ }
361
+
362
+ // ─────────────────────────────────────────────
363
+ // Subscription Operations
364
+ // ─────────────────────────────────────────────
365
+
366
+ /**
367
+ * Subscribe — inscreve um endpoint em um tópico
368
+ * @param {Object} params
369
+ * @param {string} params.TopicArn
370
+ * @param {string} params.Protocol
371
+ * @param {string} [params.Endpoint]
372
+ * @param {Object} [params.Attributes]
373
+ * @param {boolean} [params.ReturnSubscriptionArn]
374
+ * @returns {Promise<{SubscriptionArn: string}>}
375
+ */
376
+ async subscribe(params) {
377
+ const { TopicArn, Protocol, Endpoint = '', Attributes = {}, ReturnSubscriptionArn = false } = params;
378
+
379
+ if (!this.topics.has(TopicArn)) {
380
+ throw this._error('NotFound', `Topic not found: ${TopicArn}`);
381
+ }
382
+
383
+ const validProtocols = ['lambda', 'sqs', 'http', 'https', 'email', 'email-json', 'sms', 'application', 'firehose'];
384
+ if (!validProtocols.includes(Protocol)) {
385
+ throw this._error('InvalidParameter', `Invalid protocol: ${Protocol}. Must be one of: ${validProtocols.join(', ')}`);
386
+ }
387
+
388
+ // Verifica se já existe subscrição idêntica
389
+ for (const sub of this.subscriptions.values()) {
390
+ if (sub.TopicArn === TopicArn && sub.Protocol === Protocol && sub.Endpoint === Endpoint) {
391
+ return { SubscriptionArn: sub.SubscriptionArn };
392
+ }
393
+ }
394
+
395
+ const subscriptionArn = this._subscriptionArn(TopicArn);
396
+ const now = new Date().toISOString();
397
+
398
+ // Protocolos que não requerem confirmação
399
+ const autoConfirmed = ['lambda', 'sqs', 'application', 'firehose'];
400
+ const pendingConfirmation = autoConfirmed.includes(Protocol) ? 'false' : 'true';
401
+
402
+ let filterPolicy = null;
403
+ if (Attributes.FilterPolicy) {
404
+ try { filterPolicy = JSON.parse(Attributes.FilterPolicy); }
405
+ catch { throw this._error('InvalidParameter', 'FilterPolicy must be valid JSON'); }
406
+ }
407
+
408
+ let filterPolicyScope = Attributes.FilterPolicyScope || 'MessageAttributes';
409
+
410
+ const subscription = {
411
+ SubscriptionArn: subscriptionArn,
412
+ TopicArn,
413
+ Protocol,
414
+ Endpoint,
415
+ Owner: this.accountId,
416
+ ConfirmationWasAuthenticated: 'true',
417
+ PendingConfirmation: pendingConfirmation,
418
+ FilterPolicy: filterPolicy,
419
+ FilterPolicyScope: filterPolicyScope,
420
+ RawMessageDelivery: Attributes.RawMessageDelivery || 'false',
421
+ RedrivePolicy: Attributes.RedrivePolicy || null,
422
+ DeliveryPolicy: Attributes.DeliveryPolicy || null,
423
+ SubscriptionRoleArn: Attributes.SubscriptionRoleArn || '',
424
+ CreatedAt: now
425
+ };
426
+
427
+ this.subscriptions.set(subscriptionArn, subscription);
428
+
429
+ // Gerar token de confirmação para protocolos http/https/email/sms
430
+ if (pendingConfirmation === 'true') {
431
+ const token = crypto.randomBytes(64).toString('hex');
432
+ this.pendingTokens.set(token, subscriptionArn);
433
+ this.logger.info('SNS', `[CONFIRMATION MOCK] Token for ${Protocol}:${Endpoint}: ${token.substring(0, 16)}...`);
434
+ }
435
+
436
+ await this._save();
437
+
438
+ this.logger.info('SNS', `Subscribed ${Protocol}:${Endpoint} → ${TopicArn}`);
439
+
440
+ // Se ReturnSubscriptionArn=true OU auto-confirmado → retorna ARN real
441
+ // Senão retorna 'PendingConfirmation'
442
+ const returnArn = (ReturnSubscriptionArn || pendingConfirmation === 'false')
443
+ ? subscriptionArn
444
+ : 'PendingConfirmation';
445
+
446
+ return { SubscriptionArn: returnArn };
447
+ }
448
+
449
+ /**
450
+ * ConfirmSubscription — confirma uma subscrição via token
451
+ * @param {Object} params
452
+ * @param {string} params.TopicArn
453
+ * @param {string} params.Token
454
+ * @param {string} [params.AuthenticateOnUnsubscribe]
455
+ * @returns {{SubscriptionArn: string}}
456
+ */
457
+ async confirmSubscription(params) {
458
+ const { TopicArn, Token } = params;
459
+
460
+ if (!this.topics.has(TopicArn)) {
461
+ throw this._error('NotFound', `Topic not found: ${TopicArn}`);
462
+ }
463
+
464
+ const subscriptionArn = this.pendingTokens.get(Token);
465
+ if (!subscriptionArn) {
466
+ throw this._error('InvalidParameter', 'Invalid confirmation token');
467
+ }
468
+
469
+ const sub = this.subscriptions.get(subscriptionArn);
470
+ if (!sub || sub.TopicArn !== TopicArn) {
471
+ throw this._error('InvalidParameter', 'Token does not match topic');
472
+ }
473
+
474
+ sub.PendingConfirmation = 'false';
475
+ sub.ConfirmationWasAuthenticated = 'true';
476
+ this.pendingTokens.delete(Token);
477
+
478
+ await this._save();
479
+
480
+ this.logger.info('SNS', `Confirmed subscription: ${subscriptionArn}`);
481
+ return { SubscriptionArn: subscriptionArn };
482
+ }
483
+
484
+ /**
485
+ * Unsubscribe — remove uma subscrição
486
+ * @param {Object} params
487
+ * @param {string} params.SubscriptionArn
488
+ * @returns {Promise<void>}
489
+ */
490
+ async unsubscribe(params) {
491
+ const { SubscriptionArn } = params;
492
+
493
+ const sub = this.subscriptions.get(SubscriptionArn);
494
+ if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
495
+
496
+ this.subscriptions.delete(SubscriptionArn);
497
+
498
+ // Incrementar contador de deletadas no tópico
499
+ const topic = this.topics.get(sub.TopicArn);
500
+ if (topic) {
501
+ const deleted = parseInt(topic.Attributes.SubscriptionsDeleted || '0');
502
+ topic.Attributes.SubscriptionsDeleted = String(deleted + 1);
503
+ }
504
+
505
+ await this._save();
506
+
507
+ this.logger.info('SNS', `Unsubscribed: ${SubscriptionArn}`);
508
+ }
509
+
510
+ /**
511
+ * ListSubscriptions — lista todas as subscrições paginadas
512
+ * @param {Object} [params]
513
+ * @returns {{Subscriptions: Array, NextToken?: string}}
514
+ */
515
+ listSubscriptions(params = {}) {
516
+ return this._paginateSubscriptions(Array.from(this.subscriptions.values()), params.NextToken);
517
+ }
518
+
519
+ /**
520
+ * ListSubscriptionsByTopic — lista subscrições de um tópico
521
+ * @param {Object} params
522
+ * @param {string} params.TopicArn
523
+ * @returns {{Subscriptions: Array, NextToken?: string}}
524
+ */
525
+ listSubscriptionsByTopic(params) {
526
+ const { TopicArn, NextToken } = params;
527
+ if (!this.topics.has(TopicArn)) throw this._error('NotFound', `Topic not found: ${TopicArn}`);
528
+
529
+ const subs = Array.from(this.subscriptions.values()).filter(s => s.TopicArn === TopicArn);
530
+ return this._paginateSubscriptions(subs, NextToken);
531
+ }
532
+
533
+ /**
534
+ * Pagina lista de subscriptions
535
+ * @param {Array} subs
536
+ * @param {string} [nextToken]
537
+ * @returns {{Subscriptions: Array, NextToken?: string}}
538
+ * @private
539
+ */
540
+ _paginateSubscriptions(subs, nextToken) {
541
+ const pageSize = 100;
542
+ let startIdx = 0;
543
+
544
+ if (nextToken) {
545
+ try { startIdx = parseInt(Buffer.from(nextToken, 'base64').toString('utf8'), 10) || 0; }
546
+ catch { startIdx = 0; }
547
+ }
548
+
549
+ const page = subs.slice(startIdx, startIdx + pageSize);
550
+ const nextIdx = startIdx + pageSize;
551
+ const token = nextIdx < subs.length
552
+ ? Buffer.from(String(nextIdx)).toString('base64')
553
+ : undefined;
554
+
555
+ return {
556
+ Subscriptions: page.map(s => ({
557
+ SubscriptionArn: s.SubscriptionArn,
558
+ TopicArn: s.TopicArn,
559
+ Protocol: s.Protocol,
560
+ Endpoint: s.Endpoint,
561
+ Owner: s.Owner
562
+ })),
563
+ ...(token && { NextToken: token })
564
+ };
565
+ }
566
+
567
+ /**
568
+ * GetSubscriptionAttributes — retorna atributos de uma subscrição
569
+ * @param {Object} params
570
+ * @param {string} params.SubscriptionArn
571
+ * @returns {{Attributes: Object}}
572
+ */
573
+ getSubscriptionAttributes(params) {
574
+ const { SubscriptionArn } = params;
575
+ const sub = this.subscriptions.get(SubscriptionArn);
576
+ if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
577
+
578
+ return {
579
+ Attributes: {
580
+ SubscriptionArn: sub.SubscriptionArn,
581
+ TopicArn: sub.TopicArn,
582
+ Protocol: sub.Protocol,
583
+ Endpoint: sub.Endpoint,
584
+ Owner: sub.Owner,
585
+ FilterPolicy: sub.FilterPolicy ? JSON.stringify(sub.FilterPolicy) : '',
586
+ FilterPolicyScope: sub.FilterPolicyScope || 'MessageAttributes',
587
+ RawMessageDelivery: sub.RawMessageDelivery,
588
+ ConfirmationWasAuthenticated: sub.ConfirmationWasAuthenticated,
589
+ PendingConfirmation: sub.PendingConfirmation,
590
+ RedrivePolicy: sub.RedrivePolicy ? JSON.stringify(sub.RedrivePolicy) : '',
591
+ DeliveryPolicy: sub.DeliveryPolicy ? JSON.stringify(sub.DeliveryPolicy) : '',
592
+ SubscriptionRoleArn: sub.SubscriptionRoleArn || ''
593
+ }
594
+ };
595
+ }
596
+
597
+ /**
598
+ * SetSubscriptionAttributes — atualiza atributos de uma subscrição
599
+ * @param {Object} params
600
+ * @param {string} params.SubscriptionArn
601
+ * @param {string} params.AttributeName
602
+ * @param {string} [params.AttributeValue]
603
+ * @returns {Promise<void>}
604
+ */
605
+ async setSubscriptionAttributes(params) {
606
+ const { SubscriptionArn, AttributeName, AttributeValue } = params;
607
+ const sub = this.subscriptions.get(SubscriptionArn);
608
+ if (!sub) throw this._error('NotFound', `Subscription not found: ${SubscriptionArn}`);
609
+
610
+ const editableAttrs = ['FilterPolicy', 'FilterPolicyScope', 'RawMessageDelivery', 'RedrivePolicy', 'DeliveryPolicy', 'SubscriptionRoleArn'];
611
+ if (!editableAttrs.includes(AttributeName)) {
612
+ throw this._error('InvalidParameter', `Attribute ${AttributeName} is not editable`);
613
+ }
614
+
615
+ if (AttributeName === 'FilterPolicy') {
616
+ try { sub.FilterPolicy = AttributeValue ? JSON.parse(AttributeValue) : null; }
617
+ catch { throw this._error('InvalidParameter', 'FilterPolicy must be valid JSON'); }
618
+ } else {
619
+ sub[AttributeName] = AttributeValue || '';
620
+ }
621
+
622
+ await this._save();
623
+ this.logger.debug('SNS', `SetSubscriptionAttributes: ${AttributeName} on ${SubscriptionArn}`);
624
+ }
625
+
626
+ // ─────────────────────────────────────────────
627
+ // Publish Operations
628
+ // ─────────────────────────────────────────────
629
+
630
+ /**
631
+ * Publish — publica uma mensagem em um tópico ou endpoint
632
+ * @param {Object} params
633
+ * @param {string} [params.TopicArn]
634
+ * @param {string} [params.TargetArn]
635
+ * @param {string} [params.PhoneNumber]
636
+ * @param {string} params.Message
637
+ * @param {string} [params.Subject]
638
+ * @param {string} [params.MessageStructure]
639
+ * @param {Object} [params.MessageAttributes]
640
+ * @param {string} [params.MessageGroupId]
641
+ * @param {string} [params.MessageDeduplicationId]
642
+ * @returns {Promise<{MessageId: string, SequenceNumber?: string}>}
643
+ */
644
+ async publish(params) {
645
+ const {
646
+ TopicArn, TargetArn, PhoneNumber,
647
+ Message, Subject,
648
+ MessageStructure, MessageAttributes = {},
649
+ MessageGroupId, MessageDeduplicationId
650
+ } = params;
651
+
652
+ // SMS direto para número
653
+ if (PhoneNumber) {
654
+ if (this.optedOutNumbers.has(PhoneNumber)) {
655
+ throw this._error('OptedOut', `Number ${PhoneNumber} has opted out`);
656
+ }
657
+ const messageId = crypto.randomUUID();
658
+ this.logger.info('SNS', `[SMS MOCK] To: ${PhoneNumber}, Message: ${String(Message).substring(0, 160)}`);
659
+ this._logPublish(null, messageId, Message, PhoneNumber);
660
+ return { MessageId: messageId };
661
+ }
662
+
663
+ const arn = TopicArn || TargetArn;
664
+ if (!arn) throw this._error('InvalidParameter', 'TopicArn, TargetArn, or PhoneNumber is required');
665
+ if (!Message) throw this._error('InvalidParameter', 'Message is required');
666
+ if (Message.length > 262144) throw this._error('InvalidParameter', 'Message too large (max 256KB)');
667
+
668
+ // Entrega para endpoint de plataforma
669
+ if (TargetArn && !TopicArn) {
670
+ const endpoint = this.platformEndpoints.get(TargetArn);
671
+ if (!endpoint) throw this._error('NotFound', `Endpoint not found: ${TargetArn}`);
672
+ const messageId = crypto.randomUUID();
673
+ this.logger.info('SNS', `[PLATFORM MOCK] Endpoint: ${TargetArn}, Message: ${String(Message).substring(0, 100)}`);
674
+ this._logPublish(TargetArn, messageId, Message);
675
+ return { MessageId: messageId };
676
+ }
677
+
678
+ const topic = this.topics.get(arn);
679
+ if (!topic) throw this._error('NotFound', `Topic not found: ${arn}`);
680
+
681
+ const messageId = crypto.randomUUID();
682
+
683
+ // Parse de mensagem estruturada
684
+ let messagePayload = Message;
685
+ if (MessageStructure === 'json') {
686
+ try { messagePayload = JSON.parse(Message); }
687
+ catch { throw this._error('InvalidParameter', 'Message is not valid JSON for MessageStructure=json'); }
688
+ }
689
+
690
+ this._logPublish(arn, messageId, Message, null, MessageAttributes);
691
+
692
+ this.logger.debug('SNS', `Publishing messageId=${messageId} to ${arn} (${topic.Name})`);
693
+ this.audit.record({
694
+ eventName: 'Publish',
695
+ readOnly: false,
696
+ resources: [{ ARN: arn, type: 'AWS::SNS::Topic' }],
697
+ requestParameters: { topicArn: arn },
698
+ });
699
+
700
+ // Coletar subscriptions confirmadas do tópico
701
+ const topicSubs = Array.from(this.subscriptions.values())
702
+ .filter(s => s.TopicArn === arn && s.PendingConfirmation === 'false');
703
+
704
+ // Entrega assíncrona (fire and forget)
705
+ Promise.all(
706
+ topicSubs.map(sub =>
707
+ this._deliver(sub, messagePayload, MessageStructure, Subject, MessageAttributes, messageId)
708
+ .catch(err => this.logger.warn('SNS', `Delivery failed [${sub.Protocol}:${sub.Endpoint}]: ${err.message}`))
709
+ )
710
+ );
711
+
712
+ return {
713
+ MessageId: messageId,
714
+ ...(MessageGroupId && { SequenceNumber: String(Date.now()).padStart(20, '0') })
715
+ };
716
+ }
717
+
718
+ /**
719
+ * PublishBatch — publica múltiplas mensagens em um tópico
720
+ * @param {Object} params
721
+ * @param {string} params.TopicArn
722
+ * @param {Array} params.PublishBatchRequestEntries
723
+ * @returns {Promise<{Successful: Array, Failed: Array}>}
724
+ */
725
+ async publishBatch(params) {
726
+ const { TopicArn, PublishBatchRequestEntries = [] } = params;
727
+
728
+ if (!this.topics.has(TopicArn)) {
729
+ throw this._error('NotFound', `Topic not found: ${TopicArn}`);
730
+ }
731
+
732
+ if (PublishBatchRequestEntries.length === 0) {
733
+ throw this._error('InvalidParameter', 'At least one entry is required');
734
+ }
735
+
736
+ if (PublishBatchRequestEntries.length > 10) {
737
+ throw this._error('TooManyEntriesInBatchRequest', 'Maximum 10 entries per batch');
738
+ }
739
+
740
+ const successful = [];
741
+ const failed = [];
742
+
743
+ for (const entry of PublishBatchRequestEntries) {
744
+ try {
745
+ const result = await this.publish({
746
+ TopicArn,
747
+ Message: entry.Message,
748
+ Subject: entry.Subject,
749
+ MessageStructure: entry.MessageStructure,
750
+ MessageAttributes: entry.MessageAttributes,
751
+ MessageGroupId: entry.MessageGroupId,
752
+ MessageDeduplicationId: entry.MessageDeduplicationId
753
+ });
754
+
755
+ successful.push({
756
+ Id: entry.Id,
757
+ MessageId: result.MessageId,
758
+ SequenceNumber: result.SequenceNumber
759
+ });
760
+ } catch (err) {
761
+ failed.push({
762
+ Id: entry.Id,
763
+ Code: err.code || 'InternalFailure',
764
+ Message: err.message,
765
+ SenderFault: true
766
+ });
767
+ }
768
+ }
769
+
770
+ return { Successful: successful, Failed: failed };
771
+ }
772
+
773
+ // ─────────────────────────────────────────────
774
+ // Delivery Engine
775
+ // ─────────────────────────────────────────────
776
+
777
+ /**
778
+ * Entrega mensagem para um subscriber
779
+ * @param {Object} subscription
780
+ * @param {*} message
781
+ * @param {string} [messageStructure]
782
+ * @param {string} [subject]
783
+ * @param {Object} [messageAttributes]
784
+ * @param {string} messageId
785
+ * @returns {Promise<void>}
786
+ * @private
787
+ */
788
+ async _deliver(subscription, message, messageStructure, subject, messageAttributes = {}, messageId) {
789
+ // Aplicar FilterPolicy
790
+ if (subscription.FilterPolicy) {
791
+ const scope = subscription.FilterPolicyScope || 'MessageAttributes';
792
+ const matchData = scope === 'MessageBody'
793
+ ? (typeof message === 'string' ? JSON.parse(message) : message)
794
+ : messageAttributes;
795
+
796
+ if (!this._matchesFilterPolicy(subscription.FilterPolicy, matchData, scope)) {
797
+ this.logger.debug('SNS', `Message filtered for ${subscription.SubscriptionArn}`);
798
+ return;
799
+ }
800
+ }
801
+
802
+ // Resolver conteúdo para o protocolo (MessageStructure=json)
803
+ let content = message;
804
+ if (messageStructure === 'json' && typeof message === 'object') {
805
+ content = message[subscription.Protocol] || message.default || '';
806
+ }
807
+
808
+ // Construir envelope SNS
809
+ const envelope = subscription.RawMessageDelivery === 'true'
810
+ ? (typeof content === 'string' ? content : JSON.stringify(content))
811
+ : {
812
+ Type: 'Notification',
813
+ MessageId: messageId,
814
+ TopicArn: subscription.TopicArn,
815
+ Subject: subject || 'Amazon SNS',
816
+ Message: typeof content === 'string' ? content : JSON.stringify(content),
817
+ Timestamp: new Date().toISOString(),
818
+ SignatureVersion: '1',
819
+ Signature: 'LOCAL_SIMULATOR_NO_SIGNATURE',
820
+ SigningCertURL: `https://sns.${this.region}.amazonaws.com/SimpleNotificationService.pem`,
821
+ UnsubscribeURL: `http://localhost:${this.config.services?.sns?.port || 9911}/?Action=Unsubscribe&SubscriptionArn=${subscription.SubscriptionArn}`,
822
+ MessageAttributes: messageAttributes
823
+ };
824
+
825
+ switch (subscription.Protocol) {
826
+ case 'lambda':
827
+ await this._deliverToLambda(subscription.Endpoint, envelope, messageId, subscription.SubscriptionArn);
828
+ break;
829
+
830
+ case 'sqs':
831
+ await this._deliverToSqs(subscription.Endpoint, envelope, subscription.RawMessageDelivery === 'true');
832
+ break;
833
+
834
+ case 'http':
835
+ case 'https':
836
+ await this._deliverToHttp(subscription.Endpoint, envelope, subscription.Protocol);
837
+ break;
838
+
839
+ case 'email':
840
+ this.logger.info('SNS', `[EMAIL MOCK] To: ${subscription.Endpoint} | Subject: ${subject || 'No subject'} | Body: ${String(content).substring(0, 200)}`);
841
+ break;
842
+
843
+ case 'email-json':
844
+ this.logger.info('SNS', `[EMAIL-JSON MOCK] To: ${subscription.Endpoint} | Payload: ${JSON.stringify(envelope).substring(0, 200)}`);
845
+ break;
846
+
847
+ case 'sms':
848
+ if (!this.optedOutNumbers.has(subscription.Endpoint)) {
849
+ this.logger.info('SNS', `[SMS MOCK] To: ${subscription.Endpoint} | Message: ${String(content).substring(0, 160)}`);
850
+ }
851
+ break;
852
+
853
+ case 'application':
854
+ this.logger.info('SNS', `[PUSH MOCK] Endpoint: ${subscription.Endpoint} | Payload: ${String(content).substring(0, 200)}`);
855
+ break;
856
+
857
+ default:
858
+ this.logger.warn('SNS', `Unsupported protocol: ${subscription.Protocol}`);
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Entrega para função Lambda via SNS Records
864
+ * @param {string} endpoint - Lambda ARN
865
+ * @param {Object|string} envelope
866
+ * @param {string} messageId
867
+ * @param {string} subscriptionArn
868
+ * @returns {Promise<void>}
869
+ * @private
870
+ */
871
+ async _deliverToLambda(endpoint, envelope, messageId, subscriptionArn) {
872
+ if (!this.lambdaService) {
873
+ this.logger.warn('SNS', 'Lambda service not available for delivery');
874
+ return;
875
+ }
876
+
877
+ const funcMatch = endpoint.match(/function:([^:]+)/);
878
+ if (!funcMatch) {
879
+ this.logger.warn('SNS', `Cannot parse Lambda ARN: ${endpoint}`);
880
+ return;
881
+ }
882
+
883
+ const functionName = funcMatch[1];
884
+ const isRaw = typeof envelope === 'string';
885
+ const snsPayload = isRaw ? { Message: envelope } : envelope;
886
+
887
+ const event = {
888
+ Records: [{
889
+ EventSource: 'aws:sns',
890
+ EventVersion: '1.0',
891
+ EventSubscriptionArn: subscriptionArn,
892
+ Sns: {
893
+ Type: snsPayload.Type || 'Notification',
894
+ MessageId: messageId,
895
+ TopicArn: snsPayload.TopicArn || endpoint,
896
+ Subject: snsPayload.Subject || '',
897
+ Message: snsPayload.Message || '',
898
+ Timestamp: snsPayload.Timestamp || new Date().toISOString(),
899
+ SignatureVersion: snsPayload.SignatureVersion || '1',
900
+ Signature: snsPayload.Signature || 'LOCAL_SIMULATOR',
901
+ MessageAttributes: snsPayload.MessageAttributes || {}
902
+ }
903
+ }]
904
+ };
905
+
906
+ try {
907
+ await this.lambdaService.simulator.invokeFunction(functionName, event);
908
+ this.logger.debug('SNS', `Delivered to Lambda: ${functionName}`);
909
+ } catch (err) {
910
+ this.logger.error('SNS', `Lambda delivery failed [${functionName}]: ${err.message}`);
911
+ throw err;
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Entrega mensagem para fila SQS
917
+ * @param {string} endpoint - SQS URL ou ARN
918
+ * @param {Object|string} envelope
919
+ * @param {boolean} rawDelivery
920
+ * @returns {Promise<void>}
921
+ * @private
922
+ */
923
+ async _deliverToSqs(endpoint, envelope, rawDelivery) {
924
+ if (!this.sqsService) {
925
+ this.logger.warn('SNS', 'SQS service not available for delivery');
926
+ return;
927
+ }
928
+
929
+ const messageBody = rawDelivery
930
+ ? (typeof envelope === 'string' ? envelope : JSON.stringify(envelope))
931
+ : JSON.stringify(envelope);
932
+
933
+ try {
934
+ await this.sqsService.simulator.sendMessage({ QueueUrl: endpoint, MessageBody: messageBody });
935
+ this.logger.debug('SNS', `Delivered to SQS: ${endpoint}`);
936
+ } catch (err) {
937
+ this.logger.error('SNS', `SQS delivery failed [${endpoint}]: ${err.message}`);
938
+ throw err;
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Entrega mensagem via HTTP/HTTPS
944
+ * @param {string} endpoint
945
+ * @param {Object|string} envelope
946
+ * @param {string} protocol
947
+ * @returns {Promise<void>}
948
+ * @private
949
+ */
950
+ async _deliverToHttp(endpoint, envelope, protocol) {
951
+ const httpModule = require(protocol === 'https' ? 'https' : 'http');
952
+ const body = typeof envelope === 'string' ? envelope : JSON.stringify(envelope);
953
+
954
+ return new Promise((resolve) => {
955
+ try {
956
+ const url = new URL(endpoint);
957
+ const req = httpModule.request({
958
+ hostname: url.hostname,
959
+ port: url.port || (protocol === 'https' ? 443 : 80),
960
+ path: url.pathname + url.search,
961
+ method: 'POST',
962
+ headers: {
963
+ 'Content-Type': 'application/json',
964
+ 'Content-Length': Buffer.byteLength(body),
965
+ 'x-amz-sns-message-type': 'Notification',
966
+ 'x-amz-sns-topic-arn': envelope.TopicArn || '',
967
+ 'x-amz-sns-message-id': envelope.MessageId || ''
968
+ }
969
+ }, (res) => {
970
+ this.logger.debug('SNS', `HTTP delivery to ${endpoint}: HTTP ${res.statusCode}`);
971
+ resolve();
972
+ });
973
+
974
+ req.on('error', (err) => {
975
+ this.logger.warn('SNS', `HTTP delivery failed [${endpoint}]: ${err.message}`);
976
+ resolve(); // não propagar erro
977
+ });
978
+
979
+ req.setTimeout(5000, () => {
980
+ req.destroy();
981
+ this.logger.warn('SNS', `HTTP delivery timeout [${endpoint}]`);
982
+ resolve();
983
+ });
984
+
985
+ req.write(body);
986
+ req.end();
987
+ } catch (err) {
988
+ this.logger.warn('SNS', `HTTP delivery error: ${err.message}`);
989
+ resolve();
990
+ }
991
+ });
992
+ }
993
+
994
+ // ─────────────────────────────────────────────
995
+ // Filter Policy
996
+ // ─────────────────────────────────────────────
997
+
998
+ /**
999
+ * Verifica se os atributos/body da mensagem correspondem ao FilterPolicy
1000
+ * @param {Object} filterPolicy
1001
+ * @param {Object} data - MessageAttributes ou body da mensagem
1002
+ * @param {string} scope - 'MessageAttributes' | 'MessageBody'
1003
+ * @returns {boolean}
1004
+ * @private
1005
+ */
1006
+ _matchesFilterPolicy(filterPolicy, data, scope = 'MessageAttributes') {
1007
+ for (const [key, conditions] of Object.entries(filterPolicy)) {
1008
+ let value;
1009
+
1010
+ if (scope === 'MessageBody') {
1011
+ value = this._getNestedValue(data, key);
1012
+ } else {
1013
+ const attr = data[key];
1014
+ if (!attr) return false;
1015
+ value = attr.Value || attr.StringValue || attr.NumberValue;
1016
+ }
1017
+
1018
+ if (value === undefined || value === null) return false;
1019
+
1020
+ if (!Array.isArray(conditions)) continue;
1021
+
1022
+ const matched = conditions.some(cond => {
1023
+ // Valor direto (string)
1024
+ if (typeof cond === 'string') return String(value) === cond;
1025
+
1026
+ // Valor nulo
1027
+ if (cond === null) return value === null || value === undefined;
1028
+
1029
+ // Objeto de condição
1030
+ if (typeof cond === 'object') {
1031
+ if ('prefix' in cond) return String(value).startsWith(cond.prefix);
1032
+ if ('suffix' in cond) return String(value).endsWith(cond.suffix);
1033
+ if ('numeric' in cond) return this._checkNumeric(parseFloat(value), cond.numeric);
1034
+ if ('anything-but' in cond) return !cond['anything-but'].includes(value);
1035
+ if ('exists' in cond) return cond.exists ? (value !== undefined) : (value === undefined);
1036
+ }
1037
+
1038
+ return false;
1039
+ });
1040
+
1041
+ if (!matched) return false;
1042
+ }
1043
+
1044
+ return true;
1045
+ }
1046
+
1047
+ /**
1048
+ * Obtém valor aninhado em objeto usando notação de ponto
1049
+ * @param {Object} obj
1050
+ * @param {string} key
1051
+ * @returns {*}
1052
+ * @private
1053
+ */
1054
+ _getNestedValue(obj, key) {
1055
+ return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj);
1056
+ }
1057
+
1058
+ /**
1059
+ * Verifica condição numérica do FilterPolicy
1060
+ * @param {number} value
1061
+ * @param {Array} conditions - ex: ['>', 5, '<=', 10]
1062
+ * @returns {boolean}
1063
+ * @private
1064
+ */
1065
+ _checkNumeric(value, conditions) {
1066
+ if (isNaN(value)) return false;
1067
+
1068
+ for (let i = 0; i < conditions.length; i += 2) {
1069
+ const op = conditions[i];
1070
+ const threshold = conditions[i + 1];
1071
+ if (op === '=' && value !== threshold) return false;
1072
+ if (op === '>' && value <= threshold) return false;
1073
+ if (op === '>=' && value < threshold) return false;
1074
+ if (op === '<' && value >= threshold) return false;
1075
+ if (op === '<=' && value > threshold) return false;
1076
+ }
1077
+
1078
+ return true;
1079
+ }
1080
+
1081
+ // ─────────────────────────────────────────────
1082
+ // Tags
1083
+ // ─────────────────────────────────────────────
1084
+
1085
+ /**
1086
+ * TagResource — adiciona tags a um recurso SNS
1087
+ * @param {Object} params
1088
+ * @param {string} params.ResourceArn
1089
+ * @param {Array} params.Tags - [{Key, Value}]
1090
+ * @returns {Promise<void>}
1091
+ */
1092
+ async tagResource(params) {
1093
+ const { ResourceArn, Tags = [] } = params;
1094
+
1095
+ if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1096
+ throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1097
+ }
1098
+
1099
+ const existing = this.tags.get(ResourceArn) || [];
1100
+ const merged = [...existing];
1101
+
1102
+ for (const tag of Tags) {
1103
+ const idx = merged.findIndex(t => t.Key === tag.Key);
1104
+ if (idx >= 0) merged[idx] = tag;
1105
+ else merged.push(tag);
1106
+ }
1107
+
1108
+ this.tags.set(ResourceArn, merged);
1109
+ await this._save();
1110
+
1111
+ this.logger.debug('SNS', `Tagged resource ${ResourceArn}: ${Tags.map(t => `${t.Key}=${t.Value}`).join(', ')}`);
1112
+ }
1113
+
1114
+ /**
1115
+ * UntagResource — remove tags de um recurso SNS
1116
+ * @param {Object} params
1117
+ * @param {string} params.ResourceArn
1118
+ * @param {Array} params.TagKeys
1119
+ * @returns {Promise<void>}
1120
+ */
1121
+ async untagResource(params) {
1122
+ const { ResourceArn, TagKeys = [] } = params;
1123
+
1124
+ if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1125
+ throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1126
+ }
1127
+
1128
+ const existing = this.tags.get(ResourceArn) || [];
1129
+ this.tags.set(ResourceArn, existing.filter(t => !TagKeys.includes(t.Key)));
1130
+ await this._save();
1131
+
1132
+ this.logger.debug('SNS', `Untagged resource ${ResourceArn}: ${TagKeys.join(', ')}`);
1133
+ }
1134
+
1135
+ /**
1136
+ * ListTagsForResource — lista tags de um recurso
1137
+ * @param {Object} params
1138
+ * @param {string} params.ResourceArn
1139
+ * @returns {{Tags: Array}}
1140
+ */
1141
+ listTagsForResource(params) {
1142
+ const { ResourceArn } = params;
1143
+
1144
+ if (!this.topics.has(ResourceArn) && !this.platformApps.has(ResourceArn)) {
1145
+ throw this._error('ResourceNotFoundException', `Resource not found: ${ResourceArn}`);
1146
+ }
1147
+
1148
+ return { Tags: this.tags.get(ResourceArn) || [] };
1149
+ }
1150
+
1151
+ // ─────────────────────────────────────────────
1152
+ // Platform Applications (Push Notifications)
1153
+ // ─────────────────────────────────────────────
1154
+
1155
+ /**
1156
+ * CreatePlatformApplication — cria aplicação de plataforma (APNs/FCM/GCM)
1157
+ * @param {Object} params
1158
+ * @returns {Promise<{PlatformApplicationArn: string}>}
1159
+ */
1160
+ async createPlatformApplication(params) {
1161
+ const { Name, Platform, Attributes = {} } = params;
1162
+
1163
+ if (!Name || !Platform) {
1164
+ throw this._error('InvalidParameter', 'Name and Platform are required');
1165
+ }
1166
+
1167
+ const validPlatforms = ['ADM', 'APNS', 'APNS_SANDBOX', 'GCM', 'FCM', 'BAIDU', 'WNS', 'MPNS'];
1168
+ if (!validPlatforms.includes(Platform)) {
1169
+ throw this._error('InvalidParameter', `Invalid platform: ${Platform}`);
1170
+ }
1171
+
1172
+ const arn = `arn:aws:sns:${this.region}:${this.accountId}:app/${Platform}/${Name}`;
1173
+
1174
+ const app = {
1175
+ PlatformApplicationArn: arn,
1176
+ Name,
1177
+ Platform,
1178
+ Attributes: {
1179
+ Enabled: 'true',
1180
+ SuccessFeedbackRoleArn: '',
1181
+ FailureFeedbackRoleArn: '',
1182
+ ...Attributes
1183
+ },
1184
+ CreatedAt: new Date().toISOString()
1185
+ };
1186
+
1187
+ this.platformApps.set(arn, app);
1188
+ await this._save();
1189
+
1190
+ this.logger.info('SNS', `Created platform application: ${Name} (${Platform})`);
1191
+ return { PlatformApplicationArn: arn };
1192
+ }
1193
+
1194
+ /**
1195
+ * DeletePlatformApplication — remove uma aplicação de plataforma
1196
+ * @param {Object} params
1197
+ * @param {string} params.PlatformApplicationArn
1198
+ * @returns {Promise<void>}
1199
+ */
1200
+ async deletePlatformApplication(params) {
1201
+ const { PlatformApplicationArn } = params;
1202
+
1203
+ if (!this.platformApps.has(PlatformApplicationArn)) {
1204
+ throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1205
+ }
1206
+
1207
+ // Remover endpoints associados
1208
+ for (const [arn, ep] of this.platformEndpoints.entries()) {
1209
+ if (ep.PlatformApplicationArn === PlatformApplicationArn) {
1210
+ this.platformEndpoints.delete(arn);
1211
+ }
1212
+ }
1213
+
1214
+ this.platformApps.delete(PlatformApplicationArn);
1215
+ await this._save();
1216
+
1217
+ this.logger.info('SNS', `Deleted platform application: ${PlatformApplicationArn}`);
1218
+ }
1219
+
1220
+ /**
1221
+ * ListPlatformApplications — lista aplicações de plataforma
1222
+ * @param {Object} [params]
1223
+ * @returns {{PlatformApplications: Array, NextToken?: string}}
1224
+ */
1225
+ listPlatformApplications(params = {}) {
1226
+ const apps = Array.from(this.platformApps.values());
1227
+ const pageSize = 100;
1228
+ let startIdx = 0;
1229
+
1230
+ if (params.NextToken) {
1231
+ try { startIdx = parseInt(Buffer.from(params.NextToken, 'base64').toString('utf8'), 10) || 0; }
1232
+ catch { startIdx = 0; }
1233
+ }
1234
+
1235
+ const page = apps.slice(startIdx, startIdx + pageSize);
1236
+ const nextIdx = startIdx + pageSize;
1237
+ const token = nextIdx < apps.length ? Buffer.from(String(nextIdx)).toString('base64') : undefined;
1238
+
1239
+ return {
1240
+ PlatformApplications: page.map(a => ({
1241
+ PlatformApplicationArn: a.PlatformApplicationArn,
1242
+ Attributes: a.Attributes
1243
+ })),
1244
+ ...(token && { NextToken: token })
1245
+ };
1246
+ }
1247
+
1248
+ /**
1249
+ * CreatePlatformEndpoint — cria endpoint de dispositivo
1250
+ * @param {Object} params
1251
+ * @returns {Promise<{EndpointArn: string}>}
1252
+ */
1253
+ async createPlatformEndpoint(params) {
1254
+ const { PlatformApplicationArn, Token, CustomUserData = '', Attributes = {} } = params;
1255
+
1256
+ if (!this.platformApps.has(PlatformApplicationArn)) {
1257
+ throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1258
+ }
1259
+
1260
+ if (!Token) throw this._error('InvalidParameter', 'Token is required');
1261
+
1262
+ const endpointId = crypto.randomUUID();
1263
+ const app = this.platformApps.get(PlatformApplicationArn);
1264
+ const arn = `arn:aws:sns:${this.region}:${this.accountId}:endpoint/${app.Platform}/${app.Name}/${endpointId}`;
1265
+
1266
+ const endpoint = {
1267
+ EndpointArn: arn,
1268
+ PlatformApplicationArn,
1269
+ Token,
1270
+ CustomUserData,
1271
+ Attributes: {
1272
+ Enabled: 'true',
1273
+ Token,
1274
+ CustomUserData,
1275
+ ...Attributes
1276
+ },
1277
+ CreatedAt: new Date().toISOString()
1278
+ };
1279
+
1280
+ this.platformEndpoints.set(arn, endpoint);
1281
+ await this._save();
1282
+
1283
+ this.logger.info('SNS', `Created platform endpoint: ${arn}`);
1284
+ return { EndpointArn: arn };
1285
+ }
1286
+
1287
+ /**
1288
+ * DeleteEndpoint — remove endpoint de dispositivo
1289
+ * @param {Object} params
1290
+ * @param {string} params.EndpointArn
1291
+ * @returns {Promise<void>}
1292
+ */
1293
+ async deleteEndpoint(params) {
1294
+ const { EndpointArn } = params;
1295
+
1296
+ if (!this.platformEndpoints.has(EndpointArn)) {
1297
+ throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1298
+ }
1299
+
1300
+ this.platformEndpoints.delete(EndpointArn);
1301
+ await this._save();
1302
+
1303
+ this.logger.info('SNS', `Deleted endpoint: ${EndpointArn}`);
1304
+ }
1305
+
1306
+ /**
1307
+ * GetEndpointAttributes — retorna atributos de um endpoint
1308
+ * @param {Object} params
1309
+ * @param {string} params.EndpointArn
1310
+ * @returns {{Attributes: Object}}
1311
+ */
1312
+ getEndpointAttributes(params) {
1313
+ const { EndpointArn } = params;
1314
+ const ep = this.platformEndpoints.get(EndpointArn);
1315
+ if (!ep) throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1316
+ return { Attributes: ep.Attributes };
1317
+ }
1318
+
1319
+ /**
1320
+ * SetEndpointAttributes — atualiza atributos de um endpoint
1321
+ * @param {Object} params
1322
+ * @returns {Promise<void>}
1323
+ */
1324
+ async setEndpointAttributes(params) {
1325
+ const { EndpointArn, Attributes = {} } = params;
1326
+ const ep = this.platformEndpoints.get(EndpointArn);
1327
+ if (!ep) throw this._error('NotFound', `Endpoint not found: ${EndpointArn}`);
1328
+
1329
+ Object.assign(ep.Attributes, Attributes);
1330
+ await this._save();
1331
+ }
1332
+
1333
+ /**
1334
+ * ListEndpointsByPlatformApplication — lista endpoints de uma plataforma
1335
+ * @param {Object} params
1336
+ * @returns {{Endpoints: Array, NextToken?: string}}
1337
+ */
1338
+ listEndpointsByPlatformApplication(params) {
1339
+ const { PlatformApplicationArn, NextToken } = params;
1340
+
1341
+ if (!this.platformApps.has(PlatformApplicationArn)) {
1342
+ throw this._error('NotFound', `Platform application not found: ${PlatformApplicationArn}`);
1343
+ }
1344
+
1345
+ const eps = Array.from(this.platformEndpoints.values())
1346
+ .filter(e => e.PlatformApplicationArn === PlatformApplicationArn);
1347
+ const pageSize = 100;
1348
+ let startIdx = 0;
1349
+
1350
+ if (NextToken) {
1351
+ try { startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0; }
1352
+ catch { startIdx = 0; }
1353
+ }
1354
+
1355
+ const page = eps.slice(startIdx, startIdx + pageSize);
1356
+ const nextIdx = startIdx + pageSize;
1357
+ const token = nextIdx < eps.length ? Buffer.from(String(nextIdx)).toString('base64') : undefined;
1358
+
1359
+ return {
1360
+ Endpoints: page.map(e => ({ EndpointArn: e.EndpointArn, Attributes: e.Attributes })),
1361
+ ...(token && { NextToken: token })
1362
+ };
1363
+ }
1364
+
1365
+ // ─────────────────────────────────────────────
1366
+ // SMS Opt-out
1367
+ // ─────────────────────────────────────────────
1368
+
1369
+ /**
1370
+ * CheckIfPhoneNumberIsOptedOut — verifica se número optou por sair
1371
+ * @param {Object} params
1372
+ * @param {string} params.phoneNumber
1373
+ * @returns {{isOptedOut: boolean}}
1374
+ */
1375
+ checkIfPhoneNumberIsOptedOut(params) {
1376
+ return { isOptedOut: this.optedOutNumbers.has(params.phoneNumber) };
1377
+ }
1378
+
1379
+ /**
1380
+ * ListPhoneNumbersOptedOut — lista números que optaram por sair
1381
+ * @returns {{phoneNumbers: Array}}
1382
+ */
1383
+ listPhoneNumbersOptedOut() {
1384
+ return { phoneNumbers: Array.from(this.optedOutNumbers) };
1385
+ }
1386
+
1387
+ /**
1388
+ * OptInPhoneNumber — reincluir número que optou por sair
1389
+ * @param {Object} params
1390
+ * @param {string} params.phoneNumber
1391
+ * @returns {Promise<void>}
1392
+ */
1393
+ async optInPhoneNumber(params) {
1394
+ this.optedOutNumbers.delete(params.phoneNumber);
1395
+ this.logger.info('SNS', `Opted-in: ${params.phoneNumber}`);
1396
+ }
1397
+
1398
+ // ─────────────────────────────────────────────
1399
+ // SMS Attributes
1400
+ // ─────────────────────────────────────────────
1401
+
1402
+ /**
1403
+ * SetSMSAttributes — configura atributos SMS globais
1404
+ * @param {Object} params
1405
+ * @param {Object} params.attributes
1406
+ * @returns {Promise<void>}
1407
+ */
1408
+ async setSmsAttributes(params) {
1409
+ Object.assign(this.smsAttributes, params.attributes || {});
1410
+ this.logger.debug('SNS', `SMS attributes updated`);
1411
+ }
1412
+
1413
+ /**
1414
+ * GetSMSAttributes — retorna atributos SMS globais
1415
+ * @param {Object} [params]
1416
+ * @returns {{attributes: Object}}
1417
+ */
1418
+ getSmsAttributes(params = {}) {
1419
+ const { attributes = [] } = params;
1420
+
1421
+ if (!attributes.length) return { attributes: { ...this.smsAttributes } };
1422
+
1423
+ const filtered = {};
1424
+ for (const key of attributes) {
1425
+ if (key in this.smsAttributes) filtered[key] = this.smsAttributes[key];
1426
+ }
1427
+
1428
+ return { attributes: filtered };
1429
+ }
1430
+
1431
+ // ─────────────────────────────────────────────
1432
+ // Publish Log (Admin)
1433
+ // ─────────────────────────────────────────────
1434
+
1435
+ /**
1436
+ * Registra publicação no log interno
1437
+ * @param {string|null} topicArn
1438
+ * @param {string} messageId
1439
+ * @param {string} message
1440
+ * @param {string|null} [phoneNumber]
1441
+ * @param {Object} [messageAttributes]
1442
+ * @private
1443
+ */
1444
+ _logPublish(topicArn, messageId, message, phoneNumber = null, messageAttributes = {}) {
1445
+ this.publishLog.unshift({
1446
+ messageId,
1447
+ topicArn,
1448
+ phoneNumber,
1449
+ message: String(message).substring(0, 500),
1450
+ messageAttributes,
1451
+ timestamp: new Date().toISOString()
1452
+ });
1453
+
1454
+ // Manter apenas as últimas 500
1455
+ if (this.publishLog.length > 500) this.publishLog.pop();
1456
+ }
1457
+
1458
+ // ─────────────────────────────────────────────
1459
+ // Reset
1460
+ // ─────────────────────────────────────────────
1461
+
1462
+ /**
1463
+ * Limpa todos os dados do simulador
1464
+ * @returns {Promise<void>}
1465
+ */
1466
+ async reset() {
1467
+ this.topics.clear();
1468
+ this.subscriptions.clear();
1469
+ this.pendingTokens.clear();
1470
+ this.platformApps.clear();
1471
+ this.platformEndpoints.clear();
1472
+ this.tags.clear();
1473
+ this.publishLog = [];
1474
+ this.optedOutNumbers.clear();
1475
+ this.smsAttributes = { DefaultSMSType: 'Transactional', MonthlySpendLimit: '1' };
1476
+
1477
+ await this._save();
1478
+ this.logger.info('SNS', 'Data reset complete');
1479
+ }
1480
+ }
1481
+
1482
+ module.exports = { SNSSimulator };