serverless-plugin-module-registry 1.0.12 → 1.0.13
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/dist/index.d.ts +12 -0
- package/dist/index.js +150 -5
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/internal-infrastructure/__tests__/custom-stream-enabler.test.ts +214 -0
- package/src/internal-infrastructure/__tests__/eventbridge-config.test.ts +135 -0
- package/src/internal-infrastructure/__tests__/role-updater.test.ts +781 -0
- package/src/internal-infrastructure/__tests__/stream-processor.test.ts +994 -0
- package/src/internal-infrastructure/handlers/custom-stream-enabler.ts +163 -0
- package/src/internal-infrastructure/handlers/role-updater.ts +402 -0
- package/src/internal-infrastructure/handlers/shared/logger.ts +48 -0
- package/src/internal-infrastructure/handlers/shared/types.ts +31 -0
- package/src/internal-infrastructure/handlers/stream-processor.ts +371 -0
- package/src/internal-infrastructure/resources/cloudwatch.yml +36 -0
- package/src/internal-infrastructure/resources/custom-stream-enabler.yml +106 -0
- package/src/internal-infrastructure/resources/dynamodb.yml +74 -0
- package/src/internal-infrastructure/resources/eventbridge.yml +26 -0
- package/src/internal-infrastructure/resources/iam.yml +110 -0
- package/src/internal-infrastructure/resources/outputs.yml +13 -0
- package/src/internal-infrastructure/resources/sqs.yml +57 -0
- package/src/internal-infrastructure/serverless.yml +69 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role Updater Handler Tests
|
|
3
|
+
* Tests for the SQS-triggered Lambda that updates IAM role tags and policies
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Mock AWS SDK before imports
|
|
7
|
+
const mockDynamoSend = jest.fn()
|
|
8
|
+
const mockIAMSend = jest.fn()
|
|
9
|
+
const mockCloudWatchSend = jest.fn()
|
|
10
|
+
|
|
11
|
+
jest.mock('@aws-sdk/client-dynamodb', () => ({
|
|
12
|
+
DynamoDBClient: jest.fn(() => ({
|
|
13
|
+
send: mockDynamoSend,
|
|
14
|
+
})),
|
|
15
|
+
QueryCommand: jest.fn((input) => input),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('@aws-sdk/client-iam', () => ({
|
|
19
|
+
IAMClient: jest.fn(() => ({
|
|
20
|
+
send: mockIAMSend,
|
|
21
|
+
})),
|
|
22
|
+
ListRolesCommand: jest.fn((input) => input),
|
|
23
|
+
GetRoleCommand: jest.fn((input) => input),
|
|
24
|
+
TagRoleCommand: jest.fn((input) => input),
|
|
25
|
+
ListAttachedRolePoliciesCommand: jest.fn((input) => input),
|
|
26
|
+
AttachRolePolicyCommand: jest.fn((input) => input),
|
|
27
|
+
ListPoliciesCommand: jest.fn((input) => input),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
jest.mock('@aws-sdk/client-cloudwatch', () => ({
|
|
31
|
+
CloudWatchClient: jest.fn(() => ({
|
|
32
|
+
send: mockCloudWatchSend,
|
|
33
|
+
})),
|
|
34
|
+
PutMetricDataCommand: jest.fn((input) => input),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
import { SQSEvent } from 'aws-lambda'
|
|
38
|
+
import { handler } from '../handlers/role-updater'
|
|
39
|
+
|
|
40
|
+
describe('Role Updater Handler', () => {
|
|
41
|
+
const originalEnv = process.env
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
jest.clearAllMocks()
|
|
45
|
+
mockDynamoSend.mockReset()
|
|
46
|
+
mockIAMSend.mockReset()
|
|
47
|
+
mockCloudWatchSend.mockReset()
|
|
48
|
+
process.env = {
|
|
49
|
+
...originalEnv,
|
|
50
|
+
DYNAMODB_TABLE_NAME: 'test-table',
|
|
51
|
+
POLICY_PREFIX: 'api',
|
|
52
|
+
AWS_ACCOUNT_ID: '123456789012',
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
process.env = originalEnv
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('DynamoDB feature query', () => {
|
|
61
|
+
it('should query all features for a role+module', async () => {
|
|
62
|
+
const event: SQSEvent = {
|
|
63
|
+
Records: [
|
|
64
|
+
{
|
|
65
|
+
messageId: '1',
|
|
66
|
+
receiptHandle: 'receipt-1',
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
roleName: 'authenticated',
|
|
69
|
+
moduleName: 'sign',
|
|
70
|
+
eventType: 'INSERT',
|
|
71
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
72
|
+
}),
|
|
73
|
+
attributes: {} as any,
|
|
74
|
+
messageAttributes: {},
|
|
75
|
+
md5OfBody: 'hash',
|
|
76
|
+
eventSource: 'aws:sqs',
|
|
77
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
78
|
+
awsRegion: 'us-east-1',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mock DynamoDB query response
|
|
84
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
85
|
+
Items: [
|
|
86
|
+
{
|
|
87
|
+
PK: { S: 'ROLE#authenticated' },
|
|
88
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
PK: { S: 'ROLE#authenticated' },
|
|
92
|
+
SK: { S: 'MODULE#sign#FEATURE#fkpvztwW' },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// Mock IAM ListRoles (no matching roles)
|
|
98
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
99
|
+
Roles: [],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Mock CloudWatch
|
|
103
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
104
|
+
|
|
105
|
+
await handler(event)
|
|
106
|
+
|
|
107
|
+
expect(mockDynamoSend).toHaveBeenCalledWith(
|
|
108
|
+
expect.objectContaining({
|
|
109
|
+
TableName: 'test-table',
|
|
110
|
+
KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
|
|
111
|
+
ExpressionAttributeValues: {
|
|
112
|
+
':pk': { S: 'ROLE#authenticated' },
|
|
113
|
+
':sk': { S: 'MODULE#sign#' },
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('Tag value building', () => {
|
|
121
|
+
it('should sort feature IDs alphabetically and join with colon', async () => {
|
|
122
|
+
const event: SQSEvent = {
|
|
123
|
+
Records: [
|
|
124
|
+
{
|
|
125
|
+
messageId: '1',
|
|
126
|
+
receiptHandle: 'receipt-1',
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
roleName: 'authenticated',
|
|
129
|
+
moduleName: 'sign',
|
|
130
|
+
eventType: 'INSERT',
|
|
131
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
132
|
+
}),
|
|
133
|
+
attributes: {} as any,
|
|
134
|
+
messageAttributes: {},
|
|
135
|
+
md5OfBody: 'hash',
|
|
136
|
+
eventSource: 'aws:sqs',
|
|
137
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
138
|
+
awsRegion: 'us-east-1',
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Mock DynamoDB with unsorted features
|
|
144
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
145
|
+
Items: [
|
|
146
|
+
{
|
|
147
|
+
PK: { S: 'ROLE#authenticated' },
|
|
148
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
PK: { S: 'ROLE#authenticated' },
|
|
152
|
+
SK: { S: 'MODULE#sign#FEATURE#fkpvztwW' },
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
PK: { S: 'ROLE#authenticated' },
|
|
156
|
+
SK: { S: 'MODULE#sign#FEATURE#7tmARMbS' },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Mock IAM ListRoles
|
|
162
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
163
|
+
Roles: [
|
|
164
|
+
{ RoleName: 'api-acme-authenticated' },
|
|
165
|
+
],
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Mock GetRole
|
|
169
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
170
|
+
Role: {
|
|
171
|
+
Tags: [],
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
// Mock TagRole
|
|
176
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
177
|
+
|
|
178
|
+
// Mock ListAttachedPolicies
|
|
179
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
180
|
+
AttachedPolicies: [],
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Mock ListPolicies
|
|
184
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
185
|
+
Policies: [
|
|
186
|
+
{
|
|
187
|
+
PolicyName: 'api-sign',
|
|
188
|
+
Arn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Mock AttachRolePolicy
|
|
194
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
195
|
+
|
|
196
|
+
// Mock CloudWatch
|
|
197
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
198
|
+
|
|
199
|
+
await handler(event)
|
|
200
|
+
|
|
201
|
+
// Check TagRole was called with sorted IDs
|
|
202
|
+
const tagRoleCall = mockIAMSend.mock.calls.find(
|
|
203
|
+
(call) => call[0].Tags
|
|
204
|
+
)
|
|
205
|
+
expect(tagRoleCall[0].Tags[0]).toEqual({
|
|
206
|
+
Key: 'signFeatures',
|
|
207
|
+
Value: '7tmARMbS:fkpvztwW:jSiJIIpP', // Alphabetically sorted
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('IAM role discovery', () => {
|
|
213
|
+
it('should find all tenant roles matching pattern', async () => {
|
|
214
|
+
const event: SQSEvent = {
|
|
215
|
+
Records: [
|
|
216
|
+
{
|
|
217
|
+
messageId: '1',
|
|
218
|
+
receiptHandle: 'receipt-1',
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
roleName: 'authenticated',
|
|
221
|
+
moduleName: 'sign',
|
|
222
|
+
eventType: 'INSERT',
|
|
223
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
224
|
+
}),
|
|
225
|
+
attributes: {} as any,
|
|
226
|
+
messageAttributes: {},
|
|
227
|
+
md5OfBody: 'hash',
|
|
228
|
+
eventSource: 'aws:sqs',
|
|
229
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
230
|
+
awsRegion: 'us-east-1',
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Mock DynamoDB
|
|
236
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
237
|
+
Items: [
|
|
238
|
+
{
|
|
239
|
+
PK: { S: 'ROLE#authenticated' },
|
|
240
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// Mock IAM ListRoles with pagination
|
|
246
|
+
mockIAMSend
|
|
247
|
+
.mockResolvedValueOnce({
|
|
248
|
+
Roles: [
|
|
249
|
+
{ RoleName: 'api-acme-authenticated' },
|
|
250
|
+
{ RoleName: 'api-globex-authenticated' },
|
|
251
|
+
{ RoleName: 'api-acme-unauthenticated' }, // Should not match
|
|
252
|
+
],
|
|
253
|
+
Marker: 'next-page',
|
|
254
|
+
})
|
|
255
|
+
.mockResolvedValueOnce({
|
|
256
|
+
Roles: [
|
|
257
|
+
{ RoleName: 'api-vault-authenticated' },
|
|
258
|
+
],
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
// Mock GetRole (3 times - for matching roles)
|
|
262
|
+
mockIAMSend.mockResolvedValue({ Role: { Tags: [] } })
|
|
263
|
+
|
|
264
|
+
// Mock TagRole
|
|
265
|
+
mockIAMSend.mockResolvedValue({})
|
|
266
|
+
|
|
267
|
+
// Mock ListAttachedPolicies
|
|
268
|
+
mockIAMSend.mockResolvedValue({ AttachedPolicies: [] })
|
|
269
|
+
|
|
270
|
+
// Mock ListPolicies
|
|
271
|
+
mockIAMSend.mockResolvedValue({
|
|
272
|
+
Policies: [
|
|
273
|
+
{
|
|
274
|
+
PolicyName: 'api-sign',
|
|
275
|
+
Arn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// Mock AttachRolePolicy
|
|
281
|
+
mockIAMSend.mockResolvedValue({})
|
|
282
|
+
|
|
283
|
+
// Mock CloudWatch
|
|
284
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
285
|
+
|
|
286
|
+
await handler(event)
|
|
287
|
+
|
|
288
|
+
// Should update 3 matching roles (authenticated suffix)
|
|
289
|
+
// Verify by checking TagRole calls (one per role)
|
|
290
|
+
const tagRoleCalls = mockIAMSend.mock.calls.filter(
|
|
291
|
+
(call) => call[0].Tags
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
expect(tagRoleCalls.length).toBe(3)
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
describe('Idempotent tag updates', () => {
|
|
299
|
+
it('should skip tag update if value unchanged', async () => {
|
|
300
|
+
const event: SQSEvent = {
|
|
301
|
+
Records: [
|
|
302
|
+
{
|
|
303
|
+
messageId: '1',
|
|
304
|
+
receiptHandle: 'receipt-1',
|
|
305
|
+
body: JSON.stringify({
|
|
306
|
+
roleName: 'authenticated',
|
|
307
|
+
moduleName: 'sign',
|
|
308
|
+
eventType: 'INSERT',
|
|
309
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
310
|
+
}),
|
|
311
|
+
attributes: {} as any,
|
|
312
|
+
messageAttributes: {},
|
|
313
|
+
md5OfBody: 'hash',
|
|
314
|
+
eventSource: 'aws:sqs',
|
|
315
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
316
|
+
awsRegion: 'us-east-1',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Mock DynamoDB
|
|
322
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
323
|
+
Items: [
|
|
324
|
+
{
|
|
325
|
+
PK: { S: 'ROLE#authenticated' },
|
|
326
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
327
|
+
},
|
|
328
|
+
],
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Mock IAM ListRoles
|
|
332
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
333
|
+
Roles: [{ RoleName: 'api-acme-authenticated' }],
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
// Mock GetRole - tag already exists with same value
|
|
337
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
338
|
+
Role: {
|
|
339
|
+
Tags: [
|
|
340
|
+
{ Key: 'signFeatures', Value: 'jSiJIIpP' },
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// Mock ListAttachedPolicies
|
|
346
|
+
mockIAMSend.mockResolvedValueOnce({ AttachedPolicies: [] })
|
|
347
|
+
|
|
348
|
+
// Mock ListPolicies
|
|
349
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
350
|
+
Policies: [
|
|
351
|
+
{
|
|
352
|
+
PolicyName: 'api-sign',
|
|
353
|
+
Arn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
// Mock AttachRolePolicy
|
|
359
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
360
|
+
|
|
361
|
+
// Mock CloudWatch
|
|
362
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
363
|
+
|
|
364
|
+
await handler(event)
|
|
365
|
+
|
|
366
|
+
// TagRole should NOT be called
|
|
367
|
+
const tagRoleCalls = mockIAMSend.mock.calls.filter(
|
|
368
|
+
(call) => call[0].Tags
|
|
369
|
+
)
|
|
370
|
+
expect(tagRoleCalls.length).toBe(0)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('should update tag if value changed', async () => {
|
|
374
|
+
const event: SQSEvent = {
|
|
375
|
+
Records: [
|
|
376
|
+
{
|
|
377
|
+
messageId: '1',
|
|
378
|
+
receiptHandle: 'receipt-1',
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
roleName: 'authenticated',
|
|
381
|
+
moduleName: 'sign',
|
|
382
|
+
eventType: 'INSERT',
|
|
383
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
384
|
+
}),
|
|
385
|
+
attributes: {} as any,
|
|
386
|
+
messageAttributes: {},
|
|
387
|
+
md5OfBody: 'hash',
|
|
388
|
+
eventSource: 'aws:sqs',
|
|
389
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
390
|
+
awsRegion: 'us-east-1',
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Mock DynamoDB
|
|
396
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
397
|
+
Items: [
|
|
398
|
+
{
|
|
399
|
+
PK: { S: 'ROLE#authenticated' },
|
|
400
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
PK: { S: 'ROLE#authenticated' },
|
|
404
|
+
SK: { S: 'MODULE#sign#FEATURE#fkpvztwW' },
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
// Mock IAM ListRoles
|
|
410
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
411
|
+
Roles: [{ RoleName: 'api-acme-authenticated' }],
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
// Mock GetRole - tag exists with different value
|
|
415
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
416
|
+
Role: {
|
|
417
|
+
Tags: [
|
|
418
|
+
{ Key: 'signFeatures', Value: 'jSiJIIpP' }, // Old value
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
// Mock TagRole
|
|
424
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
425
|
+
|
|
426
|
+
// Mock ListAttachedPolicies
|
|
427
|
+
mockIAMSend.mockResolvedValueOnce({ AttachedPolicies: [] })
|
|
428
|
+
|
|
429
|
+
// Mock ListPolicies
|
|
430
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
431
|
+
Policies: [
|
|
432
|
+
{
|
|
433
|
+
PolicyName: 'api-sign',
|
|
434
|
+
Arn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
// Mock AttachRolePolicy
|
|
440
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
441
|
+
|
|
442
|
+
// Mock CloudWatch
|
|
443
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
444
|
+
|
|
445
|
+
await handler(event)
|
|
446
|
+
|
|
447
|
+
// TagRole should be called with new value
|
|
448
|
+
const tagRoleCalls = mockIAMSend.mock.calls.filter(
|
|
449
|
+
(call) => call[0].Tags
|
|
450
|
+
)
|
|
451
|
+
expect(tagRoleCalls.length).toBe(1)
|
|
452
|
+
expect(tagRoleCalls[0][0].Tags[0].Value).toBe('fkpvztwW:jSiJIIpP')
|
|
453
|
+
})
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
describe('Idempotent policy attachment', () => {
|
|
457
|
+
it('should skip policy attachment if already attached', async () => {
|
|
458
|
+
const event: SQSEvent = {
|
|
459
|
+
Records: [
|
|
460
|
+
{
|
|
461
|
+
messageId: '1',
|
|
462
|
+
receiptHandle: 'receipt-1',
|
|
463
|
+
body: JSON.stringify({
|
|
464
|
+
roleName: 'authenticated',
|
|
465
|
+
moduleName: 'sign',
|
|
466
|
+
eventType: 'INSERT',
|
|
467
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
468
|
+
}),
|
|
469
|
+
attributes: {} as any,
|
|
470
|
+
messageAttributes: {},
|
|
471
|
+
md5OfBody: 'hash',
|
|
472
|
+
eventSource: 'aws:sqs',
|
|
473
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
474
|
+
awsRegion: 'us-east-1',
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Mock DynamoDB
|
|
480
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
481
|
+
Items: [
|
|
482
|
+
{
|
|
483
|
+
PK: { S: 'ROLE#authenticated' },
|
|
484
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
485
|
+
},
|
|
486
|
+
],
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// Mock IAM ListRoles
|
|
490
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
491
|
+
Roles: [{ RoleName: 'api-acme-authenticated' }],
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
// Mock GetRole
|
|
495
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
496
|
+
Role: { Tags: [] },
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// Mock TagRole
|
|
500
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
501
|
+
|
|
502
|
+
// Mock ListAttachedPolicies - policy already attached
|
|
503
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
504
|
+
AttachedPolicies: [
|
|
505
|
+
{
|
|
506
|
+
PolicyName: 'api-sign',
|
|
507
|
+
PolicyArn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
})
|
|
511
|
+
|
|
512
|
+
// Mock CloudWatch
|
|
513
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
514
|
+
|
|
515
|
+
await handler(event)
|
|
516
|
+
|
|
517
|
+
// AttachRolePolicy should NOT be called
|
|
518
|
+
const attachPolicyCalls = mockIAMSend.mock.calls.filter(
|
|
519
|
+
(call) => call[0].PolicyArn && call[0].RoleName
|
|
520
|
+
)
|
|
521
|
+
expect(attachPolicyCalls.length).toBe(0)
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('should attach policy if not attached', async () => {
|
|
525
|
+
const event: SQSEvent = {
|
|
526
|
+
Records: [
|
|
527
|
+
{
|
|
528
|
+
messageId: '1',
|
|
529
|
+
receiptHandle: 'receipt-1',
|
|
530
|
+
body: JSON.stringify({
|
|
531
|
+
roleName: 'authenticated',
|
|
532
|
+
moduleName: 'sign',
|
|
533
|
+
eventType: 'INSERT',
|
|
534
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
535
|
+
}),
|
|
536
|
+
attributes: {} as any,
|
|
537
|
+
messageAttributes: {},
|
|
538
|
+
md5OfBody: 'hash',
|
|
539
|
+
eventSource: 'aws:sqs',
|
|
540
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
541
|
+
awsRegion: 'us-east-1',
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Mock DynamoDB
|
|
547
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
548
|
+
Items: [
|
|
549
|
+
{
|
|
550
|
+
PK: { S: 'ROLE#authenticated' },
|
|
551
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// Mock IAM ListRoles
|
|
557
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
558
|
+
Roles: [{ RoleName: 'api-acme-authenticated' }],
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Mock GetRole
|
|
562
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
563
|
+
Role: { Tags: [] },
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
// Mock TagRole
|
|
567
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
568
|
+
|
|
569
|
+
// Mock ListAttachedPolicies - no policies attached
|
|
570
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
571
|
+
AttachedPolicies: [],
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
// Mock ListPolicies
|
|
575
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
576
|
+
Policies: [
|
|
577
|
+
{
|
|
578
|
+
PolicyName: 'api-sign',
|
|
579
|
+
Arn: 'arn:aws:iam::123456789012:policy/api-sign',
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// Mock AttachRolePolicy
|
|
585
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
586
|
+
|
|
587
|
+
// Mock CloudWatch
|
|
588
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
589
|
+
|
|
590
|
+
await handler(event)
|
|
591
|
+
|
|
592
|
+
// AttachRolePolicy should be called
|
|
593
|
+
const attachPolicyCalls = mockIAMSend.mock.calls.filter(
|
|
594
|
+
(call) => call[0].PolicyArn && call[0].RoleName
|
|
595
|
+
)
|
|
596
|
+
expect(attachPolicyCalls.length).toBe(1)
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
describe('Error handling', () => {
|
|
601
|
+
it('should throw error if DYNAMODB_TABLE_NAME not set', async () => {
|
|
602
|
+
delete process.env.DYNAMODB_TABLE_NAME
|
|
603
|
+
|
|
604
|
+
const event: SQSEvent = {
|
|
605
|
+
Records: [],
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
await expect(handler(event)).rejects.toThrow(
|
|
609
|
+
'DYNAMODB_TABLE_NAME environment variable not set'
|
|
610
|
+
)
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
it('should continue processing other messages if one fails', async () => {
|
|
614
|
+
const event: SQSEvent = {
|
|
615
|
+
Records: [
|
|
616
|
+
{
|
|
617
|
+
messageId: '1',
|
|
618
|
+
receiptHandle: 'receipt-1',
|
|
619
|
+
body: JSON.stringify({
|
|
620
|
+
roleName: 'authenticated',
|
|
621
|
+
moduleName: 'sign',
|
|
622
|
+
eventType: 'INSERT',
|
|
623
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
624
|
+
}),
|
|
625
|
+
attributes: {} as any,
|
|
626
|
+
messageAttributes: {},
|
|
627
|
+
md5OfBody: 'hash',
|
|
628
|
+
eventSource: 'aws:sqs',
|
|
629
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
630
|
+
awsRegion: 'us-east-1',
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
messageId: '2',
|
|
634
|
+
receiptHandle: 'receipt-2',
|
|
635
|
+
body: JSON.stringify({
|
|
636
|
+
roleName: 'unauthenticated',
|
|
637
|
+
moduleName: 'workflow',
|
|
638
|
+
eventType: 'INSERT',
|
|
639
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
640
|
+
}),
|
|
641
|
+
attributes: {} as any,
|
|
642
|
+
messageAttributes: {},
|
|
643
|
+
md5OfBody: 'hash',
|
|
644
|
+
eventSource: 'aws:sqs',
|
|
645
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
646
|
+
awsRegion: 'us-east-1',
|
|
647
|
+
},
|
|
648
|
+
],
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// First message fails
|
|
652
|
+
mockDynamoSend.mockRejectedValueOnce(new Error('DynamoDB error'))
|
|
653
|
+
|
|
654
|
+
// Second message succeeds
|
|
655
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
656
|
+
Items: [],
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
// Mock IAM for second message
|
|
660
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
661
|
+
Roles: [],
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
// Mock CloudWatch
|
|
665
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
666
|
+
|
|
667
|
+
await handler(event)
|
|
668
|
+
|
|
669
|
+
// Should process second message despite first failing
|
|
670
|
+
expect(mockDynamoSend).toHaveBeenCalledTimes(2)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('should handle policy not found gracefully', async () => {
|
|
674
|
+
const event: SQSEvent = {
|
|
675
|
+
Records: [
|
|
676
|
+
{
|
|
677
|
+
messageId: '1',
|
|
678
|
+
receiptHandle: 'receipt-1',
|
|
679
|
+
body: JSON.stringify({
|
|
680
|
+
roleName: 'authenticated',
|
|
681
|
+
moduleName: 'sign',
|
|
682
|
+
eventType: 'INSERT',
|
|
683
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
684
|
+
}),
|
|
685
|
+
attributes: {} as any,
|
|
686
|
+
messageAttributes: {},
|
|
687
|
+
md5OfBody: 'hash',
|
|
688
|
+
eventSource: 'aws:sqs',
|
|
689
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
690
|
+
awsRegion: 'us-east-1',
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Mock DynamoDB
|
|
696
|
+
mockDynamoSend.mockResolvedValueOnce({
|
|
697
|
+
Items: [
|
|
698
|
+
{
|
|
699
|
+
PK: { S: 'ROLE#authenticated' },
|
|
700
|
+
SK: { S: 'MODULE#sign#FEATURE#jSiJIIpP' },
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
// Mock IAM ListRoles
|
|
706
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
707
|
+
Roles: [{ RoleName: 'api-acme-authenticated' }],
|
|
708
|
+
})
|
|
709
|
+
|
|
710
|
+
// Mock GetRole
|
|
711
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
712
|
+
Role: { Tags: [] },
|
|
713
|
+
})
|
|
714
|
+
|
|
715
|
+
// Mock TagRole
|
|
716
|
+
mockIAMSend.mockResolvedValueOnce({})
|
|
717
|
+
|
|
718
|
+
// Mock ListAttachedPolicies
|
|
719
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
720
|
+
AttachedPolicies: [],
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
// Mock ListPolicies - no matching policy
|
|
724
|
+
mockIAMSend.mockResolvedValueOnce({
|
|
725
|
+
Policies: [],
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// Mock CloudWatch
|
|
729
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
730
|
+
|
|
731
|
+
await handler(event)
|
|
732
|
+
|
|
733
|
+
// Should not throw, should continue processing
|
|
734
|
+
expect(mockCloudWatchSend).toHaveBeenCalled()
|
|
735
|
+
})
|
|
736
|
+
})
|
|
737
|
+
|
|
738
|
+
describe('CloudWatch metrics', () => {
|
|
739
|
+
it('should publish metrics after processing', async () => {
|
|
740
|
+
const event: SQSEvent = {
|
|
741
|
+
Records: [
|
|
742
|
+
{
|
|
743
|
+
messageId: '1',
|
|
744
|
+
receiptHandle: 'receipt-1',
|
|
745
|
+
body: JSON.stringify({
|
|
746
|
+
roleName: 'authenticated',
|
|
747
|
+
moduleName: 'sign',
|
|
748
|
+
eventType: 'INSERT',
|
|
749
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
750
|
+
}),
|
|
751
|
+
attributes: {} as any,
|
|
752
|
+
messageAttributes: {},
|
|
753
|
+
md5OfBody: 'hash',
|
|
754
|
+
eventSource: 'aws:sqs',
|
|
755
|
+
eventSourceARN: 'arn:aws:sqs:us-east-1:123456789012:queue',
|
|
756
|
+
awsRegion: 'us-east-1',
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Mock all AWS calls
|
|
762
|
+
mockDynamoSend.mockResolvedValueOnce({ Items: [] })
|
|
763
|
+
mockIAMSend.mockResolvedValueOnce({ Roles: [] })
|
|
764
|
+
mockCloudWatchSend.mockResolvedValueOnce({})
|
|
765
|
+
|
|
766
|
+
await handler(event)
|
|
767
|
+
|
|
768
|
+
expect(mockCloudWatchSend).toHaveBeenCalledWith(
|
|
769
|
+
expect.objectContaining({
|
|
770
|
+
Namespace: 'ModuleRegistry/RoleAutomation',
|
|
771
|
+
MetricData: expect.arrayContaining([
|
|
772
|
+
expect.objectContaining({ MetricName: 'SQSMessagesProcessed' }),
|
|
773
|
+
expect.objectContaining({ MetricName: 'RoleUpdatesProcessed' }),
|
|
774
|
+
expect.objectContaining({ MetricName: 'Errors' }),
|
|
775
|
+
expect.objectContaining({ MetricName: 'ProcessingDuration' }),
|
|
776
|
+
]),
|
|
777
|
+
})
|
|
778
|
+
)
|
|
779
|
+
})
|
|
780
|
+
})
|
|
781
|
+
})
|