Skip to content

Commit

Permalink
feat(cb2-10457): add generic file retrieval (#55)
Browse files Browse the repository at this point in the history
* feat(cb2-10457): add generic file retrieval

* fix(cb2-10457): supress lint errors

* fix(cb2-10457): auto lint updates
  • Loading branch information
shivangidas authored Jan 23, 2024
1 parent c3419d1 commit a6fcac3
Show file tree
Hide file tree
Showing 10 changed files with 533 additions and 106 deletions.
7 changes: 7 additions & 0 deletions docs/spec.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ paths:
required: false
schema:
type: string
- name: fileName
in: query
description: Name of the file to retrieve
required: false
schema:
type: string

responses:
'200':
description: Document Retrieved Successfully
Expand Down
203 changes: 103 additions & 100 deletions package-lock.json

Large diffs are not rendered by default.

23 changes: 19 additions & 4 deletions src/domain/documentRequestFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { APIGatewayProxyResult } from 'aws-lambda';
import getCertificate from './getCertificate';
import getLetter from './getLetter';
import getPlate from './getPlate';
import getFile from './getFile';

const {
NODE_ENV, BUCKET, BRANCH,
} = process.env;

export default async (vin: string, testNumber: string, plateSerialNumber: string, systemNumber: string): Promise<APIGatewayProxyResult> => {
export default async (vin: string, testNumber: string, plateSerialNumber: string, systemNumber: string, fileName: string): Promise<APIGatewayProxyResult> => {
const s3 = new S3(
process.env.IS_OFFLINE && {
s3ForcePathStyle: true,
Expand All @@ -19,9 +20,10 @@ export default async (vin: string, testNumber: string, plateSerialNumber: string
},
);

const isCertificate = vin && testNumber && !plateSerialNumber && !systemNumber;
const isPlate = plateSerialNumber && !vin && !testNumber && !systemNumber;
const isLetter = !plateSerialNumber && vin && !testNumber && systemNumber;
const isCertificate = vin && testNumber && !plateSerialNumber && !systemNumber && !fileName;
const isPlate = plateSerialNumber && !vin && !testNumber && !systemNumber && !fileName;
const isLetter = !plateSerialNumber && vin && !testNumber && systemNumber && !fileName;
const isFile = fileName && !plateSerialNumber && !vin && !testNumber && !systemNumber;

if (isCertificate) {
console.info('Calling cert service');
Expand Down Expand Up @@ -64,6 +66,19 @@ export default async (vin: string, testNumber: string, plateSerialNumber: string
);
}

if (isFile) {
console.info('Calling file retrieval service');
return getFile(
{
fileName,
},
s3,
`cvs-cert-${BUCKET}`,
BRANCH,
NODE_ENV,
);
}

return Promise.resolve({
statusCode: 400,
body: '',
Expand Down
93 changes: 93 additions & 0 deletions src/domain/getFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-base-to-string */

import { S3, AWSError } from 'aws-sdk';
import { APIGatewayProxyResult } from 'aws-lambda';
import getObjectFromS3 from '../infrastructure/s3/s3PlateService';
import NoBodyError from '../errors/NoBodyError';
import MissingBucketNameError from '../errors/MissingBucketNameError';
import IncorrectFileTypeError from '../errors/IncorrectFileTypeError';
import MissingFolderNameError from '../errors/MissingFolderNameError';
import FileNameError from '../errors/FileNameError';
import FileDetails from '../interfaces/FileDetails';

function isAWSError(error: Error | AWSError): error is AWSError {
return Object.prototype.hasOwnProperty.call(error, 'code') as boolean;
}

export default async (
event: FileDetails,
s3: S3,
bucketName: string | undefined,
folder: string | undefined,
currentEnvironment: string | undefined,
): Promise<APIGatewayProxyResult> => {
try {
if (!bucketName) {
throw new MissingBucketNameError();
}
if (currentEnvironment !== 'local' && !folder) {
throw new MissingFolderNameError();
}

console.log(`Validating: ${event.fileName}`);

if (!event.fileName) {
throw new FileNameError();
}

const file = await getObjectFromS3(s3, bucketName, folder, event.fileName);
const response = file.toString('base64');

const headers = {
'Content-type': 'application/pdf',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
'X-Content-Type-Options': 'nosniff',
Vary: 'Origin',
'X-XSS-Protection': '1; mode=block',
};

return {
headers,
statusCode: 200,
body: response,
isBase64Encoded: true,
};
} catch (e) {
let code = 500;
let message = '';

// Split into 50x and 40x errors.
if (e instanceof NoBodyError || e instanceof MissingBucketNameError || e instanceof MissingFolderNameError) {
message = e.message;
}

if (e instanceof FileNameError) {
code = 400;
message = e.message;
}

if (e instanceof IncorrectFileTypeError) {
code = 404;
message = e.message;
}

if (isAWSError(e)) {
// S3 error that the key does not exist
if (['NoSuchKey'].includes(e.code)) {
code = 404;
}

// Any other AWS errors we get will always be a 500 because it will be an error on our part.
message = e.code;
}

console.error(code);
console.error(message);

return {
statusCode: code,
body: message,
};
}
};
6 changes: 6 additions & 0 deletions src/errors/FileNameError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default class FileNameError extends Error {
constructor() {
super();
this.message = 'File name is missing or incorrect';
}
}
4 changes: 2 additions & 2 deletions src/infrastructure/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ app.get('/version', (_request, res) => {

app.get('/document-retrieval', (req: Request, res: Response) => {
const {
vinNumber, plateSerialNumber, testNumber, systemNumber,
vinNumber, plateSerialNumber, testNumber, systemNumber, fileName,
} = req.query;

documentRequestFactory(vinNumber as string, testNumber as string, plateSerialNumber as string, systemNumber as string)
documentRequestFactory(vinNumber as string, testNumber as string, plateSerialNumber as string, systemNumber as string, fileName as string)
.then(({ statusCode, headers, body }) => {
res.status(statusCode);

Expand Down
33 changes: 33 additions & 0 deletions src/infrastructure/s3/s3FileService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { S3 } from 'aws-sdk';
import IncorrectFileTypeError from '../../errors/IncorrectFileTypeError';
import NoBodyError from '../../errors/NoBodyError';

export default async (
s3: S3,
bucket: string,
folder: string | undefined,
fileName: string,
): Promise<S3.Body> => {
const key = folder ? `${folder}/${fileName}.pdf` : `${fileName}.pdf`;

console.info(`Bucket name: ${bucket}`);
console.info(`Item key: ${key}`);

const response = await s3
.getObject({
Bucket: bucket,
Key: key,
})
.promise();

if (response.ContentType !== 'application/octet-stream' && response.ContentType !== 'application/pdf') {
console.error(`Incorrect content-type: ${response.ContentType}`);
throw new IncorrectFileTypeError();
}

if (response.Body) {
return response.Body;
}

throw new NoBodyError();
};
3 changes: 3 additions & 0 deletions src/interfaces/FileDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default interface FileDetails {
fileName : string;
}
160 changes: 160 additions & 0 deletions tests/unit/domain/getFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { S3 } from 'aws-sdk';
import MissingBucketNameError from '../../../src/errors/MissingBucketNameError';
import NoBodyError from '../../../src/errors/NoBodyError';
import IncorrectFileTypeError from '../../../src/errors/IncorrectFileTypeError';
import MissingFolderNameError from '../../../src/errors/MissingFolderNameError';
import FileDetails from '../../../src/interfaces/FileDetails';
import getFile from '../../../src/domain/getFile';
import FileNameError from '../../../src/errors/FileNameError';

describe('getFile', () => {
it('returns an internal server error if the bucket is undefined', async () => {
const response = await getFile({} as FileDetails, ({} as unknown) as S3, undefined, 'folder', 'test');
const error = new MissingBucketNameError();

expect(response.statusCode).toBe(500);
expect(response.body).toEqual(error.message);
});

it('returns an internal server error if the folder is undefined', async () => {
const response = await getFile({} as FileDetails, ({} as unknown) as S3, 'bucket', undefined, 'test');
const error = new MissingFolderNameError();

expect(response.statusCode).toBe(500);
expect(response.body).toEqual(error.message);
});

it('returns a bad request if the file name is invalid', async () => {
const event: FileDetails = {
fileName: undefined,
};
const response = await getFile(event, ({} as unknown) as S3, 'bucket', 'folder', 'test');
const error = new FileNameError();

expect(response.statusCode).toBe(400);
expect(response.body).toEqual(error.message);
});

it('returns an internal server error if there is no Body in the S3 request', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest.fn().mockReturnValue(Promise.resolve({ ContentType: 'application/octet-stream' }));
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');
const error = new NoBodyError();

expect(response.statusCode).toBe(500);
expect(response.body).toEqual(error.message);
});

it('returns an 404 if the stored file is not a PDF', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest
.fn()
.mockReturnValue(Promise.resolve({ Body: 'This is an image', ContentType: 'image/jpg' }));
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');
const error = new IncorrectFileTypeError();

expect(response.statusCode).toBe(404);
expect(response.body).toEqual(error.message);
});

it('returns a not found error if the file is not found', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest.fn().mockReturnValue(Promise.reject(({ code: 'NoSuchKey' } as unknown) as Error)); // eslint-disable-line prefer-promise-reject-errors
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');

expect(response.statusCode).toBe(404);
expect(response.body).toBe('NoSuchKey');
});

it('returns an internal server error if the S3 get fails for any other reason', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest.fn().mockReturnValue(Promise.reject(({ code: 'Generic Error' } as unknown) as Error)); // eslint-disable-line prefer-promise-reject-errors
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');

expect(response.statusCode).toBe(500);
expect(response.body).toBe('Generic Error');
});

it('returns a successful response if everything works', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest
.fn()
.mockReturnValue(Promise.resolve({ Body: 'Certificate Content', ContentType: 'application/octet-stream' }));
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');

expect(response.statusCode).toBe(200);
expect(response.body).toBe('Certificate Content');
});

it('base64 encodes the response', async () => {
const mockS3 = ({} as unknown) as S3;
const body = Buffer.from('Certificate Content');
const mockPromise = jest
.fn()
.mockReturnValue(Promise.resolve({ Body: body, ContentType: 'application/octet-stream' }));
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', 'folder', 'test');

expect(response.statusCode).toBe(200);
expect(response.body).toEqual(body.toString('base64'));
});

it('ignores the folder check if the current environment is "local". Required for local testing', async () => {
const mockS3 = ({} as unknown) as S3;
const mockPromise = jest
.fn()
.mockReturnValue(Promise.resolve({ Body: 'Certificate Content', ContentType: 'application/octet-stream' }));
const mockGetObject = jest.fn().mockReturnValue({ promise: mockPromise });

mockS3.getObject = mockGetObject;

const event: FileDetails = {
fileName: 'adr_pass_123_2024-01-22T11:48:16.035Z',
};
const response = await getFile(event, mockS3, 'bucket', undefined, 'local');

expect(response.statusCode).toBe(200);
expect(response.body).toBe('Certificate Content');
});
});
Loading

0 comments on commit a6fcac3

Please sign in to comment.