diff --git a/src/v2/adapters/aws/lambda-edge.adapter.ts b/src/v2/adapters/aws/lambda-edge.adapter.ts index 90dccb03..385ac7f1 100644 --- a/src/v2/adapters/aws/lambda-edge.adapter.ts +++ b/src/v2/adapters/aws/lambda-edge.adapter.ts @@ -26,6 +26,12 @@ import { //#endregion +/** + * The type alias to indicate where we get the default value of query string to create the request. + */ +export type DefaultQueryString = + CloudFrontRequestEvent['Records'][number]['cf']['request']['querystring']; + /** * The type alias to indicate where we get the default value of path to create the request. */ @@ -68,6 +74,24 @@ export const DEFAULT_LAMBDA_EDGE_DISALLOWED_HEADERS: (string | RegExp)[] = [ 'X-Real-IP', ]; +/** + * The default max response size in bytes of viewer request and viewer response. + * + * @default 1024 * 40 = 40960 = 40KB + * + * {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html Reference} + */ +export const DEFAULT_VIEWER_MAX_RESPONSE_SIZE_IN_BYTES = 1024 * 40; + +/** + * The default max response size in bytes of origin request and origin response. + * + * @default 1024 * 1024 = 1048576 = 1MB + * + * {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html Reference} + */ +export const DEFAULT_ORIGIN_MAX_RESPONSE_SIZE_IN_BYTES = 1024 * 1024; + /** * The options to customize the {@link LambdaEdgeAdapter}. */ @@ -77,7 +101,7 @@ export interface LambdaEdgeAdapterOptions { * * {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html Reference} * - * @default 1024 * 40 = 1048576 = 1MB + * @default {@link DEFAULT_VIEWER_MAX_RESPONSE_SIZE_IN_BYTES} */ viewerMaxResponseSizeInBytes?: number; @@ -86,7 +110,7 @@ export interface LambdaEdgeAdapterOptions { * * {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html Reference} * - * @default 1024 * 1024 = 40960 = 40KB + * @default {@link DEFAULT_ORIGIN_MAX_RESPONSE_SIZE_IN_BYTES} */ originMaxResponseSizeInBytes?: number; @@ -103,6 +127,8 @@ export interface LambdaEdgeAdapterOptions { /** * Return the path to be used to create a request to the framework * + * @note You MUST append the query params from {@link DefaultQueryString}, you can use the helper {@link getPathWithQueryStringParams}. + * * @param event The event sent by the serverless * @default The value from {@link DefaultForwardPath} */ @@ -142,6 +168,7 @@ export interface LambdaEdgeAdapterOptions { * * This adapter is not fully compatible with Lambda@edge supported by @vendia/serverless-express, the request body was modified to return {@link NewLambdaEdgeBody} instead {@link OldLambdaEdgeBody}. * Also, the response has been modified to return entire body sent by the framework, in this form you MUST return the body from the framework in the format of {@link CloudFrontRequestResult}. + * And when we get an error during the forwarding to the framework, we call `resolver.fail` instead of trying to return status 500 like the old implementation was. * * {@link https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html Lambda edge docs} * {@link https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html Event Reference} @@ -271,23 +298,23 @@ export class LambdaEdgeAdapter JSON.stringify(response), ).length; - const isOrigiRequestOrResponse = this.isEventTypeOrigin( - props.event?.Records[0]?.cf.config, + const isOriginRequestOrResponse = this.isEventTypeOrigin( + props.event.Records[0].cf.config, ); - const maxSizeInBytes = isOrigiRequestOrResponse + const maxSizeInBytes = isOriginRequestOrResponse ? getDefaultIfUndefined( this.options?.originMaxResponseSizeInBytes, - 1024 * 1024, + DEFAULT_ORIGIN_MAX_RESPONSE_SIZE_IN_BYTES, ) : getDefaultIfUndefined( this.options?.viewerMaxResponseSizeInBytes, - 1024 * 40, + DEFAULT_VIEWER_MAX_RESPONSE_SIZE_IN_BYTES, ); if (responseToServiceBytes <= maxSizeInBytes) return response; if (this.options?.onResponseSizeExceedLimit) - this.options?.onResponseSizeExceedLimit(response); + this.options.onResponseSizeExceedLimit(response); else { props.log.error( `SERVERLESS_ADAPTER:LAMBDA_EDGE_ADAPTER: Max response size exceeded: ${responseToServiceBytes} of the max of ${maxSizeInBytes}.`, @@ -303,19 +330,8 @@ export class LambdaEdgeAdapter public onErrorWhileForwarding({ error, resolver, - respondWithErrors, }: OnErrorProps): void { - const body = respondWithErrors ? error.stack : undefined; - - const errorResponse: CloudFrontResultResponse = { - status: '500', - body: JSON.stringify(body), - headers: {}, - bodyEncoding: 'text', - statusDescription: 'Internal Server Error', - }; - - resolver.succeed(errorResponse); + resolver.fail(error); } //#endregion @@ -357,6 +373,19 @@ export class LambdaEdgeAdapter const parsedBody: CloudFrontResultResponse | CloudFrontRequest = JSON.parse(body); + if (parsedBody.headers) { + parsedBody.headers = Object.keys(parsedBody.headers).reduce( + (acc, header) => { + if (this.shouldStripHeader(header)) return acc; + + acc[header] = parsedBody.headers![header]; + + return acc; + }, + {} as CloudFrontHeaders, + ); + } + if (!shouldUseHeadersFromFramework) return parsedBody; parsedBody.headers = this.getHeadersForCloudfrontResponse(frameworkHeaders); @@ -406,6 +435,9 @@ export class LambdaEdgeAdapter * @param headerKey The header that will be tested */ protected shouldStripHeader(headerKey: string): boolean { + if (this.options?.shouldStripHeader) + return this.options.shouldStripHeader(headerKey); + const headerKeyLowerCase = headerKey.toLowerCase(); for (const stripHeaderIf of this.cachedDisallowedHeaders) { diff --git a/src/v2/contracts/adapter.contract.ts b/src/v2/contracts/adapter.contract.ts index f9cb0427..589489c9 100644 --- a/src/v2/contracts/adapter.contract.ts +++ b/src/v2/contracts/adapter.contract.ts @@ -1,4 +1,4 @@ -import { SingleValueHeaders } from '../@types'; +import { BothValueHeaders, SingleValueHeaders } from '../@types'; import { ILogger } from '../core'; import { ServerlessResponse } from '../network'; import { Resolver } from './resolver.contract'; @@ -92,7 +92,7 @@ export interface GetResponseAdapterProps { /** * The framework response headers */ - headers: Record; + headers: BothValueHeaders; /** * Indicates whether the response is base64 encoded or not diff --git a/src/v2/core/path.ts b/src/v2/core/path.ts index 96332571..a87c1676 100644 --- a/src/v2/core/path.ts +++ b/src/v2/core/path.ts @@ -20,6 +20,8 @@ export function getPathWithQueryStringParams( | undefined | null, ): string { + if (String(queryParams || '').length === 0) return path; + if (typeof queryParams === 'string') return `${path}?${queryParams}`; const queryParamsString = getQueryParamsStringFromRecord(queryParams); diff --git a/test/adapters/aws/lambda-edge.adapter.spec.ts b/test/adapters/aws/lambda-edge.adapter.spec.ts new file mode 100644 index 00000000..30f855b5 --- /dev/null +++ b/test/adapters/aws/lambda-edge.adapter.spec.ts @@ -0,0 +1,510 @@ +import { join } from 'path'; +import type { CloudFrontHeaders } from 'aws-lambda/common/cloudfront'; +import type { + CloudFrontRequestEvent, + CloudFrontRequestResult, +} from 'aws-lambda/trigger/cloudfront-request'; +import { + BothValueHeaders, + MultiValueHeaders, + SingleValueHeaders, +} from '../../../src/v2/@types'; +import { + DEFAULT_LAMBDA_EDGE_DISALLOWED_HEADERS, + DEFAULT_ORIGIN_MAX_RESPONSE_SIZE_IN_BYTES, + DEFAULT_VIEWER_MAX_RESPONSE_SIZE_IN_BYTES, + LambdaEdgeAdapter, +} from '../../../src/v2/adapters/aws'; +import { Resolver } from '../../../src/v2/contracts'; +import { ILogger } from '../../../src/v2/core'; +import { createCanHandleTestsForAdapter } from '../utils/can-handle'; +import { + createLambdaEdgeOriginEvent, + createLambdaEdgeViewerEvent, +} from './utils/lambda-edge'; + +describe(LambdaEdgeAdapter.name, () => { + let adapter!: LambdaEdgeAdapter; + + beforeEach(() => { + adapter = new LambdaEdgeAdapter(); + }); + + describe('getAdapterName', () => { + it('should be the same name of the class', () => { + expect(adapter.getAdapterName()).toBe(LambdaEdgeAdapter.name); + }); + }); + + createCanHandleTestsForAdapter(() => new LambdaEdgeAdapter(), undefined); + + describe('getRequest', () => { + it('should return the correct mapping for the request', () => { + const events: [ + factory: + | typeof createLambdaEdgeOriginEvent + | typeof createLambdaEdgeViewerEvent, + method: string, + path: string, + body?: any, + ][] = [ + [createLambdaEdgeOriginEvent, 'GET', '/image.png', undefined], + [ + createLambdaEdgeOriginEvent, + 'POST', + 'batata.png', + { + base64: Buffer.from('batata', 'utf-8').toString('base64'), + }, + ], + [createLambdaEdgeViewerEvent, 'GET', '/image4343.png', undefined], + [ + createLambdaEdgeViewerEvent, + 'PUT', + 'banana.png', + { + base64: Buffer.from('batata', 'utf-8').toString('base64'), + }, + ], + ]; + + for (const [createEvent, method, path, body] of events) { + const lambdaEdgeEvent = createEvent(method, path, body); + const cloudfrontRequest = lambdaEdgeEvent.Records[0].cf.request; + + const result = adapter.getRequest(lambdaEdgeEvent); + + const keys = Object.keys(result); + const expectedKeys = [ + 'method', + 'path', + 'headers', + 'body', + 'remoteAddress', + 'host', + 'hostname', + ]; + + expect(keys.length === expectedKeys.length).toBe(true); + expect(keys.every(key => expectedKeys.includes(key))).toBe(true); + + expect(result.method).toBe(method); + expect(result.path).toBe(path); + + const someHeaderValueIsArray = Object.values(result.headers).some( + Array.isArray, + ); + + expect(someHeaderValueIsArray).toBe(false); + + const headerKeys = Object.keys(result.headers); + const expectedHeaderKeys = Object.keys(cloudfrontRequest.headers); + + if (result.body) expectedHeaderKeys.push('content-length'); + + expect(headerKeys.length === expectedHeaderKeys.length).toBe(true); + expect(headerKeys.every(key => expectedHeaderKeys.includes(key))).toBe( + true, + ); + + if (result.body === undefined) expect(result.body).toBeUndefined(); + else { + const dataAsBase64 = Buffer.from( + JSON.stringify(body), + 'utf-8', + ).toString('base64'); + + const jsonString = JSON.stringify({ + action: 'read-only', + encoding: 'base64', + inputTruncated: false, + data: dataAsBase64, + }); + + expect(result.body.toString('utf-8')).toBe(jsonString); + } + + expect(result.remoteAddress).toBe(cloudfrontRequest.clientIp); + + const host = cloudfrontRequest.headers['host'][0].value; + + expect(result.host).toBe(host); + expect(result.hostname).toBe(host); + } + }); + + it('should return the correct mapping for the request with query params', () => { + const lambdaEvent = createLambdaEdgeOriginEvent( + 'GET', + '/image_of_apple.png', + undefined, + undefined, + 'pretty=true', + ); + + const result = adapter.getRequest(lambdaEvent); + + expect(result.path).toBe('/image_of_apple.png?pretty=true'); + }); + + it('should return the correct mapping for the request with custom path function', () => { + const lambdaEvent = createLambdaEdgeOriginEvent( + 'GET', + '/image2.png', + undefined, + undefined, + 'potato=true', + ); + + const customAdapter = new LambdaEdgeAdapter({ + getPathFromEvent: event => join('/prod', event.cf.request.uri), + }); + + const result = customAdapter.getRequest(lambdaEvent); + + expect(result.path).toBe('/prod/image2.png'); + + // certifies the behavior described in the comments of `getPathFromEvent`. + expect(result.path).not.toBe('/prod/image2.png?potato=true'); + }); + }); + + describe('getResponse', () => { + it('should return the correct mapping for the response', () => { + const options: CloudFrontRequestEvent[] = [ + createLambdaEdgeOriginEvent('GET', '/potato.png'), + createLambdaEdgeViewerEvent('GET', '/apple.png'), + ]; + + for (const event of options) { + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const result = adapter.getResponse({ + event, + body, + headers: {}, + log: {} as ILogger, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(result).toBeDefined(); + + expect(result).toHaveProperty('headers'); + expect(result!.headers).toHaveProperty( + 'host', + cloudFrontRequest.headers['host'], + ); + + expect(result).toHaveProperty('clientIp', cloudFrontRequest.clientIp); + expect(result).toHaveProperty('method', cloudFrontRequest.method); + expect(result).toHaveProperty('origin', cloudFrontRequest.origin); + expect(result).toHaveProperty( + 'querystring', + cloudFrontRequest.querystring, + ); + expect(result).toHaveProperty('uri', cloudFrontRequest.uri); + + expect(result).not.toHaveProperty('body'); + } + }); + + it('should return the correct mapping for the response even if we reach the max response size', () => { + const bigResponseForOrigin = new Array( + DEFAULT_ORIGIN_MAX_RESPONSE_SIZE_IN_BYTES + 1, + ).map(() => 'a'); + const bigResponseForView = new Array( + DEFAULT_VIEWER_MAX_RESPONSE_SIZE_IN_BYTES + 1, + ).map(() => 'b'); + + const options: CloudFrontRequestEvent[] = [ + createLambdaEdgeOriginEvent('GET', '/potato.png', { + bigResponseForOrigin, + }), + createLambdaEdgeViewerEvent('GET', '/apple.png', { + bigResponseForView, + }), + ]; + + for (const event of options) { + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const log = { + error: jest.fn(message => + expect(message).toContain('Max response size exceeded'), + ) as any, + } as ILogger; + + adapter.getResponse({ + event, + body, + headers: {}, + log, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(log.error).toHaveBeenCalledTimes(1); + } + }); + + it('should return the correct mapping for the response with option "shouldUseHeadersFromFramework"', () => { + const event = createLambdaEdgeViewerEvent('GET', '/potato.png'); + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const customAdapter = new LambdaEdgeAdapter({ + shouldUseHeadersFromFramework: true, + }); + + const options: BothValueHeaders[] = [ + { batata: 'true' }, + { batata: ['true'] }, + ]; + + for (const headers of options) { + const result = customAdapter.getResponse({ + event, + body, + headers, + log: {} as ILogger, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(result!.headers!['batata']).toStrictEqual([ + { key: 'batata', value: 'true' }, + ]); + + expect(result!.headers!['batata']).not.toStrictEqual([ + { key: 'batata', value: Math.random().toString() }, + ]); + } + }); + + it('should return the correct mapping for the response with option "disallowedHeaders"', () => { + const disallowedHeadersList = + DEFAULT_LAMBDA_EDGE_DISALLOWED_HEADERS.filter( + header => typeof header === 'string', + ) as string[]; + + expect(disallowedHeadersList.length > 0).toBe(true); + + const allDisallowedHeadersMap: SingleValueHeaders = {}; + const allDisallowedMultiValueHeadersMap: MultiValueHeaders = {}; + const allDisallowedCloudfrontHeaders: CloudFrontHeaders = {}; + + for (const header of disallowedHeadersList) { + allDisallowedHeadersMap[header] = Math.random().toString(); + allDisallowedMultiValueHeadersMap[header] = [Math.random().toString()]; + allDisallowedCloudfrontHeaders[header] = [ + { key: header, value: Math.random().toString() }, + ]; + } + + const options: [ + adapter: LambdaEdgeAdapter, + event: CloudFrontRequestEvent, + headers: BothValueHeaders, + ][] = [ + [ + new LambdaEdgeAdapter({ + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeViewerEvent( + 'GET', + '/potato.png', + undefined, + allDisallowedCloudfrontHeaders, + ), + {}, + ], + [ + new LambdaEdgeAdapter({ + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeOriginEvent( + 'GET', + '/potato.png', + undefined, + allDisallowedCloudfrontHeaders, + ), + {}, + ], + [ + new LambdaEdgeAdapter({ + shouldUseHeadersFromFramework: true, + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeViewerEvent('GET', '/potato.png'), + allDisallowedHeadersMap, + ], + [ + new LambdaEdgeAdapter({ + shouldUseHeadersFromFramework: true, + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeViewerEvent('GET', '/apple.png'), + allDisallowedMultiValueHeadersMap, + ], + [ + new LambdaEdgeAdapter({ + shouldUseHeadersFromFramework: true, + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeOriginEvent('GET', '/apple.png'), + allDisallowedHeadersMap, + ], + [ + new LambdaEdgeAdapter({ + shouldUseHeadersFromFramework: true, + disallowedHeaders: disallowedHeadersList, + }), + createLambdaEdgeOriginEvent('GET', '/apple.png'), + allDisallowedMultiValueHeadersMap, + ], + ]; + + for (const [customAdapter, event, headers] of options) { + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const result = customAdapter.getResponse({ + event, + body, + headers, + log: {} as ILogger, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(Object.keys(result!.headers!)).toHaveLength(0); + } + }); + + it('should return the correct mapping for the response with option "shouldStripHeader"', () => { + const customAdapter = new LambdaEdgeAdapter({ + shouldStripHeader: () => true, + }); + + const options: CloudFrontRequestEvent[] = [ + createLambdaEdgeViewerEvent('GET', '/potato.png'), + createLambdaEdgeOriginEvent('GET', '/apple.png'), + ]; + + for (const event of options) { + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const result = customAdapter.getResponse({ + event, + body, + headers: {}, + log: {} as ILogger, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(Object.keys(result!.headers!)).toHaveLength(0); + } + }); + + it('should return the correct mapping for the response with option "onResponseSizeExceedLimit"', () => { + const customAdapter = new LambdaEdgeAdapter({ + originMaxResponseSizeInBytes: 0, + viewerMaxResponseSizeInBytes: 0, + }); + + const options: CloudFrontRequestEvent[] = [ + createLambdaEdgeViewerEvent('GET', '/potato.png', { potato: true }), + createLambdaEdgeOriginEvent('GET', '/apple.png', { apple: true }), + ]; + + for (const event of options) { + const cloudFrontRequest = event.Records[0].cf.request; + const body = JSON.stringify(cloudFrontRequest); + + const log = { + error: jest.fn(message => + expect(message).toContain('Max response size exceeded'), + ) as any, + } as ILogger; + + const result = customAdapter.getResponse({ + event, + body, + headers: {}, + log, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(result).toBeDefined(); + expect(log.error).toHaveBeenCalledTimes(1); + + const onResponseSizeExceedLimit = jest.fn(); + + const customAdapter2 = new LambdaEdgeAdapter({ + originMaxResponseSizeInBytes: 0, + viewerMaxResponseSizeInBytes: 0, + onResponseSizeExceedLimit, + }); + + customAdapter2.getResponse({ + event, + body, + headers: {}, + log: {} as ILogger, + statusCode: 200, + isBase64Encoded: false, + }); + + expect(onResponseSizeExceedLimit).toHaveBeenCalled(); + } + }); + }); + + describe('onErrorWhileForwarding', () => { + it('should resolver call succeed', () => { + const options: [ + event: CloudFrontRequestEvent, + respondWithError: boolean, + ][] = [ + [ + createLambdaEdgeViewerEvent('GET', '/potato.png', { potato: true }), + false, + ], + [ + createLambdaEdgeOriginEvent('GET', '/apple.png', { apple: true }), + false, + ], + [createLambdaEdgeViewerEvent('GET', '/mapple.png'), true], + [createLambdaEdgeOriginEvent('GET', '/juice.png'), true], + ]; + + for (const [event, respondWithErrors] of options) { + const log = {} as ILogger; + + const resolver: Resolver = { + fail: jest.fn(), + succeed: jest.fn(), + }; + + const error = new Error('Test error'); + + adapter.onErrorWhileForwarding({ + event, + log, + resolver, + respondWithErrors, + error, + }); + + expect(resolver.fail).toHaveBeenCalledTimes(1); + expect(resolver.succeed).toHaveBeenCalledTimes(0); + } + }); + }); +}); diff --git a/test/adapters/aws/utils/lambda-edge.ts b/test/adapters/aws/utils/lambda-edge.ts new file mode 100644 index 00000000..ea515fc7 --- /dev/null +++ b/test/adapters/aws/utils/lambda-edge.ts @@ -0,0 +1,209 @@ +import type { CloudFrontHeaders } from 'aws-lambda/common/cloudfront'; +import type { CloudFrontRequestEvent } from 'aws-lambda/trigger/cloudfront-request'; + +export function createLambdaEdgeViewerEvent( + httpMethod: string, + path: string, + body?: Record, + headers?: CloudFrontHeaders, + queryParams?: string, +): CloudFrontRequestEvent { + return { + Records: [ + { + cf: { + config: { + distributionDomainName: 'd3qj9vk9486y6c.cloudfront.net', + distributionId: 'E2I5C7O4FEQEKZ', + eventType: 'viewer-request', + requestId: + 'BKXC0kFgBfWSEgribSo9EwziZB1FztiXQ96VRvTfFNHYCBv7Ko-RBQ==', + }, + request: { + clientIp: '203.123.103.37', + headers: headers ?? { + host: [ + { + key: 'Host', + value: 'd3qj9vk9486y6c.cloudfront.net', + }, + ], + 'user-agent': [ + { + key: 'User-Agent', + value: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36', + }, + ], + 'cache-control': [ + { + key: 'Cache-Control', + value: 'max-age=0', + }, + ], + accept: [ + { + key: 'accept', + value: + 'application/json,text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', + }, + ], + 'if-none-match': [ + { + key: 'if-none-match', + value: 'W/"2e-Lu6qxFOQSPFulDAGUFiiK6QgREo"', + }, + ], + 'accept-language': [ + { + key: 'accept-language', + value: 'en-US,en;q=0.9', + }, + ], + 'upgrade-insecure-requests': [ + { + key: 'upgrade-insecure-requests', + value: '1', + }, + ], + origin: [ + { + key: 'Origin', + value: 'https://d3qj9vk9486y6c.cloudfront.net', + }, + ], + 'sec-fetch-site': [ + { + key: 'Sec-Fetch-Site', + value: 'same-origin', + }, + ], + 'sec-fetch-mode': [ + { + key: 'Sec-Fetch-Mode', + value: 'cors', + }, + ], + 'sec-fetch-dest': [ + { + key: 'Sec-Fetch-Dest', + value: 'empty', + }, + ], + referer: [ + { + key: 'Referer', + value: 'https://d3qj9vk9486y6c.cloudfront.net/users', + }, + ], + 'accept-encoding': [ + { + key: 'Accept-Encoding', + value: 'gzip, deflate, br', + }, + ], + }, + body: body + ? { + action: 'read-only', + encoding: 'base64', + inputTruncated: false, + data: Buffer.from(JSON.stringify(body), 'utf-8').toString( + 'base64', + ), + } + : undefined, + method: httpMethod, + querystring: queryParams || '', + uri: path, + }, + }, + }, + ], + }; +} + +export function createLambdaEdgeOriginEvent( + httpMethod: string, + path: string, + body?: Record, + headers?: CloudFrontHeaders, + queryParams?: string, +): CloudFrontRequestEvent { + return { + Records: [ + { + cf: { + config: { + distributionDomainName: 'd111111abcdef8.cloudfront.net', + distributionId: 'EDFDVBD6EXAMPLE', + eventType: 'origin-request', + requestId: + '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', + }, + request: { + body: body + ? { + action: 'read-only', + encoding: 'base64', + inputTruncated: false, + data: Buffer.from(JSON.stringify(body), 'utf-8').toString( + 'base64', + ), + } + : undefined, + clientIp: '203.0.113.178', + headers: headers ?? { + 'x-forwarded-for': [ + { + key: 'X-Forwarded-For', + value: '203.0.113.178', + }, + ], + 'user-agent': [ + { + key: 'User-Agent', + value: 'Amazon CloudFront', + }, + ], + via: [ + { + key: 'Via', + value: + '2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)', + }, + ], + host: [ + { + key: 'Host', + value: 'example.org', + }, + ], + 'cache-control': [ + { + key: 'Cache-Control', + value: 'no-cache, cf-no-cache', + }, + ], + }, + method: httpMethod, + origin: { + custom: { + customHeaders: {}, + domainName: 'example.org', + keepaliveTimeout: 5, + path: '', + port: 443, + protocol: 'https', + readTimeout: 30, + sslProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], + }, + }, + querystring: queryParams || '', + uri: path, + }, + }, + }, + ], + }; +}