Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,3 @@ resource "aws_cloudwatch_log_group" "event_bus" {
retention_in_days = var.log_retention_in_days
kms_key_id = module.kms.key_arn
}

resource "aws_cloudwatch_log_resource_policy" "event_bus" {
Comment thread
Ian-Hodges marked this conversation as resolved.
policy_document = data.aws_iam_policy_document.event_bus_logs.json
policy_name = "AWSLogDeliveryWrite-${aws_cloudwatch_event_bus.main.name}"
}

data "aws_iam_policy_document" "event_bus_logs" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["delivery.logs.amazonaws.com"]
}
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = [
"${aws_cloudwatch_log_group.event_bus.arn}:log-stream:*"
]
condition {
test = "StringEquals"
variable = "aws:SourceAccount"
values = [var.aws_account_id]
}
condition {
test = "ArnLike"
variable = "aws:SourceArn"
values = [
aws_cloudwatch_log_delivery_source.main_info_logs.arn,
aws_cloudwatch_log_delivery_source.main_error_logs.arn,
aws_cloudwatch_log_delivery_source.main_trace_logs.arn
]
}
}
}
1 change: 1 addition & 0 deletions infrastructure/terraform/components/dl/module_kms.tf
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ data "aws_iam_policy_document" "kms" {

identifiers = [
"events.amazonaws.com",
"delivery.logs.amazonaws.com"
]
}

Expand Down
1,782 changes: 1,714 additions & 68 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions tests/playwright/constants/backend-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Environment Configuration
export const ENV = process.env.ENVIRONMENT || 'main';
export const REGION = process.env.AWS_REGION || 'eu-west-2';
export const { AWS_ACCOUNT_ID } = process.env;

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

// Queue Url Prefix
export const SQS_URL_PREFIX = `https://sqs.${REGION}.amazonaws.com/${AWS_ACCOUNT_ID}/`;

// Event Bus
export const EVENT_BUS_ARN = `arn:aws:events:${REGION}:${process.env.AWS_ACCOUNT_ID}:event-bus/${CSI}`;
export const EVENT_BUS_DLQ_URL = `https://sqs.${REGION}.amazonaws.com/${process.env.AWS_ACCOUNT_ID}/${CSI}-event-publisher-errors-queue`;
export const EVENT_BUS_ARN = `arn:aws:events:${REGION}:${AWS_ACCOUNT_ID}:event-bus/${CSI}`;
export const EVENT_BUS_DLQ_URL = `${SQS_URL_PREFIX}${CSI}-event-publisher-errors-queue`;

// DynamoDB
export const TTL_TABLE_NAME = `${CSI}-ttl`;
Comment thread
Ian-Hodges marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test';
import getTtl from 'helpers/dynamodb-helpers';
import { getTtl } from 'helpers/dynamodb-helpers';
import eventPublisher from 'helpers/event-bus-helpers';
import expectToPassEventually from 'helpers/expectations';
import { v4 as uuidv4 } from 'uuid';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, test } from '@playwright/test';
import { ENV } from 'constants/backend-constants';
import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers';
import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers';
import expectToPassEventually from 'helpers/expectations';
import { expectMessageContainingString, purgeQueue } from 'helpers/sqs-helpers';
import { v4 as uuidv4 } from 'uuid';

test.describe('Digital Letters - Handle TTL', () => {
const handleTtlDlqName = `nhs-${ENV}-dl-ttl-handle-expiry-errors-queue`;

test.beforeAll(async () => {
await purgeQueue(handleTtlDlqName);
});

const baseEvent = {
profileversion: '1.0.0',
profilepublished: '2025-10',
specversion: '1.0',
source: '/nhs/england/notify/production/primary/data-plane/digital-letters',
subject:
'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959',
type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1',
time: '2023-06-20T12:00:00Z',
recordedtime: '2023-06-20T12:00:00.250Z',
severitynumber: 2,
traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01',
datacontenttype: 'application/json',
dataschema:
'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json',
dataschemaversion: '1.0',
severitytext: 'INFO',
data: {
messageReference: 'ref1',
senderId: 'sender1',
},
};
Comment thread
gareth-allan marked this conversation as resolved.

test('should handle withdrawn item', async () => {
const letterId = uuidv4();
const messageUri = `https://example.com/ttl/resource/${letterId}`;

const event = {
...baseEvent,
id: letterId,
data: {
...baseEvent.data,
messageUri,
'digital-letter-id': letterId,
Comment thread
gareth-allan marked this conversation as resolved.
},
};

const ttlItem = {
PK: messageUri,
SK: 'TTL',
dateOfExpiry: '2023-12-31#0',
event,
ttl: Date.now() / 1000 + 3600,
withdrawn: true,
};

const putResponseCode = await putTtl(ttlItem);
expect(putResponseCode).toBe(200);

const deleteResponseCode = await deleteTtl(messageUri);
expect(deleteResponseCode).toBe(200);

await expectToPassEventually(async () => {
const eventLogEntry = await getLogsFromCloudwatch(
`/aws/lambda/nhs-${ENV}-dl-ttl-handle-expiry`,
[
`$.message.messageUri = "${messageUri}"`,
'$.message.description = "ItemDequeued event not sent as item withdrawn"',
],
);

expect(eventLogEntry.length).toEqual(1);
});
});

test('should handle expired item', async () => {
const letterId = uuidv4();
const messageUri = `https://example.com/ttl/resource/${letterId}`;

const event = {
...baseEvent,
id: letterId,
data: {
...baseEvent.data,
messageUri,
'digital-letter-id': letterId,
},
};

const ttlItem = {
PK: messageUri,
SK: 'TTL',
dateOfExpiry: '2023-12-31#0',
event,
ttl: Date.now() / 1000 + 3600,
};

const putResponseCode = await putTtl(ttlItem);
expect(putResponseCode).toBe(200);

const deleteResponseCode = await deleteTtl(messageUri);
expect(deleteResponseCode).toBe(200);

await expectToPassEventually(async () => {
await expectToPassEventually(async () => {
const eventLogEntry = await getLogsFromCloudwatch(
`/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`,
[
'$.message_type = "EVENT_RECEIPT"',
'$.details.detail_type = "uk.nhs.notify.digital.letters.queue.item.dequeued.v1"',
`$.details.event_detail = "*\\"messageUri\\":\\"${messageUri}\\"*"`,
],
);

expect(eventLogEntry.length).toEqual(1);
});
});
});

test('should send invalid item to dlq', async () => {
const letterId = uuidv4();
const messageUri = `https://example.com/ttl/resource/${letterId}`;

const eventWithNoMessageUri = {
...baseEvent,
id: letterId,
data: {
...baseEvent.data,
'digital-letter-id': letterId,
},
};

const ttlItem = {
PK: messageUri,
SK: 'TTL',
dateOfExpiry: '2023-12-31#0',
event: eventWithNoMessageUri,
ttl: Date.now() / 1000 + 3600,
};

const putResponseCode = await putTtl(ttlItem);
expect(putResponseCode).toBe(200);

const deleteResponseCode = await deleteTtl(messageUri);
expect(deleteResponseCode).toBe(200);

await expectMessageContainingString(handleTtlDlqName, letterId);
});
});
36 changes: 36 additions & 0 deletions tests/playwright/helpers/cloudwatch-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
CloudWatchLogsClient,
FilterLogEventsCommand,
} from '@aws-sdk/client-cloudwatch-logs';
import { region } from 'utils';
import { test } from '@playwright/test';

const client = new CloudWatchLogsClient({ region: region() });

let testStartTime = new Date();

test.beforeEach(() => {
testStartTime = new Date();
});

/**
* @param logGroupName e.g. '/aws/lambda/nhs-main-dl-apim-key-generation'
* @param patterns e.g. [ '$.id = "someId"', '$.message.messageUri = "messageUri"' ]
*/
export async function getLogsFromCloudwatch(
logGroupName: string,
patterns: string[],
): Promise<unknown[]> {
const filterEvents = new FilterLogEventsCommand({
logGroupName,
startTime: testStartTime.getTime() - 60 * 1000,
Comment thread
Ian-Hodges marked this conversation as resolved.
filterPattern: `{${patterns.join(' && ')}}`,
limit: 50,
});

const { events = [] } = await client.send(filterEvents);

return events.flatMap(({ message }) =>
message ? [JSON.parse(message)] : [],
);
}
41 changes: 38 additions & 3 deletions tests/playwright/helpers/dynamodb-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
DeleteItemCommand,
DeleteItemCommandOutput,
DynamoDBClient,
PutItemCommand,
PutItemCommandOutput,
} from '@aws-sdk/client-dynamodb';
import { QueryCommand, QueryCommandOutput } from '@aws-sdk/lib-dynamodb';
import { marshall } from '@aws-sdk/util-dynamodb';
import { REGION, TTL_TABLE_NAME } from 'constants/backend-constants';
import { TtlDynamodbRecord } from 'utils';

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

async function getTtl(messageUri: string) {
export async function getTtl(messageUri: string) {
const params = {
TableName: TTL_TABLE_NAME,
KeyConditionExpression: `PK = :messageUri`,
Expand All @@ -18,4 +26,31 @@ async function getTtl(messageUri: string) {
return Items ?? [];
}

export default getTtl;
export async function putTtl(ttlItem: TtlDynamodbRecord) {
const params = {
TableName: TTL_TABLE_NAME,
Item: marshall(ttlItem),
};
const request = new PutItemCommand(params);
const output: PutItemCommandOutput = await dynamoDbClient.send(request);

return output.$metadata.httpStatusCode;
}

export async function deleteTtl(messageUri: string) {
const params = {
TableName: TTL_TABLE_NAME,
Key: {
PK: {
S: messageUri,
},
SK: {
S: 'TTL',
},
},
};
const request = new DeleteItemCommand(params);
const output: DeleteItemCommandOutput = await dynamoDbClient.send(request);

return output.$metadata.httpStatusCode;
}
64 changes: 64 additions & 0 deletions tests/playwright/helpers/sqs-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
DeleteMessageBatchCommand,
ReceiveMessageCommand,
ReceiveMessageCommandInput,
} from '@aws-sdk/client-sqs';
import { expect } from '@playwright/test';
import { SQS_URL_PREFIX } from 'constants/backend-constants';
import { sqsClient } from 'utils';
import expectToPassEventually from 'helpers/expectations';

function getQueueUrl(queueName: string) {
return `${SQS_URL_PREFIX}${queueName}`;
}

export async function expectMessageContainingString(
queueName: string,
searchTerm: string,
) {
const input: ReceiveMessageCommandInput = {
QueueUrl: getQueueUrl(queueName),
MaxNumberOfMessages: 10,
WaitTimeSeconds: 1,
VisibilityTimeout: 2,
};

await expectToPassEventually(async () => {
const result = await sqsClient.send(new ReceiveMessageCommand(input));
const polledMessages = result.Messages || [];

expect(
polledMessages.find((m) => (m.Body ?? '').includes(searchTerm)),
).toBeDefined();
Comment thread
Ian-Hodges marked this conversation as resolved.
Outdated
});
}

export async function purgeQueue(queueName: string) {
const queueUrl = getQueueUrl(queueName);

for (;;) {
const result = await sqsClient.send(
new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 1,
}),
);

const messages = result.Messages || [];

if (messages.length === 0) {
break;
}

await sqsClient.send(
new DeleteMessageBatchCommand({
QueueUrl: queueUrl,
Entries: messages.map((msg, index) => ({
Id: index.toString(),
ReceiptHandle: msg.ReceiptHandle!,
})),
}),
);
Comment thread
gareth-allan marked this conversation as resolved.
}
}
Loading