Skip to content

Commit 933524f

Browse files
Add tf and tests
1 parent ee5c3ea commit 933524f

File tree

5 files changed

+171
-8
lines changed

5 files changed

+171
-8
lines changed

infrastructure/terraform/components/api/module_lambda_get_letter_data.tf

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,16 @@ data "aws_iam_policy_document" "get_letter_data_lambda" {
6969
"${aws_dynamodb_table.letters.arn}/index/supplierStatus-index"
7070
]
7171
}
72+
73+
statement {
74+
sid = "S3GetObjectForPresign"
75+
actions = ["s3:GetObject"]
76+
resources = ["${module.s3bucket_test_letters.arn}/*"]
77+
}
78+
79+
statement {
80+
sid = "KmsForS3Objects"
81+
actions = ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey"]
82+
resources = [module.s3bucket_test_letters.kms_key_arn]
83+
}
7284
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { APIGatewayProxyResult, Context } from 'aws-lambda';
2+
import { mockDeep } from 'jest-mock-extended';
3+
import { makeApiGwEvent } from './utils/test-utils';
4+
import * as letterService from '../../services/letter-operations';
5+
import { mapErrorToResponse } from '../../mappers/error-mapper';
6+
import { ValidationError } from '../../errors';
7+
import * as errors from '../../contracts/errors';
8+
import { getLetterData } from '../get-letter-data';
9+
10+
jest.mock('../../mappers/error-mapper');
11+
const mockedMapErrorToResponse = jest.mocked(mapErrorToResponse);
12+
const expectedErrorResponse: APIGatewayProxyResult = {
13+
statusCode: 400,
14+
body: 'Error'
15+
};
16+
mockedMapErrorToResponse.mockReturnValue(expectedErrorResponse);
17+
18+
jest.mock('../../services/letter-operations');
19+
20+
jest.mock('../../config/lambda-config', () => ({
21+
lambdaConfig: {
22+
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
23+
APIM_CORRELATION_HEADER: 'nhsd-correlation-id'
24+
}
25+
}));
26+
27+
describe('API Lambda handler', () => {
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
jest.resetModules();
32+
});
33+
34+
it('returns 302 Found with a pre signed url', async () => {
35+
36+
const mockedGetLetterDataUrlService = letterService.getLetterDataUrl as jest.Mock;
37+
mockedGetLetterDataUrlService.mockResolvedValue('https://somePreSignedUrl.com');
38+
39+
const event = makeApiGwEvent({path: '/letters/letter1/data',
40+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'},
41+
pathParameters: {id: 'id1'}
42+
});
43+
const context = mockDeep<Context>();
44+
const callback = jest.fn();
45+
46+
const result = await getLetterData(event, context, callback);
47+
48+
expect(result).toEqual({
49+
statusCode: 302,
50+
Location: 'https://somePreSignedUrl.com',
51+
body: ''
52+
});
53+
});
54+
55+
it('returns 400 for missing supplier ID (empty headers)', async () => {
56+
const event = makeApiGwEvent({ path: '/letters/letter1/data', headers: {},
57+
pathParameters: {id: 'id1'}
58+
});
59+
const context = mockDeep<Context>();
60+
const callback = jest.fn();
61+
62+
const result = await getLetterData(event, context, callback);
63+
64+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error('The request headers are empty'), undefined);
65+
expect(result).toEqual(expectedErrorResponse);
66+
});
67+
68+
it('returns 500 if correlation id not provided in request', async () => {
69+
const event = makeApiGwEvent({
70+
path: '/letters/letter1/data',
71+
queryStringParameters: { limit: '2000' },
72+
headers: {'nhsd-supplier-id': 'supplier1'},
73+
pathParameters: {id: 'id1'}
74+
});
75+
const context = mockDeep<Context>();
76+
const callback = jest.fn();
77+
78+
const result = await getLetterData(event, context, callback);
79+
80+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new Error("The request headers don't contain the APIM correlation id"), undefined);
81+
expect(result).toEqual(expectedErrorResponse);
82+
});
83+
84+
it('returns error response when path parameter letterId is not found', async () => {
85+
const event = makeApiGwEvent({
86+
path: '/letters/',
87+
headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId'}
88+
});
89+
const context = mockDeep<Context>();
90+
const callback = jest.fn();
91+
92+
const result = await getLetterData(event, context, callback);
93+
94+
expect(mockedMapErrorToResponse).toHaveBeenCalledWith(new ValidationError(errors.ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter), 'correlationId');
95+
expect(result).toEqual(expectedErrorResponse);
96+
});
97+
});

lambdas/api-handler/src/handlers/get-letter-data.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export const getLetterData: APIGatewayProxyHandler = async (event) => {
2222

2323
return {
2424
statusCode: 302,
25-
Location: getLetterDataUrl(supplierId, letterId, letterRepo),
25+
Location: await getLetterDataUrl(supplierId, letterId, letterRepo),
2626
body: ''
2727
};
2828
}

lambdas/api-handler/src/services/__tests__/letter-operations.test.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
11
import { Letter } from '../../../../../internal/datastore/src';
2-
import { LetterDto, LetterStatus } from '../../contracts/letters';
3-
import { getLettersForSupplier, patchLetterStatus } from '../letter-operations';
2+
import { LetterDto } from '../../contracts/letters';
3+
import { getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations';
44

5+
jest.mock('@aws-sdk/s3-request-presigner', () => ({
6+
getSignedUrl: jest.fn(),
7+
}));
8+
9+
jest.mock('@aws-sdk/client-s3', () => {
10+
return {
11+
S3Client: jest.fn().mockImplementation(() => ({})),
12+
GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })),
13+
};
14+
});
15+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
16+
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3';
17+
18+
const mockedGetSignedUrl = getSignedUrl as jest.MockedFunction<typeof getSignedUrl>;
19+
const MockedGetObjectCommand = GetObjectCommand as unknown as jest.Mock;
520

621
function makeLetter(id: string, status: Letter['status']) : Letter {
722
return {
@@ -10,7 +25,7 @@ function makeLetter(id: string, status: Letter['status']) : Letter {
1025
supplierId: 'supplier1',
1126
specificationId: 'spec123',
1227
groupId: 'group123',
13-
url: 'https://example.com/letter/abc123',
28+
url: `s3://letterDataBucket/${id}.pdf`,
1429
createdAt: new Date().toISOString(),
1530
updatedAt: new Date().toISOString(),
1631
supplierStatus: `supplier1#${status}`,
@@ -104,3 +119,43 @@ describe('patchLetterStatus function', () => {
104119
await expect(patchLetterStatus(updatedLetterDto, 'letter1', mockRepo as any)).rejects.toThrow("unexpected error");
105120
});
106121
});
122+
123+
describe('getLetterDataUrl function', () => {
124+
125+
const updatedLetter = makeLetter("letter1", "REJECTED");
126+
127+
it('should return pre signed url successfully', async () => {
128+
const mockRepo = {
129+
getLetterById: jest.fn().mockResolvedValue(updatedLetter)
130+
};
131+
132+
mockedGetSignedUrl.mockResolvedValue('http://somePreSignedUrl.com');
133+
134+
const result = await getLetterDataUrl('supplier1', 'letter1', mockRepo as any);
135+
136+
expect(mockedGetSignedUrl).toHaveBeenCalled();
137+
expect(MockedGetObjectCommand).toHaveBeenCalledWith({
138+
Bucket: 'letterDataBucket',
139+
Key: 'letter1.pdf'
140+
});
141+
142+
expect(result).toEqual('http://somePreSignedUrl.com');
143+
});
144+
145+
it('should throw notFoundError when letter does not exist', async () => {
146+
const mockRepo = {
147+
getLetterById: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1'))
148+
};
149+
150+
await expect(getLetterDataUrl('supplier1', 'letter42', mockRepo as any)).rejects.toThrow("No resource found with that ID");
151+
});
152+
153+
it('should throw unexpected error', async () => {
154+
155+
const mockRepo = {
156+
getLetterById: jest.fn().mockRejectedValue(new Error('unexpected error'))
157+
};
158+
159+
await expect(getLetterDataUrl('supplier1', 'letter1', mockRepo as any)).rejects.toThrow("unexpected error");
160+
});
161+
});

lambdas/api-handler/src/services/letter-operations.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { NotFoundError, ValidationError } from '../errors';
33
import { LetterDto, PatchLetterResponse } from '../contracts/letters';
44
import { mapToPatchLetterResponse } from '../mappers/letter-mapper';
55
import { ApiErrorDetail } from '../contracts/errors';
6-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
7-
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
6+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
7+
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
88

99

1010
export const getLettersForSupplier = async (supplierId: string, status: string, limit: number, letterRepo: LetterRepository): Promise<LetterBase[]> => {
@@ -38,14 +38,13 @@ export const getLetterDataUrl = async (supplierId: string, letterId: string, let
3838

3939
try {
4040
letter = await letterRepo.getLetterById(supplierId, letterId);
41+
return await getPresignedUrl(letter.url);
4142
} catch (error) {
4243
if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) {
4344
throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
4445
}
4546
throw error;
4647
}
47-
48-
return getPresignedUrl(letter.url);
4948
}
5049

5150
async function getPresignedUrl(s3Uri: string) {

0 commit comments

Comments
 (0)