@gugananuvem/aws-local-simulator 1.0.14 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +594 -481
- package/bin/aws-local-simulator.js +63 -63
- package/package.json +11 -10
- package/src/config/config-loader.js +114 -114
- package/src/config/default-config.js +68 -68
- package/src/config/env-loader.js +68 -68
- package/src/index.js +146 -146
- package/src/index.mjs +123 -123
- package/src/server.js +227 -227
- package/src/services/apigateway/index.js +73 -73
- package/src/services/apigateway/server.js +507 -507
- package/src/services/apigateway/simulator.js +1261 -1261
- package/src/services/athena/index.js +75 -75
- package/src/services/athena/server.js +101 -101
- package/src/services/athena/simulador.js +998 -998
- package/src/services/athena/simulator.js +346 -346
- package/src/services/cloudformation/index.js +106 -106
- package/src/services/cloudformation/server.js +417 -417
- package/src/services/cloudformation/simulador.js +1045 -1045
- package/src/services/cloudtrail/index.js +84 -84
- package/src/services/cloudtrail/server.js +235 -235
- package/src/services/cloudtrail/simulador.js +719 -719
- package/src/services/cloudwatch/index.js +84 -84
- package/src/services/cloudwatch/server.js +366 -366
- package/src/services/cloudwatch/simulador.js +1173 -1173
- package/src/services/cognito/index.js +79 -70
- package/src/services/cognito/server.js +301 -279
- package/src/services/cognito/simulator.js +1655 -1119
- package/src/services/config/index.js +96 -96
- package/src/services/config/server.js +215 -215
- package/src/services/config/simulador.js +1260 -1260
- package/src/services/dynamodb/index.js +74 -74
- package/src/services/dynamodb/server.js +125 -123
- package/src/services/dynamodb/simulator.js +630 -630
- package/src/services/ecs/index.js +65 -65
- package/src/services/ecs/server.js +235 -233
- package/src/services/ecs/simulator.js +844 -844
- package/src/services/eventbridge/index.js +89 -89
- package/src/services/eventbridge/server.js +209 -209
- package/src/services/eventbridge/simulator.js +684 -684
- package/src/services/index.js +45 -45
- package/src/services/kms/index.js +75 -75
- package/src/services/kms/server.js +67 -67
- package/src/services/kms/simulator.js +324 -324
- package/src/services/lambda/handler-loader.js +183 -183
- package/src/services/lambda/index.js +78 -78
- package/src/services/lambda/route-registry.js +274 -274
- package/src/services/lambda/server.js +145 -145
- package/src/services/lambda/simulator.js +199 -182
- package/src/services/parameter-store/index.js +80 -80
- package/src/services/parameter-store/server.js +50 -50
- package/src/services/parameter-store/simulator.js +201 -201
- package/src/services/s3/index.js +73 -73
- package/src/services/s3/server.js +329 -245
- package/src/services/s3/simulator.js +565 -496
- package/src/services/secret-manager/index.js +80 -80
- package/src/services/secret-manager/server.js +50 -50
- package/src/services/secret-manager/simulator.js +171 -171
- package/src/services/sns/index.js +89 -89
- package/src/services/sns/server.js +580 -580
- package/src/services/sns/simulator.js +1482 -1482
- package/src/services/sqs/index.js +93 -93
- package/src/services/sqs/server.js +349 -347
- package/src/services/sqs/simulator.js +441 -441
- package/src/services/sts/index.js +37 -37
- package/src/services/sts/server.js +144 -142
- package/src/services/sts/simulator.js +69 -69
- package/src/services/xray/index.js +83 -83
- package/src/services/xray/server.js +308 -308
- package/src/services/xray/simulador.js +994 -994
- package/src/template/aws-config-template.js +87 -87
- package/src/template/aws-config-template.mjs +90 -90
- package/src/template/config-template.json +203 -203
- package/src/utils/aws-config.js +91 -91
- package/src/utils/cloudtrail-audit.js +129 -129
- package/src/utils/local-store.js +83 -83
- package/src/utils/logger.js +59 -59
|
@@ -1,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 };
|