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.
@@ -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
+ })