Skip to content

Commit 6487e77

Browse files
authored
CCM-13146 Component tests for handling ttl (#145)
1 parent e03e1b9 commit 6487e77

File tree

12 files changed

+2017
-120
lines changed

12 files changed

+2017
-120
lines changed

infrastructure/terraform/components/dl/cloudwatch_log_group_event_bus.tf

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,3 @@ resource "aws_cloudwatch_log_group" "event_bus" {
33
retention_in_days = var.log_retention_in_days
44
kms_key_id = module.kms.key_arn
55
}
6-
7-
resource "aws_cloudwatch_log_resource_policy" "event_bus" {
8-
policy_document = data.aws_iam_policy_document.event_bus_logs.json
9-
policy_name = "AWSLogDeliveryWrite-${aws_cloudwatch_event_bus.main.name}"
10-
}
11-
12-
data "aws_iam_policy_document" "event_bus_logs" {
13-
statement {
14-
effect = "Allow"
15-
principals {
16-
type = "Service"
17-
identifiers = ["delivery.logs.amazonaws.com"]
18-
}
19-
actions = [
20-
"logs:CreateLogStream",
21-
"logs:PutLogEvents"
22-
]
23-
resources = [
24-
"${aws_cloudwatch_log_group.event_bus.arn}:log-stream:*"
25-
]
26-
condition {
27-
test = "StringEquals"
28-
variable = "aws:SourceAccount"
29-
values = [var.aws_account_id]
30-
}
31-
condition {
32-
test = "ArnLike"
33-
variable = "aws:SourceArn"
34-
values = [
35-
aws_cloudwatch_log_delivery_source.main_info_logs.arn,
36-
aws_cloudwatch_log_delivery_source.main_error_logs.arn,
37-
aws_cloudwatch_log_delivery_source.main_trace_logs.arn
38-
]
39-
}
40-
}
41-
}

infrastructure/terraform/components/dl/module_kms.tf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ data "aws_iam_policy_document" "kms" {
3333

3434
identifiers = [
3535
"events.amazonaws.com",
36+
"delivery.logs.amazonaws.com"
3637
]
3738
}
3839

package-lock.json

Lines changed: 1714 additions & 68 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/playwright/constants/backend-constants.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Environment Configuration
33
export const ENV = process.env.ENVIRONMENT || 'main';
44
export const REGION = process.env.AWS_REGION || 'eu-west-2';
5+
export const { AWS_ACCOUNT_ID } = process.env;
56

67
// Compound Scope Indicator
78
export const CSI = `nhs-${ENV}-dl`;
@@ -15,9 +16,12 @@ export const TTL_POLL_LAMBDA_NAME = `${CSI}-ttl-poll`;
1516
export const TTL_QUEUE_NAME = `${CSI}-ttl-queue`;
1617
export const TTL_DLQ_NAME = `${CSI}-ttl-dlq`;
1718

19+
// Queue Url Prefix
20+
export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`;
21+
1822
// Event Bus
19-
export const EVENT_BUS_ARN = `arn:aws:events:${REGION}:${process.env.AWS_ACCOUNT_ID}:event-bus/${CSI}`;
20-
export const EVENT_BUS_DLQ_URL = `https://sqs.${REGION}.amazonaws.com/${process.env.AWS_ACCOUNT_ID}/${CSI}-event-publisher-errors-queue`;
23+
export const EVENT_BUS_ARN = `arn:aws:events:${REGION}:${AWS_ACCOUNT_ID}:event-bus/${CSI}`;
24+
export const EVENT_BUS_DLQ_URL = `${SQS_URL_PREFIX}${CSI}-event-publisher-errors-queue`;
2125

2226
// DynamoDB
2327
export const TTL_TABLE_NAME = `${CSI}-ttl`;

tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from '@playwright/test';
2-
import getTtl from 'helpers/dynamodb-helpers';
2+
import { getTtl } from 'helpers/dynamodb-helpers';
33
import eventPublisher from 'helpers/event-bus-helpers';
44
import expectToPassEventually from 'helpers/expectations';
55
import { v4 as uuidv4 } from 'uuid';
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { expect, test } from '@playwright/test';
2+
import { ENV } from 'constants/backend-constants';
3+
import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
4+
import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers';
5+
import expectToPassEventually from 'helpers/expectations';
6+
import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
7+
import { v4 as uuidv4 } from 'uuid';
8+
9+
test.describe('Digital Letters - Handle TTL', () => {
10+
const handleTtlDlqName = `nhs-${ENV}-dl-ttl-handle-expiry-errors-queue`;
11+
12+
test.beforeAll(async () => {
13+
await purgeQueue(handleTtlDlqName);
14+
});
15+
16+
const baseEvent = {
17+
profileversion: '1.0.0',
18+
profilepublished: '2025-10',
19+
specversion: '1.0',
20+
source: '/nhs/england/notify/production/primary/data-plane/digital-letters',
21+
subject:
22+
'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
23+
type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1',
24+
time: '2023-06-20T12:00:00Z',
25+
recordedtime: '2023-06-20T12:00:00.250Z',
26+
severitynumber: 2,
27+
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
28+
datacontenttype: 'application/json',
29+
dataschema:
30+
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json',
31+
dataschemaversion: '1.0',
32+
severitytext: 'INFO',
33+
data: {
34+
messageReference: 'ref1',
35+
senderId: 'sender1',
36+
},
37+
};
38+
39+
test('should handle withdrawn item', async () => {
40+
const letterId = uuidv4();
41+
const messageUri = `https://example.com/ttl/resource/${letterId}`;
42+
43+
const event = {
44+
...baseEvent,
45+
id: letterId,
46+
data: {
47+
...baseEvent.data,
48+
messageUri,
49+
'digital-letter-id': letterId,
50+
},
51+
};
52+
53+
const ttlItem = {
54+
PK: messageUri,
55+
SK: 'TTL',
56+
dateOfExpiry: '2023-12-31#0',
57+
event,
58+
ttl: Date.now() / 1000 + 3600,
59+
withdrawn: true,
60+
};
61+
62+
const putResponseCode = await putTtl(ttlItem);
63+
expect(putResponseCode).toBe(200);
64+
65+
const deleteResponseCode = await deleteTtl(messageUri);
66+
expect(deleteResponseCode).toBe(200);
67+
68+
await expectToPassEventually(async () => {
69+
const eventLogEntry = await getLogsFromCloudwatch(
70+
`/aws/lambda/nhs-${ENV}-dl-ttl-handle-expiry`,
71+
[
72+
`$.message.messageUri = "${messageUri}"`,
73+
'$.message.description = "ItemDequeued event not sent as item withdrawn"',
74+
],
75+
);
76+
77+
expect(eventLogEntry.length).toEqual(1);
78+
});
79+
});
80+
81+
test('should handle expired item', async () => {
82+
const letterId = uuidv4();
83+
const messageUri = `https://example.com/ttl/resource/${letterId}`;
84+
85+
const event = {
86+
...baseEvent,
87+
id: letterId,
88+
data: {
89+
...baseEvent.data,
90+
messageUri,
91+
'digital-letter-id': letterId,
92+
},
93+
};
94+
95+
const ttlItem = {
96+
PK: messageUri,
97+
SK: 'TTL',
98+
dateOfExpiry: '2023-12-31#0',
99+
event,
100+
ttl: Date.now() / 1000 + 3600,
101+
};
102+
103+
const putResponseCode = await putTtl(ttlItem);
104+
expect(putResponseCode).toBe(200);
105+
106+
const deleteResponseCode = await deleteTtl(messageUri);
107+
expect(deleteResponseCode).toBe(200);
108+
109+
await expectToPassEventually(async () => {
110+
const eventLogEntry = await getLogsFromCloudwatch(
111+
`/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`,
112+
[
113+
'$.message_type = "EVENT_RECEIPT"',
114+
'$.details.detail_type = "uk.nhs.notify.digital.letters.queue.item.dequeued.v1"',
115+
`$.details.event_detail = "*\\"messageUri\\":\\"${messageUri}\\"*"`,
116+
],
117+
);
118+
119+
expect(eventLogEntry.length).toEqual(1);
120+
});
121+
});
122+
123+
test('should send invalid item to dlq', async () => {
124+
const letterId = uuidv4();
125+
const messageUri = `https://example.com/ttl/resource/${letterId}`;
126+
127+
const eventWithNoMessageUri = {
128+
...baseEvent,
129+
id: letterId,
130+
data: {
131+
...baseEvent.data,
132+
'digital-letter-id': letterId,
133+
},
134+
};
135+
136+
const ttlItem = {
137+
PK: messageUri,
138+
SK: 'TTL',
139+
dateOfExpiry: '2023-12-31#0',
140+
event: eventWithNoMessageUri,
141+
ttl: Date.now() / 1000 + 3600,
142+
};
143+
144+
const putResponseCode = await putTtl(ttlItem);
145+
expect(putResponseCode).toBe(200);
146+
147+
const deleteResponseCode = await deleteTtl(messageUri);
148+
expect(deleteResponseCode).toBe(200);
149+
150+
await expectMessageContainingString(handleTtlDlqName, letterId);
151+
});
152+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
CloudWatchLogsClient,
3+
FilterLogEventsCommand,
4+
} from '@aws-sdk/client-cloudwatch-logs';
5+
import { region } from 'utils';
6+
import { test } from '@playwright/test';
7+
8+
const client = new CloudWatchLogsClient({ region: region() });
9+
10+
let testStartTime = new Date();
11+
12+
test.beforeEach(() => {
13+
testStartTime = new Date();
14+
});
15+
16+
/**
17+
* @param logGroupName e.g. '/aws/lambda/nhs-main-dl-apim-key-generation'
18+
* @param patterns e.g. [ '$.id = "someId"', '$.message.messageUri = "messageUri"' ]
19+
*/
20+
export async function getLogsFromCloudwatch(
21+
logGroupName: string,
22+
patterns: string[],
23+
): Promise<unknown[]> {
24+
const filterEvents = new FilterLogEventsCommand({
25+
logGroupName,
26+
startTime: testStartTime.getTime() - 60 * 1000,
27+
filterPattern: `{${patterns.join(' && ')}}`,
28+
limit: 50,
29+
});
30+
31+
const { events = [] } = await client.send(filterEvents);
32+
33+
return events.flatMap(({ message }) =>
34+
message ? [JSON.parse(message)] : [],
35+
);
36+
}

tests/playwright/helpers/dynamodb-helpers.ts

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
1+
import {
2+
DeleteItemCommand,
3+
DeleteItemCommandOutput,
4+
DynamoDBClient,
5+
PutItemCommand,
6+
PutItemCommandOutput,
7+
} from '@aws-sdk/client-dynamodb';
28
import { QueryCommand, QueryCommandOutput } from '@aws-sdk/lib-dynamodb';
9+
import { marshall } from '@aws-sdk/util-dynamodb';
310
import { REGION, TTL_TABLE_NAME } from 'constants/backend-constants';
11+
import { TtlDynamodbRecord } from 'utils';
412

513
const dynamoDbClient = new DynamoDBClient({ region: REGION });
614

7-
async function getTtl(messageUri: string) {
15+
export async function getTtl(messageUri: string) {
816
const params = {
917
TableName: TTL_TABLE_NAME,
1018
KeyConditionExpression: `PK = :messageUri`,
@@ -18,4 +26,31 @@ async function getTtl(messageUri: string) {
1826
return Items ?? [];
1927
}
2028

21-
export default getTtl;
29+
export async function putTtl(ttlItem: TtlDynamodbRecord) {
30+
const params = {
31+
TableName: TTL_TABLE_NAME,
32+
Item: marshall(ttlItem),
33+
};
34+
const request = new PutItemCommand(params);
35+
const output: PutItemCommandOutput = await dynamoDbClient.send(request);
36+
37+
return output.$metadata.httpStatusCode;
38+
}
39+
40+
export async function deleteTtl(messageUri: string) {
41+
const params = {
42+
TableName: TTL_TABLE_NAME,
43+
Key: {
44+
PK: {
45+
S: messageUri,
46+
},
47+
SK: {
48+
S: 'TTL',
49+
},
50+
},
51+
};
52+
const request = new DeleteItemCommand(params);
53+
const output: DeleteItemCommandOutput = await dynamoDbClient.send(request);
54+
55+
return output.$metadata.httpStatusCode;
56+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {
2+
DeleteMessageBatchCommand,
3+
ReceiveMessageCommand,
4+
ReceiveMessageCommandInput,
5+
} from '@aws-sdk/client-sqs';
6+
import { expect } from '@playwright/test';
7+
import { SQS_URL_PREFIX } from 'constants/backend-constants';
8+
import { sqsClient } from 'utils';
9+
import expectToPassEventually from 'helpers/expectations';
10+
11+
function getQueueUrl(queueName: string) {
12+
return `${SQS_URL_PREFIX}${queueName}`;
13+
}
14+
15+
export async function expectMessageContainingString(
16+
queueName: string,
17+
searchTerm: string,
18+
) {
19+
const input: ReceiveMessageCommandInput = {
20+
QueueUrl: getQueueUrl(queueName),
21+
MaxNumberOfMessages: 10,
22+
WaitTimeSeconds: 1,
23+
VisibilityTimeout: 2,
24+
};
25+
26+
await expectToPassEventually(async () => {
27+
const result = await sqsClient.send(new ReceiveMessageCommand(input));
28+
const polledMessages = result.Messages || [];
29+
30+
expect(polledMessages.some((m) => m.Body?.includes(searchTerm))).toBe(true);
31+
});
32+
}
33+
34+
export async function purgeQueue(queueName: string) {
35+
const queueUrl = getQueueUrl(queueName);
36+
37+
for (;;) {
38+
const result = await sqsClient.send(
39+
new ReceiveMessageCommand({
40+
QueueUrl: queueUrl,
41+
MaxNumberOfMessages: 10,
42+
WaitTimeSeconds: 1,
43+
}),
44+
);
45+
46+
const messages = result.Messages || [];
47+
48+
if (messages.length === 0) {
49+
break;
50+
}
51+
52+
await sqsClient.send(
53+
new DeleteMessageBatchCommand({
54+
QueueUrl: queueUrl,
55+
Entries: messages.map((msg, index) => ({
56+
Id: index.toString(),
57+
ReceiptHandle: msg.ReceiptHandle!,
58+
})),
59+
}),
60+
);
61+
}
62+
}

0 commit comments

Comments
 (0)