Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions internal/datastore/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const LetterSchemaBase = z.object({
groupId: z.string(),
reasonCode: z.string().optional(),
reasonText: z.string().optional(),
sha256hash: z.string().optional(),
});

export const LetterSchema = LetterSchemaBase.extend({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,43 @@ describe("API Lambda handler", () => {
it("returns 303 Found with a pre signed url", async () => {
const mockedGetLetterDataUrlService =
letterService.getLetterDataUrl as jest.Mock;
mockedGetLetterDataUrlService.mockResolvedValue(
"https://somePreSignedUrl.com",
);
mockedGetLetterDataUrlService.mockResolvedValue({
presignedUrl: "https://somePreSignedUrl.com",
sha256hash: "abc123sha256",
});

const event = makeApiGwEvent({
path: "/letters/letter1/data",
headers: {
"nhsd-supplier-id": "supplier1",
"nhsd-correlation-id": "correlationId",
"x-request-id": "requestId",
},
pathParameters: { id: "id1" },
});
const context = mockDeep<Context>();
const callback = jest.fn();

const getLetterDataHandler = createGetLetterDataHandler(mockedDeps);
const result = await getLetterDataHandler(event, context, callback);

expect(result).toEqual({
statusCode: 303,
headers: {
Location: "https://somePreSignedUrl.com",
"X-Notify-File-Sha256": "abc123sha256",
},
body: "",
});
});

it("returns 303 without sha256 header when metadata is absent", async () => {
const mockedGetLetterDataUrlService =
letterService.getLetterDataUrl as jest.Mock;
mockedGetLetterDataUrlService.mockResolvedValue({
presignedUrl: "https://somePreSignedUrl.com",
sha256hash: undefined,
});

const event = makeApiGwEvent({
path: "/letters/letter1/data",
Expand Down
11 changes: 9 additions & 2 deletions lambdas/api-handler/src/handlers/get-letter-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ export default function createGetLetterDataHandler(
),
);

const presignedUrl = await getLetterDataUrl(supplierId, letterId, deps);
const downloadDetails = await getLetterDataUrl(
supplierId,
letterId,
deps,
);

deps.logger.info({
description: "Generated presigned URL",
Expand All @@ -56,7 +60,10 @@ export default function createGetLetterDataHandler(
return {
statusCode: 303,
headers: {
Location: presignedUrl,
Location: downloadDetails.presignedUrl,
...(downloadDetails.sha256hash
? { "X-Notify-File-Sha256": downloadDetails.sha256hash }
: {}),
},
body: "",
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.mock("@aws-sdk/client-s3", () => {
jest.requireActual("@aws-sdk/client-s3");
return {
GetObjectCommand: jest.fn().mockImplementation((input) => ({ input })),
HeadObjectCommand: jest.fn().mockImplementation((input) => ({ input })),
};
});

Expand Down Expand Up @@ -169,10 +170,14 @@ describe("getLetterDataUrl function", () => {
};
const deps: Deps = { s3Client, letterRepo, logger, env } as Deps;

it("should return pre signed url successfully", async () => {
it("should return pre signed url and sha256 metadata successfully", async () => {
mockedGetSignedUrl.mockResolvedValue("https://somePreSignedUrl.com");
(s3Client.send as jest.Mock).mockResolvedValue({
Metadata: { sha256hash: "abc123sha256" },
});

const result = await getLetterDataUrl("supplier1", "letter1", deps);
expect(s3Client.send).toHaveBeenCalledTimes(1);

const expectedCommandInput = {
Bucket: "letterDataBucket",
Expand All @@ -183,7 +188,24 @@ describe("getLetterDataUrl function", () => {
{ input: expectedCommandInput },
{ expiresIn: 60 },
);
expect(result).toEqual("https://somePreSignedUrl.com");
expect(result).toEqual({
presignedUrl: "https://somePreSignedUrl.com",
sha256hash: "abc123sha256",
});
});

it("should return undefined sha256 when metadata is absent", async () => {
mockedGetSignedUrl.mockResolvedValue("https://somePreSignedUrl.com");
(s3Client.send as jest.Mock).mockResolvedValue({
Metadata: {},
});

const result = await getLetterDataUrl("supplier1", "letter1", deps);

expect(result).toEqual({
presignedUrl: "https://somePreSignedUrl.com",
sha256hash: undefined,
});
});

it("should throw notFoundError when letter does not exist", async () => {
Expand Down
47 changes: 37 additions & 10 deletions lambdas/api-handler/src/services/letter-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,56 @@ import {
PendingLetterBase,
} from "@internal/datastore";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
HeadObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { SendMessageBatchCommand } from "@aws-sdk/client-sqs";
import LetterNotFoundError from "@internal/datastore/src/errors/letter-not-found-error";
import NotFoundError from "../errors/not-found-error";
import { UpdateLetterCommand } from "../contracts/letters";
import { ApiErrorDetail } from "../contracts/errors";
import { Deps } from "../config/deps";

async function getDownloadUrl(
s3Uri: string,
s3Client: S3Client,
expiry: number,
) {
export type LetterDataDownloadDetails = {
presignedUrl: string;
sha256hash?: string;
};

function parseS3Uri(s3Uri: string) {
const url = new URL(s3Uri); // works for s3:// URIs
const bucket = url.hostname;
const key = url.pathname.slice(1); // remove leading '/'

const command = new GetObjectCommand({
return { bucket, key };
}

async function getDownloadDetails(
s3Uri: string,
s3Client: S3Client,
expiry: number,
): Promise<LetterDataDownloadDetails> {
const { bucket, key } = parseS3Uri(s3Uri);

const getObjectCommand = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
const headObjectCommand = new HeadObjectCommand({
Bucket: bucket,
Key: key,
});

const [presignedUrl, headObject] = await Promise.all([
getSignedUrl(s3Client, getObjectCommand, { expiresIn: expiry }),
s3Client.send(headObjectCommand),
]);

return getSignedUrl(s3Client, command, { expiresIn: expiry });
return {
presignedUrl,
sha256hash: headObject.Metadata?.sha256hash,
};
}

function mapPendingLetterToLetterBase(pending: PendingLetterBase): LetterBase {
Expand Down Expand Up @@ -85,12 +112,12 @@ export const getLetterDataUrl = async (
supplierId: string,
letterId: string,
deps: Deps,
): Promise<string> => {
): Promise<LetterDataDownloadDetails> => {
let letter;

try {
letter = await deps.letterRepo.getLetterById(supplierId, letterId);
return await getDownloadUrl(
return await getDownloadDetails(
letter.url,
deps.s3Client,
deps.env.DOWNLOAD_URL_TTL_SECONDS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ describe("uploadFile", () => {
Key: `${supplierId}/${targetFilename}`,
Body: Buffer.from("fake-pdf-bytes"),
ContentType: "application/pdf",
Metadata: {
sha256hash:
"50af8d443ccf8b2777b72a9169cd0665ef4be5335b8f53543556fa0d320b135b",
},
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";

Expand All @@ -12,12 +13,16 @@ export default async function uploadFile(
const s3 = new S3Client();
const filePath = path.join(__dirname, "..", "test-letters", sourceFilename);
const fileContent = readFileSync(filePath);
const hash = createHash("sha256").update(fileContent).digest("hex");

const uploadParams = {
Bucket: bucketName,
Key: `${folder}/${targetFilename}`,
Body: fileContent,
ContentType: "application/pdf",
Metadata: {
sha256hash: hash,
},
};

const command = new PutObjectCommand(uploadParams);
Expand Down
4 changes: 4 additions & 0 deletions specification/api/components/responses/getDataId303.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ description: See Other
headers:
Location:
$ref: "../responseHeaders/getDataLocation.yml"
X-Notify-File-Sha256:
description: SHA-256 hash of the file from S3 user metadata key sha256.
schema:
type: string
X-Request-ID:
$ref: "../responseHeaders/xRequestId.yml"
X-Correlation-ID:
Expand Down
13 changes: 11 additions & 2 deletions tests/component-tests/apiGateway-tests/get-letter-pdf.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { createHash } from "node:crypto";
import getRestApiGatewayBaseUrl from "../../helpers/aws-gateway-helper";
import {
createTestData,
Expand Down Expand Up @@ -39,8 +40,16 @@ test.describe("API Gateway Tests to Verify Get Letter PDF Endpoint", () => {
);

expect(response.status()).toBe(200);
const responseBody = await response.text();
expect(responseBody).toContain("PDF");
const responseMetadataSha256 = response.headers()["x-amz-meta-sha256"];
expect(responseMetadataSha256).toBeDefined();

const responseBodyBuffer = await response.body();
expect(responseBodyBuffer.toString("utf8")).toContain("PDF");

const downloadedPdfSha256 = createHash("sha256")
.update(responseBodyBuffer)
.digest("hex");
expect(downloadedPdfSha256).toBe(responseMetadataSha256);

async function fetchUrl(url: string) {
const res = await request.get(url, { headers });
Expand Down
8 changes: 8 additions & 0 deletions tests/constants/request-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ export interface RequestHeaders {
[key: string]: string;
}

export const sandBoxHeaderWithHash: RequestSandBoxHeaders = {
"X-Request-ID": randomUUID(),
"NHSD-Supplier-ID": SUPPLIERID,
"Content-Type": "application/vnd.api+json",
"X-Correlation-ID": randomUUID(),
"x-amz-meta-sha256": "xyz123sha256hash",
};

export interface RequestSandBoxHeaders {
"X-Request-ID": string;
"Content-Type": string;
Expand Down
13 changes: 12 additions & 1 deletion tests/sandbox/get-letter-data-sandbox.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { expect, test } from "@playwright/test";
import { createHash } from "node:crypto";
import {
SUPPLIER_API_URL_SANDBOX,
SUPPLIER_LETTERS,
} from "../constants/api-constants";
import {
RequestSandBoxHeaders,
sandBoxHeader,
sandBoxHeaderWithHash,
} from "../constants/request-headers";

test.describe("Sandbox Tests To Get Letter Data", () => {
test(`Get Letter Data endpoint returns 200 for valid id`, async ({
request,
}) => {
const id = "2AL5eYSWGzCHlGmzNxuqVusPxDg";
const headers: RequestSandBoxHeaders = sandBoxHeader;
const headers: RequestSandBoxHeaders = sandBoxHeaderWithHash;
const response = await request.get(
`${SUPPLIER_API_URL_SANDBOX}/${SUPPLIER_LETTERS}/${id}/data`,
{
Expand All @@ -23,6 +25,15 @@ test.describe("Sandbox Tests To Get Letter Data", () => {

expect(response.status()).toBe(200);
expect(response.headers()["content-type"]).toMatch("application/pdf");

const responseBodyBuffer = await response.body();
const computedSha256 = createHash("sha256")
.update(responseBodyBuffer)
.digest("hex");

const expectedSha256 = sandBoxHeaderWithHash["x-amz-meta-sha256"];
expect(expectedSha256).toBeDefined();
expect(computedSha256).toBe(expectedSha256); // Will need to change the header to include the actual hash of the sandbox pdf for this to work
});
test(`Get Letter Data endpoint returns 404 for invalid id`, async ({
request,
Expand Down
Loading