diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..39ebc35 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +name: Test + +on: + push: + branches: + - main + +jobs: + main: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@v2 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 14.x + - name: Test + run: | + ./test/test.sh diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af6e144 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "javascript.format.insertSpaceAfterCommaDelimiter": false, + "javascript.format.insertSpaceAfterFunctionKeywordForAnonymousFunctions": false, + "javascript.format.insertSpaceAfterOpeningAndBeforeClosingEmptyBraces": false, + "javascript.format.semicolons": "insert", + "[javascript]": { + "editor.formatOnSave": true, + }, + "[json]": { + "editor.formatOnSave": true, + }, +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bba3195 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Peter Mescalchin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4097d19 --- /dev/null +++ b/README.md @@ -0,0 +1,582 @@ +# Edgy + +[![Test](https://github.com/magnetikonline/edgy/actions/workflows/test.yaml/badge.svg)](https://github.com/magnetikonline/edgy/actions/workflows/test.yaml) + +Npm module providing a harness for authoring unit or integration tests against Node.js based AWS CloudFront [Lambda@Edge](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html) functions. + +- [What can this do?](#what-can-this-do) +- [Usage](#usage) + - [ViewerRequest()](#viewerrequest) + - [OriginRequest()](#originrequest) + - [OriginResponse()](#originresponse) + - [ViewerResponse()](#viewerresponse) +- [Methods](#methods) + - [setDistributionDomainName(name)](#setdistributiondomainnamename) + - [setDistributionId(id)](#setdistributionidid) + - [setRequestId(id)](#setrequestidid) + - [setClientIp(ipAddr)](#setclientipipaddr) + - [setHttpMethod(method)](#sethttpmethodmethod) + - [setQuerystring(qs)](#setquerystringqs) + - [setUri(uri)](#seturiuri) + - [setRequestBody(data[,isTruncated])](#setrequestbodydataistruncated) + - [addRequestHttpHeader(key,value)](#addrequesthttpheaderkeyvalue) + - [setRequestOriginCustom(domainName[,path])](#setrequestorigincustomdomainnamepath) + - [setRequestOriginKeepaliveTimeout(timeout)](#setrequestoriginkeepalivetimeouttimeout) + - [setRequestOriginPort(port)](#setrequestoriginportport) + - [setRequestOriginHttps(isHttps)](#setrequestoriginhttpsishttps) + - [setRequestOriginReadTimeout(timeout)](#setrequestoriginreadtimeouttimeout) + - [setRequestOriginSslProtocolList(protocolList)](#setrequestoriginsslprotocollistprotocollist) + - [setRequestOriginS3(domainName[,region][,path])](#setrequestorigins3domainnameregionpath) + - [setRequestOriginOAI(isOAI)](#setrequestoriginoaiisoai) + - [addRequestOriginHttpHeader(key,value)](#addrequestoriginhttpheaderkeyvalue) + - [setResponseHttpStatusCode(code)](#setresponsehttpstatuscodecode) + - [addResponseHttpHeader(key,value)](#addresponsehttpheaderkeyvalue) + - [execute(handler)](#executehandler) +- [Reference](#reference) + +## What can this do? + +Edgy provides the following: + +- Generation of Lambda@Edge [event structures](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html) for the four available request life cycle points (viewer request, origin request, origin response, viewer response). +- Execution of Lambda@Edge functions in a manner _somewhat similar_ to the CloudFront runtime. Both [`async` and older callback style](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html) handlers are supported. +- Implements various checks and bounds (duck typing) of payloads returned from edge functions, with the [`execute(handler)`](#executehandler) harness function throwing errors for anything deemed to be malformed. +- Captures the executed Lambda@Edge function payload, allowing for further testing and assertions. + +## Usage + +Edgy provides four core constructors, which directly relate to each of the [four life cycle points](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html) available in a CloudFront request. With an instance created, the desired event structure is then crafted and a supplied Lambda@Edge function executed against it. + +### `ViewerRequest()` + +An example of crafting a [viewer request](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-viewer-request) event payload and executing a dummy function against it: + +```js +const edgy = require('@magnetikonline/edgy'); + +async function myTest() { + const vReq = new edgy.ViewerRequest(); + vReq + .setClientIp('1.2.3.4') + .setHttpMethod('PUT') + .setUri('/path/to/api/route') + .addRequestHttpHeader('X-Fancy-Header','apples'); + + const resp = await vReq.execute( + // example edge function + async function(event) { + return event.Records[0].cf.request; + } + ); + + console.dir(resp,{ depth: null }); + + /* + { + clientIp: '1.2.3.4', + headers: { 'x-fancy-header': [ { key: 'X-Fancy-Header', value: 'apples' } ] }, + method: 'PUT', + querystring: '', + uri: '/path/to/api/route' + } + */ +} +``` + +Available methods: + +- [setDistributionDomainName(name)](#setdistributiondomainnamename) +- [setDistributionId(id)](#setdistributionidid) +- [setRequestId(id)](#setrequestidid) +- [setClientIp(ipAddr)](#setclientipipaddr) +- [setHttpMethod(method)](#sethttpmethodmethod) +- [setQuerystring(qs)](#setquerystringqs) +- [setUri(uri)](#seturiuri) +- [setRequestBody(data[,isTruncated])](#setrequestbodydataistruncated) +- [addRequestHttpHeader(key,value)](#addrequesthttpheaderkeyvalue) +- [execute(handler)](#executehandler) + +### `OriginRequest()` + +An example of crafting a [origin request](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#example-origin-request) event payload and executing a dummy function against it: + +```js +const edgy = require('@magnetikonline/edgy'); + +async function myTest() { + const oReq = new edgy.OriginRequest(); + oReq + .setClientIp('1.2.3.4') + .setHttpMethod('POST') + .setUri('/path/to/api/route') + .addRequestHttpHeader('X-Fancy-Header','apples') + .setRequestOriginS3('mybucket.s3.ap-southeast-2.amazonaws.com','ap-southeast-2'); + + const resp = await oReq.execute( + // example edge function + async function(event) { + return event.Records[0].cf.request; + } + ); + + console.dir(resp,{ depth: null }); + + /* + { + clientIp: '1.2.3.4', + headers: { 'x-fancy-header': [ { key: 'X-Fancy-Header', value: 'apples' } ] }, + method: 'POST', + querystring: '', + uri: '/path/to/api/route', + origin: { + s3: { + authMethod: 'none', + customHeaders: {}, + domainName: 'mybucket.s3.ap-southeast-2.amazonaws.com', + path: '/', + region: 'ap-southeast-2' + } + } + } + */ +} +``` + +Available methods: + +- [setDistributionDomainName(name)](#setdistributiondomainnamename) +- [setDistributionId(id)](#setdistributionidid) +- [setRequestId(id)](#setrequestidid) +- [setClientIp(ipAddr)](#setclientipipaddr) +- [setHttpMethod(method)](#sethttpmethodmethod) +- [setQuerystring(qs)](#setquerystringqs) +- [setUri(uri)](#seturiuri) +- [setRequestBody(data[,isTruncated])](#setrequestbodydataistruncated) +- [addRequestHttpHeader(key,value)](#addrequesthttpheaderkeyvalue) +- [setRequestOriginCustom(domainName[,path])](#setrequestorigincustomdomainnamepath) +- [setRequestOriginKeepaliveTimeout(timeout)](#setrequestoriginkeepalivetimeouttimeout) +- [setRequestOriginPort(port)](#setrequestoriginportport) +- [setRequestOriginHttps(isHttps)](#setrequestoriginhttpsishttps) +- [setRequestOriginReadTimeout(timeout)](#setrequestoriginreadtimeouttimeout) +- [setRequestOriginSslProtocolList(protocolList)](#setrequestoriginsslprotocollistprotocollist) +- [setRequestOriginS3(domainName[,region][,path])](#setrequestorigins3domainnameregionpath) +- [setRequestOriginOAI(isOAI)](#setrequestoriginoaiisoai) +- [addRequestOriginHttpHeader(key,value)](#addrequestoriginhttpheaderkeyvalue) +- [execute(handler)](#executehandler) + +### `OriginResponse()` + +An example of crafting a [origin response](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#lambda-event-structure-response-origin) event payload and executing a dummy function against it: + +```js +const edgy = require('@magnetikonline/edgy'); + +async function myTest() { + const oRsp = new edgy.OriginResponse(); + oRsp + .setResponseHttpStatusCode(202) + .addResponseHttpHeader('X-Fancy-Header','oranges'); + + const resp = await oRsp.execute( + // example edge function + async function(event) { + return event.Records[0].cf.response; + } + ); + + console.dir(resp,{ depth: null }); + + /* + { + headers: { 'x-fancy-header': [ { key: 'X-Fancy-Header', value: 'oranges' } ] }, + status: '202', + statusDescription: 'Accepted' + } + */ +} +``` + +Available methods: + +- [setDistributionDomainName(name)](#setdistributiondomainnamename) +- [setDistributionId(id)](#setdistributionidid) +- [setRequestId(id)](#setrequestidid) +- [setClientIp(ipAddr)](#setclientipipaddr) +- [setHttpMethod(method)](#sethttpmethodmethod) +- [setQuerystring(qs)](#setquerystringqs) +- [setUri(uri)](#seturiuri) +- [addRequestHttpHeader(key,value)](#addrequesthttpheaderkeyvalue) +- [setRequestOriginCustom(domainName[,path])](#setrequestorigincustomdomainnamepath) +- [setRequestOriginKeepaliveTimeout(timeout)](#setrequestoriginkeepalivetimeouttimeout) +- [setRequestOriginPort(port)](#setrequestoriginportport) +- [setRequestOriginHttps(isHttps)](#setrequestoriginhttpsishttps) +- [setRequestOriginReadTimeout(timeout)](#setrequestoriginreadtimeouttimeout) +- [setRequestOriginSslProtocolList(protocolList)](#setrequestoriginsslprotocollistprotocollist) +- [setRequestOriginS3(domainName[,region][,path])](#setrequestorigins3domainnameregionpath) +- [setRequestOriginOAI(isOAI)](#setrequestoriginoaiisoai) +- [addRequestOriginHttpHeader(key,value)](#addrequestoriginhttpheaderkeyvalue) +- [setResponseHttpStatusCode(code)](#setresponsehttpstatuscodecode) +- [addResponseHttpHeader(key,value)](#addresponsehttpheaderkeyvalue) +- [execute(handler)](#executehandler) + +### `ViewerResponse()` + +An example of crafting a [viewer response](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html#lambda-event-structure-response-viewer) event payload and executing a dummy function against it: + +```js +const edgy = require('@magnetikonline/edgy'); + +async function myTest() { + const vRsp = new edgy.ViewerResponse(); + vRsp + .setResponseHttpStatusCode(304) + .addResponseHttpHeader('X-Fancy-Header','oranges'); + + const resp = await vRsp.execute( + // example edge function + async function(event) { + return event.Records[0].cf.response; + } + ); + + console.dir(resp,{ depth: null }); + + /* + { + headers: { 'x-fancy-header': [ { key: 'X-Fancy-Header', value: 'oranges' } ] }, + status: '304', + statusDescription: 'Not Modified' + } + */ +} +``` + +Available methods: + +- [setDistributionDomainName(name)](#setdistributiondomainnamename) +- [setDistributionId(id)](#setdistributionidid) +- [setRequestId(id)](#setrequestidid) +- [setClientIp(ipAddr)](#setclientipipaddr) +- [setHttpMethod(method)](#sethttpmethodmethod) +- [setQuerystring(qs)](#setquerystringqs) +- [setUri(uri)](#seturiuri) +- [addRequestHttpHeader(key,value)](#addrequesthttpheaderkeyvalue) +- [setResponseHttpStatusCode(code)](#setresponsehttpstatuscodecode) +- [addResponseHttpHeader(key,value)](#addresponsehttpheaderkeyvalue) +- [execute(handler)](#executehandler) + +## Methods + +### `setDistributionDomainName(name)` + +### `setDistributionId(id)` + +### `setRequestId(id)` + +Methods to set properties related to the CloudFront distribution: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setDistributionDomainName('d111111abcdef8.cloudfront.net') + .setDistributionId('EDFDVBD6EXAMPLE') + .setRequestId('4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ=='); + +/* +{ + Records: [ + { + cf: { + config: { + distributionDomainName: 'd111111abcdef8.cloudfront.net', + distributionId: 'EDFDVBD6EXAMPLE', + requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==' + } + } + } + ] +} +*/ +``` + +### `setClientIp(ipAddr)` + +### `setHttpMethod(method)` + +### `setQuerystring(qs)` + +### `setUri(uri)` + +Methods to set general properties related to the request sent from the client: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setClientIp('203.0.113.178') + .setHttpMethod('GET') + .setQuerystring('?key=value') + .setUri('/path/to/route'); + +/* +{ + Records: [ + { + cf: { + request: { + clientIp: '203.0.113.178', + method: 'GET', + querystring: 'key=value', + uri: '/path/to/route' + } + } + } + ] +} +*/ +``` + +### `setRequestBody(data[,isTruncated])` + +Adds a collection of request `body` properties. The given `data` will be base64 encoded automatically: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness.setRequestBody('data payload',false); + +/* +{ + Records: [ + { + cf: { + request: { + body: { + action: 'read-only', + data: 'ZGF0YSBwYXlsb2Fk', + encoding: 'base64', + inputTruncated: false + } + } + } + } + ] +} +*/ +``` + +### `addRequestHttpHeader(key,value)` + +Adds HTTP header items to the request payload: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .addRequestHttpHeader('User-Agent','curl/7.66.0') + .addRequestHttpHeader('X-Custom-Header','apples'); + +/* +{ + Records: [ + { + cf: { + request: { + headers: { + 'user-agent': [ { key: 'User-Agent', value: 'curl/7.66.0' } ], + 'x-custom-header': [ { key: 'X-Custom-Header', value: 'apples' } ] + } + } + } + } + ] +} +*/ +``` + +### `setRequestOriginCustom(domainName[,path])` + +### `setRequestOriginKeepaliveTimeout(timeout)` + +### `setRequestOriginPort(port)` + +### `setRequestOriginHttps(isHttps)` + +### `setRequestOriginReadTimeout(timeout)` + +### `setRequestOriginSslProtocolList(protocolList)` + +Methods to define a custom origin property set for the request event payload: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setRequestOriginCustom('example.org','/custom/origin/path') + .setRequestOriginKeepaliveTimeout(35) + .setRequestOriginPort(1234) + .setRequestOriginHttps(true) + .setRequestOriginReadTimeout(25) + .setRequestOriginSslProtocolList(['TLSv1.1','TLSv1.2']); + +/* +{ + Records: [ + { + cf: { + request: { + origin: { + custom: { + customHeaders: {}, + domainName: 'example.org', + keepaliveTimeout: 35, + path: '/custom/origin/path', + port: 1234, + protocol: 'https', + readTimeout: 25, + sslProtocols: [ 'TLSv1.1', 'TLSv1.2' ] + } + } + } + } + } + ] +} +*/ +``` + +### `setRequestOriginS3(domainName[,region][,path])` + +### `setRequestOriginOAI(isOAI)` + +Methods to define an S3 origin property set for the request event payload: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setRequestOriginS3( + 'mybucket.s3.ap-southeast-2.amazonaws.com', + 'ap-southeast-2', + '/s3/bucket/path') + .setRequestOriginOAI(true); + +/* +{ + Records: [ + { + cf: { + request: { + origin: { + s3: { + authMethod: 'origin-access-identity', + customHeaders: {}, + domainName: 'mybucket.s3.ap-southeast-2.amazonaws.com', + path: '/s3/bucket/path', + region: 'ap-southeast-2' + } + } + } + } + } + ] +} +*/ +``` + +### `addRequestOriginHttpHeader(key,value)` + +Adds HTTP header items to the request origin event payload for both [custom](#setrequestorigincustomdomainnamepath) and [s3](#setrequestorigins3domainnameregionpath) targets: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setRequestOriginS3( + 'mybucket.s3.ap-southeast-2.amazonaws.com', + 'ap-southeast-2', + '/s3/bucket/path') + .addRequestOriginHttpHeader('X-Custom-Header','apples') + .addRequestOriginHttpHeader('X-Custom-Header','oranges'); + +/* +{ + Records: [ + { + cf: { + request: { + origin: { + s3: { + customHeaders: { + 'x-custom-header': [ + { key: 'X-Custom-Header', value: 'apples' }, + { key: 'X-Custom-Header', value: 'oranges' } + ] + } + } + } + } + } + } + ] +} +*/ +``` + +### `setResponseHttpStatusCode(code)` + +### `addResponseHttpHeader(key,value)` + +Methods to set properties related to the response received from the upstream CloudFront target: + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); +harness + .setResponseHttpStatusCode(304) + .addResponseHttpHeader('X-Fancy-Header','oranges'); + +/* +{ + Records: [ + { + cf: { + response: { + headers: { + 'x-fancy-header': [ { key: 'X-Fancy-Header', value: 'oranges' } ] + }, + status: '304', + statusDescription: 'Not Modified' + } + } + } + ] +} +*/ +``` + +### `execute(handler)` + +Executes a Lambda@Edge function, passing a constructed event payload. Supports both [`async` and older callback style](https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html) function handlers. + +After successful execution: + +- High level validations are performed against returned payload, verifying it should _at a minimum_ pass as a usable response by CloudFront. This should in _no way_ be considered comprehensive/complete, but should help catch obvious malformed payload cases. +- Return the transformed payload from the executed Lambda@Edge function, allowing for additional assertions to be performed. + +```js +const harness = new edgy.EVENT_TYPE_CONSTRUCTOR(); + +// --- construct Lambda@Edge event payload using instance methods --- +// .setHttpMethod() +// .setUri() +// .setQuerystring() +// etc. + +// execute function against payload +const resp = await vRsp.execute( + // example edge function + async function(event) { + return event.Records[0].cf.response; + } +); +``` + +## Reference + +- [Using AWS Lambda with CloudFront Lambda@Edge](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html) +- [Lambda@Edge event structure](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html) +- [Restrictions on edge functions](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions-restrictions.html) diff --git a/lib.js b/lib.js new file mode 100644 index 0000000..dc35bbe --- /dev/null +++ b/lib.js @@ -0,0 +1,647 @@ +'use strict'; + +const VALID_HTTP_METHOD_LIST = [ + 'DELETE', + 'GET', + 'HEAD', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', +]; + +// ref: https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html +const HTTP_STATUS_CODE_DESCRIPTION = { + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + // 306: reserved/unused + 307: 'Temporary Redirect', + + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Requested Range Not Satisfiable', + 417: 'Expectation Failed', + + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', +}; + +const VALID_SSL_PROTOCOL_LIST = [ + 'SSLv3', + 'TLSv1', + 'TLSv1.1', + 'TLSv1.2', +]; + + +class EdgeEventBase { + // properties: config + setDistributionDomainName(name) { + cfEventData(this._event).config.distributionDomainName = name; + return this; + } + + setDistributionId(id) { + cfEventData(this._event).config.distributionId = id; + return this; + } + + setRequestId(id) { + cfEventData(this._event).config.requestId = id; + return this; + } + + // properties: request + setClientIp(ipAddr) { + cfEventData(this._event).request.clientIp = ipAddr; + return this; + } + + addRequestHttpHeader(key,value) { + addEdgeEventHttpHeaderKeyValue( + cfEventData(this._event).request.headers, + key,value + ); + + return this; + } + + setHttpMethod(method) { + if (!VALID_HTTP_METHOD_LIST.includes(method)) { + throw new Error(`unexpected HTTP method of [${method}]`); + } + + cfEventData(this._event).request.method = method; + return this; + } + + setQuerystring(qs) { + // strip any leading question mark(s) and whitespace + qs = qs.trim().replace(/^[? ]+/,''); + + cfEventData(this._event).request.querystring = qs; + return this; + } + + setUri(uri) { + // `uri` must start with a single forward slash + uri = uri.trim().replace(/^[/ ]+/,''); + + cfEventData(this._event).request.uri = `/${uri}`; + return this; + } + + async execute(handler) { + // create copy of CloudFront Lambda@Edge event and execute Lambda@Edge handler + const event = cfEventClone(this._event); + + // execute handler and validate returned/mutated payload + const payload = await executeHandler(handler,event); + this._payloadVerify(payload); + + return payload; + } +} + +class EdgeEventRequestBase extends EdgeEventBase { + constructor(eventType,hasOrigin) { + super(); + this._event = buildEventBase(eventType,hasOrigin,false); + } + + // properties: request + setRequestBody(data,isTruncated = false) { + // note: `data` will be base64 encoded for the `cf.request.body.data` property + cfEventData(this._event).request.body = { + action: 'read-only', + data: Buffer.from(data || '').toString('base64'), + encoding: 'base64', + inputTruncated: !!isTruncated, + }; + + return this; + } +} + +class EdgeEventResponseBase extends EdgeEventBase { + constructor(eventType,hasOrigin) { + super(); + this._event = buildEventBase(eventType,hasOrigin,true); + } + + // properties: response + addResponseHttpHeader(key,value) { + addEdgeEventHttpHeaderKeyValue( + cfEventData(this._event).response.headers, + key,value + ); + + return this; + } + + setResponseHttpStatusCode(code) { + setEdgeEventResponseHttpStatusCode(this._event,code); + return this; + } +} + +function buildEventBase(eventType,hasOrigin,hasResponse) { + // common payload properties + const event = { + Records: [{ + cf: { + config: { + distributionDomainName: undefined, + distributionId: undefined, + eventType: eventType, + requestId: undefined, + }, + request: { + // note: skipping `body` property - added with call to `EdgeEventRequestBase.setRequestBody()` + clientIp: '127.0.0.1', + headers: {}, + method: 'GET', + querystring: '', + uri: '/', + }, + } + }] + }; + + if (hasOrigin) { + // additional origin (`origin-request` / `origin-response`) payload properties + cfEventData(event).request.origin = {}; + } + + if (hasResponse) { + // additional response payload properties + cfEventData(event).response = { headers: {} }; + setEdgeEventResponseHttpStatusCode(event,200); // default to HTTP 200 + } + + return event; +} + +function setEdgeEventRequestOriginCustom(event,domainName,path) { + cfEventData(event).request.origin = { + custom: { + customHeaders: {}, + domainName: domainName, + keepaliveTimeout: 1, + path: (path || '/'), + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + }; +} + +function setEdgeEventRequestOriginKeepaliveTimeout(event,timeout) { + verifyEdgeEventRequestOriginModeCustom(event); + cfEventData(event).request.origin.custom.keepaliveTimeout = intOrZero(timeout); +} + +function setEdgeEventRequestOriginPort(event,port) { + verifyEdgeEventRequestOriginModeCustom(event); + cfEventData(event).request.origin.custom.port = intOrZero(port); +} + +function setEdgeEventRequestOriginHttps(event,isHttps) { + verifyEdgeEventRequestOriginModeCustom(event); + cfEventData(event).request.origin.custom.protocol = (!!isHttps) ? 'https' : 'http'; +} + +function setEdgeEventRequestOriginReadTimeout(event,timeout) { + verifyEdgeEventRequestOriginModeCustom(event); + cfEventData(event).request.origin.custom.readTimeout = intOrZero(timeout); +} + +function setEdgeEventRequestOriginSslProtocolList(event,protocolList) { + verifyEdgeEventRequestOriginModeCustom(event); + + if (!Array.isArray(protocolList)) { + throw new Error('protocol list must be an array'); + } + + const resultList = []; + for (const item of VALID_SSL_PROTOCOL_LIST) { + if (protocolList.includes(item)) resultList.push(item); + } + + cfEventData(event).request.origin.custom.sslProtocols = resultList; +} + +function setEdgeEventRequestOriginS3(event,domainName,region,path) { + cfEventData(event).request.origin = { + s3: { + authMethod: 'none', + customHeaders: {}, + domainName: domainName, + path: (path || '/'), + region: (region || ''), + } + }; +} + +function setEdgeEventRequestOriginOAI(event,isOAI) { + verifyEdgeEventRequestOriginModeS3(event); + cfEventData(event).request.origin.s3.authMethod = (!!isOAI) ? 'origin-access-identity' : 'none'; +} + +// addEdgeEventRequestOriginHttpHeader() is the only origin method shared by custom/S3 modes +function addEdgeEventRequestOriginHttpHeader(event,key,value) { + const origin = cfEventData(event).request.origin; + if (origin.hasOwnProperty('custom')) { + addEdgeEventHttpHeaderKeyValue(origin.custom.customHeaders,key,value); + return; + } + + if (origin.hasOwnProperty('s3')) { + addEdgeEventHttpHeaderKeyValue(origin.s3.customHeaders,key,value); + return; + } + + throw new Error('an origin mode must be set via [setRequestOriginCustom()/setRequestOriginS3()]'); +} + +function verifyEdgeEventRequestOriginModeCustom(event) { + const origin = cfEventData(event).request.origin; + if ((origin === undefined) || !origin.hasOwnProperty('custom')) { + throw new Error('method only valid in custom origin [setRequestOriginCustom()] mode'); + } +} + +function verifyEdgeEventRequestOriginModeS3(event) { + const origin = cfEventData(event).request.origin; + if ((origin === undefined) || !origin.hasOwnProperty('s3')) { + throw new Error('method only valid in S3 origin [setRequestOriginS3()] mode'); + } +} + +function setEdgeEventResponseHttpStatusCode(event,httpCode) { + const response = cfEventData(event).response; + response.status = '' + httpCode; // as string + response.statusDescription = HTTP_STATUS_CODE_DESCRIPTION[httpCode] || ''; +} + +function addEdgeEventHttpHeaderKeyValue(headerCollection,key,value) { + // trim whitespace from key/value + key = key.trim(); + value = value.trim(); + const keyLower = key.toLowerCase(); + + // if HTTP header key doesn't exist - create + if (!headerCollection.hasOwnProperty(keyLower)) { + headerCollection[keyLower] = []; + } + + // add HTTP header to collection + headerCollection[keyLower].push({ + key: key, + value: value, + }); +} + +async function executeHandler(handler,event) { + const argLength = handler.length; + + // execute Lambda@Edge handler based on type + // see: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-handler.html + if (handler.constructor.name == 'AsyncFunction') { + if (argLength < 1 || argLength > 2) { + throw new Error('unexpected async handler argument count - expecting either one or two arguments'); + } + + return await handler(event,{}); + } + + // callback handler + if (argLength != 3) { + throw new Error('unexpected callback handler argument count - expecting exactly three arguments'); + } + + return new Promise(function(resolve,reject) { + handler(event,{},function(err,payload) { + if (err) { + // return error from Lambda@Edge function callback + return reject(err); + } + + resolve(payload); + }); + }); +} + +function payloadVerifyRequest(payload) { + // payload must be an object + if (typeof payload != 'object') { + throw new Error('expected payload to be of type object'); + } + + // confirm expected properties exist + payloadPropertyExistsString(payload,'clientIp'); + payloadPropertyExistsObject(payload,'headers'); + payloadPropertyExistsString(payload,'method'); + payloadPropertyExistsString(payload,'querystring'); + payloadPropertyExistsString(payload,'uri'); + + // ensure `payload.method` is a valid HTTP method + if (!VALID_HTTP_METHOD_LIST.includes(payload.method)) { + throw new Error(`unexpected payload HTTP [method] of [${payload.method}]`); + } + + // ensure `payload.uri` starts with forward slash + if (payload.uri.slice(0,1) != '/') { + throw new Error(`payload value [uri] must begin with forward slash - got [${payload.uri}]`); + } + + // if `payload.body` exists - validate properties within + if (payload.hasOwnProperty('body')) { + payloadPropertyExistsObject(payload,'body'); + const body = payload.body; + + // confirm expected `body` properties exist + payloadPropertyExistsString(body,'action','body'); + payloadPropertyExistsString(body,'data','body'); + payloadPropertyExistsString(body,'encoding','body'); + payloadPropertyExists(body,'inputTruncated','body'); + + // verify `body.action` and `body.encoding` properties have allowed values + if (!['read-only','replace'].includes(body.action)) { + throw new Error(`payload value [body.action] must be 'read-only' or 'replace' - got [${body.action}]`); + } + + if (!['base64','text'].includes(body.encoding)) { + throw new Error(`payload value [body.encoding] must be 'base64' or 'text' - got [${body.encoding}]`); + } + } +} + +function payloadVerifyRequestOrigin(payload) { + function isValidPath(path) { + if (path.slice(0,1) != '/') { + return false; + } + + if ((path != '/') && (path.slice(-1) == '/')) { + return false; + } + + return true; + } + + payloadPropertyExistsObject(payload,'origin'); + const origin = payload.origin; + + // origin must contain a property - one of `custom` or `s3` + if (origin.hasOwnProperty('custom') && origin.hasOwnProperty('s3')) { + throw new Error('expected payload property [origin] to contain child of [custom] or [s3] - never both'); + } + + if (!origin.hasOwnProperty('custom') && !origin.hasOwnProperty('s3')) { + throw new Error('expected payload property [origin] to contain child of either [custom] or [s3]'); + } + + if (origin.hasOwnProperty('custom')) { + payloadPropertyExistsObject(origin,'custom','origin'); + const custom = origin.custom; + + // confirm expected properties exist + payloadPropertyExistsObject(custom,'customHeaders','origin.custom'); + payloadPropertyExistsString(custom,'domainName','origin.custom'); + payloadPropertyExistsNumber(custom,'keepaliveTimeout','origin.custom'); + payloadPropertyExistsString(custom,'path','origin.custom'); + payloadPropertyExistsNumber(custom,'port','origin.custom'); + payloadPropertyExistsString(custom,'protocol','origin.custom'); + payloadPropertyExistsNumber(custom,'readTimeout','origin.custom'); + payloadPropertyExists(custom,'sslProtocols','origin.custom'); + + // ensure `origin.custom.domainName` is non-empty + if (custom.domainName.trim() == '') { + throw new Error('payload property [origin.custom.domainName] must be non-empty'); + } + + // ensure `origin.custom.keepaliveTimeout` is within bounds + if ((custom.keepaliveTimeout < 1) || (custom.keepaliveTimeout > 60)) { + throw new Error(`payload property [origin.custom.keepaliveTimeout] must be between 1-60 seconds - got [${custom.keepaliveTimeout}]`); + } + + // ensure `origin.custom.path` is valid + if (!isValidPath(custom.path)) { + throw new Error(`payload property [origin.custom.path] must begin with, but not end with a forward slash - got [${custom.path}]`); + } + + // ensure `origin.custom.port` is within bounds + if ( + (custom.port != 80) && + (custom.port != 443) && + ((custom.port < 1024) || (custom.port > 65535)) + ) { + throw new Error(`payload property [origin.custom.port] must be a value of 80,443 or between 1024-65535 - got [${custom.port}]`); + } + + // verify `origin.custom.protocol` is one of 'http' or 'https' + if (!['http','https'].includes(custom.protocol)) { + throw new Error(`payload value [origin.custom.protocol] must be 'http' or 'https' - got [${custom.protocol}]`); + } + + // ensure `origin.custom.readTimeout` is within bounds + if ((custom.readTimeout < 4) || (custom.readTimeout > 60)) { + throw new Error(`payload property [origin.custom.readTimeout] must be between 4-60 seconds - got [${custom.readTimeout}]`); + } + + // ensure `origin.custom.sslProtocols` is an array and contains valid protocols + if (!Array.isArray(custom.sslProtocols)) { + throw new Error('payload property [origin.custom.sslProtocols] must be an array'); + } + + for (const item of custom.sslProtocols) { + if (!VALID_SSL_PROTOCOL_LIST.includes(item)) { + throw new Error(`payload property [origin.custom.sslProtocols] contains an invalid protocol - got [${item}]`); + } + } + } + + if (origin.hasOwnProperty('s3')) { + payloadPropertyExistsObject(origin,'s3','origin'); + const s3 = origin.s3; + + // confirm expected properties exist + payloadPropertyExistsString(s3,'authMethod','origin.s3'); + payloadPropertyExistsObject(s3,'customHeaders','origin.s3'); + payloadPropertyExistsString(s3,'domainName','origin.s3'); + payloadPropertyExistsString(s3,'path','origin.s3'); + payloadPropertyExistsString(s3,'region','origin.s3'); + + // verify `origin.s3.authMethod` is one of 'none' or 'origin-access-identity' + if (!['origin-access-identity','none'].includes(s3.authMethod)) { + throw new Error(`payload value [origin.s3.authMethod] must be 'origin-access-identity' or 'none' - got [${s3.authMethod}]`); + } + + // ensure `origin.s3.domainName` is non-empty + if (s3.domainName.trim() == '') { + throw new Error('payload property [origin.s3.domainName] must be non-empty'); + } + + // ensure `origin.s3.path` is valid + if (!isValidPath(s3.path)) { + throw new Error(`payload property [origin.s3.path] must begin with, but not end with a forward slash - got [${s3.path}]`); + } + } +} + +function payloadVerifyResponse(payload) { + // payload must be an object + if (typeof payload != 'object') { + throw new Error('expected payload to be of type object'); + } + + // confirm expected properties exist + payloadPropertyExistsObject(payload,'headers'); + payloadPropertyExistsString(payload,'status'); + payloadPropertyExistsString(payload,'statusDescription'); + + // ensure `payload.status` is a valid/known HTTP status code + if (!HTTP_STATUS_CODE_DESCRIPTION.hasOwnProperty(payload.status)) { + throw new Error(`payload value [status] is an unknown HTTP status code - got [${payload.status}]`); + } +} + +function payloadPropertyExists(payload,property,prefix) { + if (payload.hasOwnProperty(property)) { + return; + } + + throw new Error(`expected payload property [${payloadPropertyDisplay(prefix,property)}] not found`); +} + +function payloadPropertyExistsObject(payload,property,prefix) { + payloadPropertyExists(payload,property,prefix); + if (typeof payload[property] == 'object') { + return; + } + + throw new Error(`expected payload property [${payloadPropertyDisplay(prefix,property)}] to be of type object`); +} + +function payloadPropertyExistsString(payload,property,prefix) { + payloadPropertyExists(payload,property,prefix); + if (typeof payload[property] == 'string') { + return; + } + + throw new Error(`expected payload property [${payloadPropertyDisplay(prefix,property)}] to be of type string`); +} + +function payloadPropertyExistsNumber(payload,property,prefix) { + payloadPropertyExists(payload,property,prefix); + if (typeof payload[property] == 'number') { + return; + } + + throw new Error(`expected payload property [${payloadPropertyDisplay(prefix,property)}] to be of type number`); +} + +function payloadPropertyDisplay(prefix,property) { + return (prefix) ? `${prefix}.${property}` : property; +} + +function cfEventData(event) { + return event.Records[0].cf; +} + +// cfEventClone() performs a basic deep copy of a CloudFront Lambda@Edge event (object/array/primitive types) +function cfEventClone(event,seen = new WeakMap()) { + // primitive type? + if (!(event instanceof Object)) { + return event; + } + + // property already cloned? + if (seen.get(event)) { + // return prior clone - avoid circular refs + return seen.get(event); + } + + if (Array.isArray(event)) { + // array type + const clone = []; + seen.set(event,clone); + for (const value of event) { + clone.push(cfEventClone(value,seen)); + } + + return clone; + } + + // `{}` type + const clone = {}; + seen.set(event,clone); + for (const key of Object.keys(event)) { + clone[key] = cfEventClone(event[key],seen); + } + + return clone; +} + +function intOrZero(value) { + value = parseInt(value,10); + return (isNaN(value)) ? 0 : value; +} + + +module.exports = { + EdgeEventRequestBase, + EdgeEventResponseBase, + + // functions for mutating `event.Records[0].cf.request.origin.[custom|s3]` + setEdgeEventRequestOriginCustom, + setEdgeEventRequestOriginKeepaliveTimeout, + setEdgeEventRequestOriginPort, + setEdgeEventRequestOriginHttps, + setEdgeEventRequestOriginReadTimeout, + setEdgeEventRequestOriginSslProtocolList, + setEdgeEventRequestOriginS3, + setEdgeEventRequestOriginOAI, + addEdgeEventRequestOriginHttpHeader, + + // functions for verifying returned Lambda@Edge function payloads + payloadVerifyRequest, + payloadVerifyRequestOrigin, + payloadVerifyResponse, + + // functions exported for tests + setEdgeEventResponseHttpStatusCode, + cfEventClone, +}; diff --git a/main.js b/main.js new file mode 100644 index 0000000..044df5a --- /dev/null +++ b/main.js @@ -0,0 +1,145 @@ +'use strict'; + +const lib = require('./lib.js'); + + +class ViewerRequest extends lib.EdgeEventRequestBase { + constructor() { + super('viewer-request',false); + } + + _payloadVerify(payload) { + lib.payloadVerifyRequest(payload); + } +} + +class OriginRequest extends lib.EdgeEventRequestBase { + constructor() { + super('origin-request',true); + } + + // [set|add]RequestOrigin*() methods shared by `OriginRequest` / `OriginResponse` + setRequestOriginCustom(domainName,path) { + lib.setEdgeEventRequestOriginCustom(this._event,domainName,path); + return this; + } + + setRequestOriginKeepaliveTimeout(timeout) { + lib.setEdgeEventRequestOriginKeepaliveTimeout(this._event,timeout); + return this; + } + + setRequestOriginPort(port) { + lib.setEdgeEventRequestOriginPort(this._event,port); + return this; + } + + setRequestOriginHttps(isHttps) { + lib.setEdgeEventRequestOriginHttps(this._event,isHttps); + return this; + } + + setRequestOriginReadTimeout(timeout) { + lib.setEdgeEventRequestOriginReadTimeout(this._event,timeout); + return this; + } + + setRequestOriginSslProtocolList(protocolList) { + lib.setEdgeEventRequestOriginSslProtocolList(this._event,protocolList); + return this; + } + + setRequestOriginS3(domainName,region,path) { + lib.setEdgeEventRequestOriginS3(this._event,domainName,region,path); + return this; + } + + setRequestOriginOAI(isOAI) { + lib.setEdgeEventRequestOriginOAI(this._event,isOAI); + return this; + } + + addRequestOriginHttpHeader(key,value) { + lib.addEdgeEventRequestOriginHttpHeader(this._event,key,value); + return this; + } + + _payloadVerify(payload) { + lib.payloadVerifyRequest(payload); + lib.payloadVerifyRequestOrigin(payload); + } +} + +class OriginResponse extends lib.EdgeEventResponseBase { + constructor() { + super('origin-response',true); + } + + // [set|add]RequestOrigin*() methods shared by `OriginRequest` / `OriginResponse` + setRequestOriginCustom(domainName,path) { + lib.setEdgeEventRequestOriginCustom(this._event,domainName,path); + return this; + } + + setRequestOriginKeepaliveTimeout(timeout) { + lib.setEdgeEventRequestOriginKeepaliveTimeout(this._event,timeout); + return this; + } + + setRequestOriginPort(port) { + lib.setEdgeEventRequestOriginPort(this._event,port); + return this; + } + + setRequestOriginHttps(isHttps) { + lib.setEdgeEventRequestOriginHttps(this._event,isHttps); + return this; + } + + setRequestOriginReadTimeout(timeout) { + lib.setEdgeEventRequestOriginReadTimeout(this._event,timeout); + return this; + } + + setRequestOriginSslProtocolList(protocolList) { + lib.setEdgeEventRequestOriginSslProtocolList(this._event,protocolList); + return this; + } + + setRequestOriginS3(domainName,region,path) { + lib.setEdgeEventRequestOriginS3(this._event,domainName,region,path); + return this; + } + + setRequestOriginOAI(isOAI) { + lib.setEdgeEventRequestOriginOAI(this._event,isOAI); + return this; + } + + addRequestOriginHttpHeader(key,value) { + lib.addEdgeEventRequestOriginHttpHeader(this._event,key,value); + return this; + } + + _payloadVerify(payload) { + lib.payloadVerifyResponse(payload); + } +} + +class ViewerResponse extends lib.EdgeEventResponseBase { + constructor() { + super('viewer-response',false); + } + + _payloadVerify(payload) { + lib.payloadVerifyResponse(payload); + } +} + + +module.exports = { + ViewerRequest, + OriginRequest, + OriginResponse, + ViewerResponse, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..94531b0 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "@magnetikonline/edgy", + "version": "1.0.0", + "description": "Harness for authoring tests against AWS CloudFront Lambda@Edge functions.", + "keywords": [ + "aws", + "cloudfront", + "lambda", + "lambda@edge" + ], + "license": "MIT", + "author": "Peter Mescalchin (http://magnetikonline.com)", + "main": "main.js", + "repository": { + "type": "git", + "url": "https://github.com/magnetikonline/edgy.git" + } +} diff --git a/test/lib.test.js b/test/lib.test.js new file mode 100644 index 0000000..c8ef99a --- /dev/null +++ b/test/lib.test.js @@ -0,0 +1,143 @@ +'use strict'; + +const assert = require('assert').strict, + util = require('./util.js'), + lib = require('./../lib.js'), + + runner = new util.TestCaseRunner(); + + +runner.add(function testSetEdgeEventResponseHttpStatusCode() { + const mockEvent = { + Records: [{ + cf: { + response: {} + } + }] + }; + + function assertStatus(httpCode,description) { + assert.equal(mockEvent.Records[0].cf.response.status,'' + httpCode); + assert.equal(mockEvent.Records[0].cf.response.statusDescription,description); + } + + // unknown HTTP code + lib.setEdgeEventResponseHttpStatusCode(mockEvent,666); + assertStatus(666,''); + + // HTTP 20X + lib.setEdgeEventResponseHttpStatusCode(mockEvent,200); + assertStatus(200,'OK'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,201); + assertStatus(201,'Created'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,202); + assertStatus(202,'Accepted'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,203); + assertStatus(203,'Non-Authoritative Information'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,204); + assertStatus(204,'No Content'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,205); + assertStatus(205,'Reset Content'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,206); + assertStatus(206,'Partial Content'); + + // HTTP 30X + lib.setEdgeEventResponseHttpStatusCode(mockEvent,300); + assertStatus(300,'Multiple Choices'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,301); + assertStatus(301,'Moved Permanently'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,302); + assertStatus(302,'Found'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,303); + assertStatus(303,'See Other'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,304); + assertStatus(304,'Not Modified'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,305); + assertStatus(305,'Use Proxy'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,307); + assertStatus(307,'Temporary Redirect'); + + // HTTP 4XX + lib.setEdgeEventResponseHttpStatusCode(mockEvent,400); + assertStatus(400,'Bad Request'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,401); + assertStatus(401,'Unauthorized'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,402); + assertStatus(402,'Payment Required'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,403); + assertStatus(403,'Forbidden'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,404); + assertStatus(404,'Not Found'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,405); + assertStatus(405,'Method Not Allowed'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,406); + assertStatus(406,'Not Acceptable'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,407); + assertStatus(407,'Proxy Authentication Required'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,408); + assertStatus(408,'Request Timeout'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,409); + assertStatus(409,'Conflict'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,410); + assertStatus(410,'Gone'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,411); + assertStatus(411,'Length Required'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,412); + assertStatus(412,'Precondition Failed'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,413); + assertStatus(413,'Request Entity Too Large'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,414); + assertStatus(414,'Request-URI Too Long'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,415); + assertStatus(415,'Unsupported Media Type'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,416); + assertStatus(416,'Requested Range Not Satisfiable'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,417); + assertStatus(417,'Expectation Failed'); + + // HTTP 50X + lib.setEdgeEventResponseHttpStatusCode(mockEvent,500); + assertStatus(500,'Internal Server Error'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,501); + assertStatus(501,'Not Implemented'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,502); + assertStatus(502,'Bad Gateway'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,503); + assertStatus(503,'Service Unavailable'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,504); + assertStatus(504,'Gateway Timeout'); + lib.setEdgeEventResponseHttpStatusCode(mockEvent,505); + assertStatus(505,'HTTP Version Not Supported'); +}); + + +runner.add(function testCfEventClone() { + const source = { + one: '1', + two: '2', + three: [ + 1,2, + { child: 3 }, + ], + undef: undefined, + }; + + const copy = lib.cfEventClone(source); + assert.notEqual(copy,source); + assert.deepEqual(copy,source); + assert.equal(copy.undef,source.undef); + + copy.one = 'mutate'; + copy.three[0] = 66; + copy.undef = 'blurg'; + assert.notEqual(copy.one,source.one); + assert.notDeepEqual(copy.three,source.three); + assert.notEqual(copy.undef,source.undef); + + // force a circular reference, ensure `lib.cfEventClone()` can handle it + source.circular = source; + lib.cfEventClone(source); +}); + + +runner.execute(); diff --git a/test/main-execute.test.js b/test/main-execute.test.js new file mode 100644 index 0000000..77f75e3 --- /dev/null +++ b/test/main-execute.test.js @@ -0,0 +1,450 @@ +'use strict'; + +const assert = require('assert').strict, + util = require('./util.js'), + main = require('./../main.js'), + + runner = new util.TestCaseRunner(); + + +runner.add(async function testExecuteViewerRequest() { + const vReq = new main.ViewerRequest(); + + // test: Lambda@Edge functions with bad argument counts + await assert.rejects(async function() { + await vReq.execute(edgeFunctionAsyncBadArgCount); + }); + + await assert.rejects(async function() { + await vReq.execute(edgeFunctionCallbackBadArgCount); + }); + + // test: Lambda@Edge functions returning errors + await assert.rejects(vReq.execute( + async function(event) { + throw new Error('function went bad'); + } + )); + + await assert.rejects(vReq.execute( + function(event,context,callback) { + callback(new Error('function went bad')); + } + )); + + // test: return invalid request payload + await assert.rejects(vReq.execute( + buildEdgeFunctionRequestAsync(function(payload) { + payload.headers = -1; + }) + )); + + await assert.rejects(vReq.execute( + buildEdgeFunctionRequestCallback(function(payload) { + payload.headers = -1; + }) + )); + + // test: successful Lambda@Edge function executions + vReq + .setClientIp('1.2.3.4') + .addRequestHttpHeader('X-My-Header','viewer-request') + .setHttpMethod('POST') + .setQuerystring('?test=viewer-request-async'); + + const asyncResp = await vReq.execute( + buildEdgeFunctionRequestAsync(function(payload) { + payload.uri = '/test/viewer-request-async'; + }) + ); + + assert.deepEqual(asyncResp, + { + clientIp: '1.2.3.4', + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'viewer-request', + } + ] + }, + method: 'POST', + querystring: 'test=viewer-request-async', + uri: '/test/viewer-request-async', + } + ); + + vReq.setQuerystring('?test=viewer-request-callback'); + const callbackResp = await vReq.execute( + buildEdgeFunctionRequestCallback(function(payload) { + payload.uri = '/test/viewer-request-callback'; + }) + ); + + assert.deepEqual(callbackResp, + { + clientIp: '1.2.3.4', + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'viewer-request', + } + ] + }, + method: 'POST', + querystring: 'test=viewer-request-callback', + uri: '/test/viewer-request-callback', + } + ); +}); + + +runner.add(async function testExecuteOriginRequest() { + const oReq = new main.OriginRequest(); + oReq.setRequestOriginCustom('domain.tld'); + + // test: Lambda@Edge functions with bad argument counts + await assert.rejects(async function() { + await oReq.execute(edgeFunctionAsyncBadArgCount); + }); + + await assert.rejects(async function() { + await oReq.execute(edgeFunctionCallbackBadArgCount); + }); + + // test: Lambda@Edge functions returning errors + await assert.rejects(oReq.execute( + async function(event) { + throw new Error('function went bad'); + } + )); + + await assert.rejects(oReq.execute( + function(event,context,callback) { + callback(new Error('function went bad')); + } + )); + + // test: return invalid request payload + await assert.rejects(oReq.execute( + buildEdgeFunctionRequestAsync(function(payload) { + payload.headers = -1; + }) + )); + + await assert.rejects(oReq.execute( + buildEdgeFunctionRequestCallback(function(payload) { + payload.headers = -1; + }) + )); + + // test: successful Lambda@Edge function executions + oReq + .setClientIp('1.2.3.4') + .addRequestHttpHeader('X-My-Header','origin-request') + .setHttpMethod('POST') + .setQuerystring('?test=origin-request-async'); + + const asyncResp = await oReq.execute( + buildEdgeFunctionRequestAsync(function(payload) { + payload.uri = '/test/origin-request-async'; + }) + ); + + assert.deepEqual(asyncResp, + { + clientIp: '1.2.3.4', + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'origin-request', + } + ] + }, + method: 'POST', + origin: { + custom: { + customHeaders: {}, + domainName: 'domain.tld', + keepaliveTimeout: 1, + path: '/', + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + }, + querystring: 'test=origin-request-async', + uri: '/test/origin-request-async', + } + ); + + oReq.setQuerystring('?test=origin-request-callback'); + const callbackResp = await oReq.execute( + buildEdgeFunctionRequestCallback(function(payload) { + payload.uri = '/test/origin-request-callback'; + }) + ); + + assert.deepEqual(callbackResp, + { + clientIp: '1.2.3.4', + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'origin-request', + } + ] + }, + method: 'POST', + origin: { + custom: { + customHeaders: {}, + domainName: 'domain.tld', + keepaliveTimeout: 1, + path: '/', + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + }, + querystring: 'test=origin-request-callback', + uri: '/test/origin-request-callback', + } + ); +}); + + +runner.add(async function testExecuteOriginResponse() { + const oRsp = new main.OriginResponse(); + + // test: Lambda@Edge functions with bad argument counts + await assert.rejects(async function() { + await oRsp.execute(edgeFunctionAsyncBadArgCount); + }); + + await assert.rejects(async function() { + await oRsp.execute(edgeFunctionCallbackBadArgCount); + }); + + // test: Lambda@Edge functions returning errors + await assert.rejects(oRsp.execute( + async function(event) { + throw new Error('function went bad'); + } + )); + + await assert.rejects(oRsp.execute( + function(event,context,callback) { + callback(new Error('function went bad')); + } + )); + + // test: return invalid response payload + await assert.rejects(oRsp.execute( + buildEdgeFunctionResponseAsync(function(payload) { + payload.headers = -1; + }) + )); + + await assert.rejects(oRsp.execute( + buildEdgeFunctionResponseCallback(function(payload) { + payload.headers = -1; + }) + )); + + // test: successful Lambda@Edge function executions + oRsp + .addResponseHttpHeader('X-My-Header','origin-response') + .setResponseHttpStatusCode(304); + + const asyncResp = await oRsp.execute( + buildEdgeFunctionResponseAsync(function(payload) { + payload.statusDescription = 'Mutated async'; + }) + ); + + assert.deepEqual(asyncResp, + { + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'origin-response', + } + ] + }, + status: '304', + statusDescription: 'Mutated async', + } + ); + + const callbackResp = await oRsp.execute( + buildEdgeFunctionResponseCallback(function(payload) { + payload.statusDescription = 'Mutated callback'; + }) + ); + + assert.deepEqual(callbackResp, + { + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'origin-response', + } + ] + }, + status: '304', + statusDescription: 'Mutated callback', + } + ); +}); + + +runner.add(async function testExecuteViewerResponse() { + const vRsp = new main.ViewerResponse(); + + // test: Lambda@Edge functions with bad argument counts + await assert.rejects(async function() { + await vRsp.execute(edgeFunctionAsyncBadArgCount); + }); + + await assert.rejects(async function() { + await vRsp.execute(edgeFunctionCallbackBadArgCount); + }); + + // test: Lambda@Edge functions returning errors + await assert.rejects(vRsp.execute( + async function(event) { + throw new Error('function went bad'); + } + )); + + await assert.rejects(vRsp.execute( + function(event,context,callback) { + callback(new Error('function went bad')); + } + )); + + // test: return invalid response payload + await assert.rejects(vRsp.execute( + buildEdgeFunctionResponseAsync(function(payload) { + payload.headers = -1; + }) + )); + + await assert.rejects(vRsp.execute( + buildEdgeFunctionResponseCallback(function(payload) { + payload.headers = -1; + }) + )); + + // test: successful Lambda@Edge function executions + vRsp + .addResponseHttpHeader('X-My-Header','viewer-response') + .setResponseHttpStatusCode(304); + + const asyncResp = await vRsp.execute( + buildEdgeFunctionResponseAsync(function(payload) { + payload.statusDescription = 'Mutated async'; + }) + ); + + assert.deepEqual(asyncResp, + { + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'viewer-response', + } + ] + }, + status: '304', + statusDescription: 'Mutated async', + } + ); + + const callbackResp = await vRsp.execute( + buildEdgeFunctionResponseCallback(function(payload) { + payload.statusDescription = 'Mutated callback'; + }) + ); + + assert.deepEqual(callbackResp, + { + headers: { + 'x-my-header': [ + { + key: 'X-My-Header', + value: 'viewer-response', + } + ] + }, + status: '304', + statusDescription: 'Mutated callback', + } + ); +}); + + +async function edgeFunctionAsyncBadArgCount() { + // note: an async Lambda@Edge function needs 1 or 2 arguments +} + +function edgeFunctionCallbackBadArgCount() { + // note: a callback Lambda@Edge function needs exactly 3 arguments +} + +function buildEdgeFunctionRequestAsync(mutate) { + return async function(event) { + const payload = event.Records[0].cf.request; + if (mutate) { + mutate(payload); + } + + return payload; + }; +} + +function buildEdgeFunctionRequestCallback(mutate) { + return function(event,context,callback) { + const payload = event.Records[0].cf.request; + if (mutate) { + mutate(payload); + } + + callback(null,payload); + }; +} + +function buildEdgeFunctionResponseAsync(mutate) { + return async function(event) { + const payload = event.Records[0].cf.response; + if (mutate) { + mutate(payload); + } + + return payload; + }; +} + +function buildEdgeFunctionResponseCallback(mutate) { + return function(event,context,callback) { + const payload = event.Records[0].cf.response; + if (mutate) { + mutate(payload); + } + + callback(null,payload); + }; +} + + +runner.execute(); diff --git a/test/main-payloadverify.test.js b/test/main-payloadverify.test.js new file mode 100644 index 0000000..4a9ec89 --- /dev/null +++ b/test/main-payloadverify.test.js @@ -0,0 +1,682 @@ +'use strict'; + +const assert = require('assert').strict, + util = require('./util.js'), + main = require('./../main.js'), + + runner = new util.TestCaseRunner(); + + +runner.add(function testPayloadVerifyViewerRequest() { + const vReq = new main.ViewerRequest(); + testPayloadVerifyRequest(vReq); +}); + + +runner.add(function testPayloadVerifyOriginRequest() { + const oReq = new main.OriginRequest(); + testPayloadVerifyRequest(oReq,true); + testPayloadVerifyRequestOrigin(oReq); +}); + + +runner.add(function testPayloadVerifyOriginResponse() { + const oRsp = new main.OriginResponse(); + testPayloadVerifyResponse(oRsp); +}); + + +runner.add(function testPayloadVerifyViewerResponse() { + const vRsp = new main.ViewerResponse(); + testPayloadVerifyResponse(vRsp); +}); + + +function testPayloadVerifyRequest(inst,withMockOrigin = false) { + function callVerify(payload) { + if (withMockOrigin && (typeof payload == 'object')) { + // bolt on an `origin = {}` property to the payload + payload.origin = { + custom: { + customHeaders: {}, + domainName: 'example.org', + keepaliveTimeout: 1, + path: '/', + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + }; + } + + inst._payloadVerify(payload); + } + + function makePayload(mutate,extendBase = {}) { + const payload = { + ...{ + clientIp: '127.0.0.1', + headers: {}, + method: 'GET', + querystring: '', + uri: '/', + }, + ...extendBase + }; + + if (mutate) { + mutate(payload); + } + + return payload; + } + + function makePayloadWithBody(mutate) { + return makePayload( + mutate, + { + body: { + action: 'read-only', + data: '', + encoding: 'base64', + inputTruncated: false, + } + } + ); + } + + // test: passing a valid payload + callVerify(makePayload()); + + callVerify(makePayload(function(payload) { + payload.method = 'POST'; + })); + + // test: payload must be an object + assert.throws(function() { callVerify(undefined); }); + + // test: payload missing/invalid property values + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.clientIp; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.clientIp = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.headers; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.headers = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.method; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.method = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.method = 'invalid_http_method'; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.querystring; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.querystring = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.uri; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.uri = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.uri = 'no_leading_slash'; + })); + }); + + // test: optional `body` property is present + + // test: passing a valid payload + callVerify(makePayloadWithBody()); + + callVerify(makePayloadWithBody(function(payload) { + payload.body.action = 'replace'; + })); + + callVerify(makePayloadWithBody(function(payload) { + payload.body.encoding = 'text'; + })); + + // test: given `body` must be an object + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body = -1; + })); + }); + + // test: payload missing/invalid property values + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + delete payload.body.action; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body.action = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body.action = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + delete payload.body.data; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body.data = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + delete payload.body.encoding; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body.encoding = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + payload.body.encoding = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithBody(function(payload) { + delete payload.body.inputTruncated; + })); + }); +} + +function testPayloadVerifyRequestOrigin(inst) { + function callVerify(payload) { + inst._payloadVerify(payload); + } + + function makePayload(mutate,extendBase = {}) { + const payload = { + ...{ + clientIp: '127.0.0.1', + headers: {}, + method: 'GET', + querystring: '', + uri: '/', + }, + ...extendBase + }; + + if (mutate) { + mutate(payload); + } + + return payload; + } + + function makePayloadWithOriginCustom(mutate) { + return makePayload( + mutate, + { + origin: { + custom: { + customHeaders: {}, + domainName: 'example.org', + keepaliveTimeout: 1, + path: '/', + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + } + } + ); + } + + function makePayloadWithOriginS3(mutate) { + return makePayload( + mutate, + { + origin: { + s3: { + authMethod: 'none', + customHeaders: {}, + domainName: 'bucket.s3.us-east-1.amazonaws.com', + path: '/', + region: 'us-east-1', + } + } + } + ); + } + + // test: origin must be an object + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.origin = -1; + })); + }); + + // test: origin must contain *just one* of `custom` or `s3` - but never both + assert.throws(function() { + callVerify(makePayload(function(payload) { + // provided both + payload.origin = { + custom: {}, + s3: {}, + }; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + // provided neither + payload.origin = {}; + })); + }); + + // test: origin [custom] + + // test: passing a valid payload + callVerify(makePayloadWithOriginCustom()); + + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.port = 80; + })); + + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.port = 1024; + })); + + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.protocol = 'http'; + })); + + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.sslProtocols = ['TLSv1.2']; + })); + + // test: payload missing/invalid property values + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom = -1; // `origin.custom` must be an object + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.customHeaders; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.customHeaders = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.domainName; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.domainName = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.domainName = ''; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.keepaliveTimeout; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.keepaliveTimeout = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.keepaliveTimeout = 0; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.keepaliveTimeout = 61; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.path; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.path = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.path = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.path = '/invalid/'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.port; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.port = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.port = 666; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.protocol; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.protocol = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.protocol = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.readTimeout; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.readTimeout = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.readTimeout = 3; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.readTimeout = 61; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + delete payload.origin.custom.sslProtocols; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.sslProtocols = 'not_array'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginCustom(function(payload) { + payload.origin.custom.sslProtocols = ['invalid_proto']; + })); + }); + + // test: origin [s3] + + // test: passing a valid payload + callVerify(makePayloadWithOriginS3()); + + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.authMethod = 'origin-access-identity'; + })); + + // test: payload missing/invalid property values + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3 = -1; // `origin.s3` must be an object + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + delete payload.origin.s3.authMethod; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.authMethod = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.authMethod = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + delete payload.origin.s3.customHeaders; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.customHeaders = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + delete payload.origin.s3.domainName; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.domainName = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.domainName = ''; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + delete payload.origin.s3.path; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.path = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.path = 'invalid'; + })); + }); + + assert.throws(function() { + callVerify(makePayloadWithOriginS3(function(payload) { + payload.origin.s3.path = '/invalid/'; + })); + }); +} + +function testPayloadVerifyResponse(inst) { + function callVerify(payload) { + inst._payloadVerify(payload); + } + + function makePayload(mutate) { + const payload = { + headers: {}, + status: '200', + statusDescription: 'OK', + }; + + if (mutate) { + mutate(payload); + } + + return payload; + } + + // test: passing a valid payload + callVerify(makePayload()); + + callVerify(makePayload(function(payload) { + payload.status = '404'; + payload.statusDescription = 'Not Found'; + })); + + // test: payload must be an object + assert.throws(function() { callVerify(undefined); }); + + // test: payload missing/invalid property values + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.headers; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.headers = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.status; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.status = -1; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.status = '999'; // unknown HTTP status code + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + delete payload.statusDescription; + })); + }); + + assert.throws(function() { + callVerify(makePayload(function(payload) { + payload.statusDescription = -1; + })); + }); +} + + +runner.execute(); diff --git a/test/main-property.test.js b/test/main-property.test.js new file mode 100644 index 0000000..5ce2bb3 --- /dev/null +++ b/test/main-property.test.js @@ -0,0 +1,477 @@ +'use strict'; + +const assert = require('assert').strict, + util = require('./util.js'), + main = require('./../main.js'), + + runner = new util.TestCaseRunner(); + + +runner.add(function testMethodsReturnSelf() { + // test: viewer request + const vReq = new main.ViewerRequest(); + + assert.equal(vReq.setDistributionDomainName(),vReq); + assert.equal(vReq.setDistributionId(),vReq); + assert.equal(vReq.setRequestId(),vReq); + + assert.equal(vReq.setRequestBody(),vReq); + assert.equal(vReq.setClientIp(),vReq); + assert.equal(vReq.addRequestHttpHeader('',''),vReq); + assert.equal(vReq.setHttpMethod('GET'),vReq); + assert.equal(vReq.setQuerystring(''),vReq); + assert.equal(vReq.setUri(''),vReq); + + + // test: origin request + const oReq = new main.OriginRequest(); + + assert.equal(oReq.setDistributionDomainName(),oReq); + assert.equal(oReq.setDistributionId(),oReq); + assert.equal(oReq.setRequestId(),oReq); + + assert.equal(oReq.setRequestBody(),oReq); + assert.equal(oReq.setClientIp(),oReq); + assert.equal(oReq.addRequestHttpHeader('',''),oReq); + assert.equal(oReq.setHttpMethod('GET'),oReq); + + // origin methods + assert.equal(oReq.setRequestOriginCustom(),oReq); + assert.equal(oReq.setRequestOriginKeepaliveTimeout(),oReq); + assert.equal(oReq.setRequestOriginPort(),oReq); + assert.equal(oReq.setRequestOriginHttps(),oReq); + assert.equal(oReq.setRequestOriginReadTimeout(),oReq); + assert.equal(oReq.setRequestOriginSslProtocolList([]),oReq); + assert.equal(oReq.setRequestOriginS3(),oReq); + assert.equal(oReq.setRequestOriginOAI(),oReq); + assert.equal(oReq.addRequestOriginHttpHeader('',''),oReq); + + assert.equal(oReq.setQuerystring(''),oReq); + assert.equal(oReq.setUri(''),oReq); + + + // test: origin response + const oRsp = new main.OriginResponse(); + + assert.equal(oRsp.setDistributionDomainName(),oRsp); + assert.equal(oRsp.setDistributionId(),oRsp); + assert.equal(oRsp.setRequestId(),oRsp); + + assert.equal(oRsp.setClientIp(),oRsp); + assert.equal(oRsp.addRequestHttpHeader('',''),oRsp); + assert.equal(oRsp.setHttpMethod('GET'),oRsp); + + // origin methods + assert.equal(oRsp.setRequestOriginCustom(),oRsp); + assert.equal(oRsp.setRequestOriginKeepaliveTimeout(),oRsp); + assert.equal(oRsp.setRequestOriginPort(),oRsp); + assert.equal(oRsp.setRequestOriginHttps(),oRsp); + assert.equal(oRsp.setRequestOriginReadTimeout(),oRsp); + assert.equal(oRsp.setRequestOriginSslProtocolList([]),oRsp); + assert.equal(oRsp.setRequestOriginS3(),oRsp); + assert.equal(oRsp.setRequestOriginOAI(),oRsp); + assert.equal(oRsp.addRequestOriginHttpHeader('',''),oRsp); + + assert.equal(oRsp.setQuerystring(''),oRsp); + assert.equal(oRsp.setUri(''),oRsp); + + assert.equal(oRsp.addResponseHttpHeader('',''),oRsp); + assert.equal(oRsp.setResponseHttpStatusCode(),oRsp); + + + // test: viewer response + const vRsp = new main.ViewerResponse(); + + assert.equal(vRsp.setDistributionDomainName(),vRsp); + assert.equal(vRsp.setDistributionId(),vRsp); + assert.equal(vRsp.setRequestId(),vRsp); + + assert.equal(vRsp.setClientIp(),vRsp); + assert.equal(vRsp.addRequestHttpHeader('',''),vRsp); + assert.equal(vRsp.setHttpMethod('GET'),vRsp); + assert.equal(vRsp.setQuerystring(''),vRsp); + assert.equal(vRsp.setUri(''),vRsp); + + assert.equal(vRsp.addResponseHttpHeader('',''),vRsp); + assert.equal(vRsp.setResponseHttpStatusCode(),vRsp); +}); + + +runner.add(function testPropertyViewerRequest() { + const vReq = new main.ViewerRequest(); + + // test: config + assert.equal(cfEventData(vReq).config.eventType,'viewer-request'); + testPropertyConfigDistributionDomainName(vReq); + testPropertyConfigDistributionId(vReq); + testPropertyConfigRequestId(vReq); + + // test: request + testPropertyRequestBody(vReq); + testPropertyRequestClientIp(vReq); + testPropertyRequestHttpHeader(vReq); + testPropertyRequestMethod(vReq); + assert.equal(cfEventData(vReq).request.origin,undefined); + testPropertyRequestQuerystring(vReq); + testPropertyRequestUri(vReq); +}); + + +runner.add(function testPropertyOriginRequest() { + const oReq = new main.OriginRequest(); + + // test: config + assert.equal(cfEventData(oReq).config.eventType,'origin-request'); + testPropertyConfigDistributionDomainName(oReq); + testPropertyConfigDistributionId(oReq); + testPropertyConfigRequestId(oReq); + + // test: request + testPropertyRequestBody(oReq); + testPropertyRequestClientIp(oReq); + testPropertyRequestHttpHeader(oReq); + testPropertyRequestMethod(oReq); + testPropertyRequestOrigin(oReq); + testPropertyRequestQuerystring(oReq); + testPropertyRequestUri(oReq); +}); + + +runner.add(function testPropertyOriginResponse() { + const oRsp = new main.OriginResponse(); + + // test: config + assert.equal(cfEventData(oRsp).config.eventType,'origin-response'); + testPropertyConfigDistributionDomainName(oRsp); + testPropertyConfigDistributionId(oRsp); + testPropertyConfigRequestId(oRsp); + + // test: request + testPropertyRequestClientIp(oRsp); + testPropertyRequestHttpHeader(oRsp); + testPropertyRequestMethod(oRsp); + testPropertyRequestOrigin(oRsp); + testPropertyRequestQuerystring(oRsp); + testPropertyRequestUri(oRsp); + + // test: response + testPropertyResponseHttpHeader(oRsp); + testPropertyResponseStatus(oRsp); +}); + + +runner.add(function testPropertyViewerResponse() { + const vRsp = new main.ViewerResponse(); + + // test: config + assert.equal(cfEventData(vRsp).config.eventType,'viewer-response'); + testPropertyConfigDistributionDomainName(vRsp); + testPropertyConfigDistributionId(vRsp); + testPropertyConfigRequestId(vRsp); + + // test: request + testPropertyRequestClientIp(vRsp); + testPropertyRequestHttpHeader(vRsp); + testPropertyRequestMethod(vRsp); + assert.equal(cfEventData(vRsp).request.origin,undefined); + testPropertyRequestQuerystring(vRsp); + testPropertyRequestUri(vRsp); + + // test: response + testPropertyResponseHttpHeader(vRsp); + testPropertyResponseStatus(vRsp); +}); + + +function testPropertyConfigDistributionDomainName(inst) { + assert.equal(cfEventData(inst).config.distributionDomainName,undefined); + inst.setDistributionDomainName('abcd.cloudfront.net'); + assert.equal(cfEventData(inst).config.distributionDomainName,'abcd.cloudfront.net'); +} + +function testPropertyConfigDistributionId(inst) { + assert.equal(cfEventData(inst).config.distributionId,undefined); + inst.setDistributionId('ABCD010ZKWG3C'); + assert.equal(cfEventData(inst).config.distributionId,'ABCD010ZKWG3C'); +} + +function testPropertyConfigRequestId(inst) { + assert.equal(cfEventData(inst).config.requestId,undefined); + inst.setRequestId('OxakAoSw89Qow3gwR5oJQpWZiHB4ZcYIG2qoBwDtFvq_8nPn5_W_9w=='); + assert.equal(cfEventData(inst).config.requestId,'OxakAoSw89Qow3gwR5oJQpWZiHB4ZcYIG2qoBwDtFvq_8nPn5_W_9w=='); +} + +function testPropertyRequestBody(inst) { + assert.equal(cfEventData(inst).request.body,undefined); + + inst.setRequestBody(); + assert.equal(cfEventData(inst).request.body.data,''); + assert.equal(cfEventData(inst).request.body.inputTruncated,false); + + inst.setRequestBody('hello there viewer!'); + assert.deepEqual(cfEventData(inst).request.body, + { + action: 'read-only', + data: 'aGVsbG8gdGhlcmUgdmlld2VyIQ==', // 'hello there viewer!' + encoding: 'base64', + inputTruncated: false, + } + ); + + inst.setRequestBody('',true); + assert.equal(cfEventData(inst).request.body.inputTruncated,true); +} + +function testPropertyRequestClientIp(inst) { + assert.equal(cfEventData(inst).request.clientIp,'127.0.0.1'); + inst.setClientIp('192.168.0.1'); + assert.equal(cfEventData(inst).request.clientIp,'192.168.0.1'); +} + +function testPropertyRequestHttpHeader(inst) { + assert.deepEqual(cfEventData(inst).request.headers,{}); + + inst + .addRequestHttpHeader('Host','my-hostname.tld') + .addRequestHttpHeader('User-Agent','curl/7.x.x') + .addRequestHttpHeader('Multi-Key','apples') + .addRequestHttpHeader('Multi-Key','oranges') + .addRequestHttpHeader(' Trim ',' value '); + + assert.deepEqual(cfEventData(inst).request.headers, + { + 'host': [ + { key: 'Host',value: 'my-hostname.tld' }, + ], + 'multi-key': [ + { key: 'Multi-Key',value: 'apples' }, + { key: 'Multi-Key',value: 'oranges' }, + ], + 'trim': [ + { key: 'Trim',value: 'value' }, + ], + 'user-agent': [ + { key: 'User-Agent',value: 'curl/7.x.x' }, + ], + } + ); +} + +function testPropertyRequestMethod(inst) { + assert.equal(cfEventData(inst).request.method,'GET'); + + inst.setHttpMethod('DELETE'); + assert.equal(cfEventData(inst).request.method,'DELETE'); + + assert.throws(function() { inst.setHttpMethod('Invalid'); }); +} + +function testPropertyRequestOrigin(inst) { + assert.deepEqual(cfEventData(inst).request.origin,{}); + + // calling custom/S3 origin methods before origin mode set should throw error + assert.throws(function() { inst.setRequestOriginKeepaliveTimeout(666); }); + assert.throws(function() { inst.setRequestOriginPort(123); }); + assert.throws(function() { inst.setRequestOriginHttps(); }); + assert.throws(function() { inst.setRequestOriginReadTimeout(6); }); + assert.throws(function() { inst.setRequestOriginSslProtocolList([]); }); + assert.throws(function() { inst.setRequestOriginOAI(); }); + assert.throws(function() { inst.addRequestOriginHttpHeader(); }); + + + // test: origin [custom] + inst.setRequestOriginCustom('my-hostname.tld'); // note: `path` defaults to `/` + assert.deepEqual(cfEventData(inst).request.origin, + { + custom: { + customHeaders: {}, + domainName: 'my-hostname.tld', + keepaliveTimeout: 1, + path: '/', + port: 443, + protocol: 'https', + readTimeout: 4, + sslProtocols: [], + } + } + ); + + inst.setRequestOriginCustom('my-hostname.tld','/my/path'); + assert.equal(cfEventData(inst).request.origin.custom.path,'/my/path'); + + inst + .addRequestOriginHttpHeader('User-Agent','curl/7.x.x') + .addRequestOriginHttpHeader('Multi-Origin-Key','apples') + .addRequestOriginHttpHeader('Multi-Origin-Key','oranges'); + + assert.deepEqual(cfEventData(inst).request.origin.custom.customHeaders, + { + 'multi-origin-key': [ + { key: 'Multi-Origin-Key',value: 'apples' }, + { key: 'Multi-Origin-Key',value: 'oranges' }, + ], + 'user-agent': [ + { key: 'User-Agent',value: 'curl/7.x.x' }, + ], + } + ); + + inst.setRequestOriginKeepaliveTimeout(666); + assert.equal(cfEventData(inst).request.origin.custom.keepaliveTimeout,666); + inst.setRequestOriginKeepaliveTimeout('123'); + assert.equal(cfEventData(inst).request.origin.custom.keepaliveTimeout,123); + inst.setRequestOriginKeepaliveTimeout(); + assert.equal(cfEventData(inst).request.origin.custom.keepaliveTimeout,0); + inst.setRequestOriginKeepaliveTimeout('invalid'); + assert.equal(cfEventData(inst).request.origin.custom.keepaliveTimeout,0); + + inst.setRequestOriginPort(123); + assert.equal(cfEventData(inst).request.origin.custom.port,123); + inst.setRequestOriginPort('666'); + assert.equal(cfEventData(inst).request.origin.custom.port,666); + inst.setRequestOriginPort(); + assert.equal(cfEventData(inst).request.origin.custom.port,0); + inst.setRequestOriginPort('invalid'); + assert.equal(cfEventData(inst).request.origin.custom.port,0); + + inst.setRequestOriginHttps(); + assert.equal(cfEventData(inst).request.origin.custom.protocol,'http'); + inst.setRequestOriginHttps(false); + assert.equal(cfEventData(inst).request.origin.custom.protocol,'http'); + inst.setRequestOriginHttps(true); + assert.equal(cfEventData(inst).request.origin.custom.protocol,'https'); + inst.setRequestOriginHttps(1); + assert.equal(cfEventData(inst).request.origin.custom.protocol,'https'); + + inst.setRequestOriginReadTimeout(6); + assert.equal(cfEventData(inst).request.origin.custom.readTimeout,6); + inst.setRequestOriginReadTimeout('14'); + assert.equal(cfEventData(inst).request.origin.custom.readTimeout,14); + inst.setRequestOriginReadTimeout(); + assert.equal(cfEventData(inst).request.origin.custom.readTimeout,0); + inst.setRequestOriginReadTimeout('invalid'); + assert.equal(cfEventData(inst).request.origin.custom.readTimeout,0); + + assert.throws(function() { inst.setRequestOriginSslProtocolList(); }); + assert.throws(function() { inst.setRequestOriginSslProtocolList('not array'); }); + inst.setRequestOriginSslProtocolList(['SSLv3','TLSv1.2','unknown']); + assert.deepEqual(cfEventData(inst).request.origin.custom.sslProtocols,['SSLv3','TLSv1.2']); + inst.setRequestOriginSslProtocolList(['unknown']); + assert.deepEqual(cfEventData(inst).request.origin.custom.sslProtocols,[]); + + // test: origin [custom] - ensure [S3] origin methods throw error when called in custom mode + assert.throws(function() { inst.setRequestOriginOAI(); }); + + + // test: origin [S3] + inst.setRequestOriginS3('my-bucket.s3.ap-southeast-2.amazonaws.com'); // note: `path` defaults to `/` + assert.deepEqual(cfEventData(inst).request.origin, + { + s3: { + authMethod: 'none', + customHeaders: {}, + domainName: 'my-bucket.s3.ap-southeast-2.amazonaws.com', + path: '/', + region: '', + } + } + ); + + inst.setRequestOriginS3('my-bucket.s3.ap-southeast-2.amazonaws.com','ap-southeast-2'); + assert.equal(cfEventData(inst).request.origin.s3.path,'/'); + assert.equal(cfEventData(inst).request.origin.s3.region,'ap-southeast-2'); + inst.setRequestOriginS3('my-bucket.s3.ap-southeast-2.amazonaws.com','ap-southeast-2','/my/path'); + assert.equal(cfEventData(inst).request.origin.s3.path,'/my/path'); + assert.equal(cfEventData(inst).request.origin.s3.region,'ap-southeast-2'); + + inst + .addRequestOriginHttpHeader('User-Agent','curl/7.x.x') + .addRequestOriginHttpHeader('Multi-Origin-Key','apples') + .addRequestOriginHttpHeader('Multi-Origin-Key','oranges'); + + assert.deepEqual(cfEventData(inst).request.origin.s3.customHeaders, + { + 'multi-origin-key': [ + { key: 'Multi-Origin-Key',value: 'apples' }, + { key: 'Multi-Origin-Key',value: 'oranges' }, + ], + 'user-agent': [ + { key: 'User-Agent',value: 'curl/7.x.x' }, + ], + } + ); + + inst.setRequestOriginOAI(); + assert.equal(cfEventData(inst).request.origin.s3.authMethod,'none'); + inst.setRequestOriginOAI(false); + assert.equal(cfEventData(inst).request.origin.s3.authMethod,'none'); + inst.setRequestOriginOAI(true); + assert.equal(cfEventData(inst).request.origin.s3.authMethod,'origin-access-identity'); + inst.setRequestOriginOAI(1); + assert.equal(cfEventData(inst).request.origin.s3.authMethod,'origin-access-identity'); + + // test: origin [S3] - ensure [custom] origin methods throw error when called in S3 mode + assert.throws(function() { inst.setRequestOriginKeepaliveTimeout(666); }); + assert.throws(function() { inst.setRequestOriginPort(123); }); + assert.throws(function() { inst.setRequestOriginHttps(); }); + assert.throws(function() { inst.setRequestOriginReadTimeout(6); }); + assert.throws(function() { inst.setRequestOriginSslProtocolList([]); }); +} + +function testPropertyRequestQuerystring(inst) { + assert.equal(cfEventData(inst).request.querystring,''); + inst.setQuerystring('?key01=value01&key02=value02'); + assert.equal(cfEventData(inst).request.querystring,'key01=value01&key02=value02'); + inst.setQuerystring(' ??? key01=value01&key02=value02 '); + assert.equal(cfEventData(inst).request.querystring,'key01=value01&key02=value02'); +} + +function testPropertyRequestUri(inst) { + assert.equal(cfEventData(inst).request.uri,'/'); + inst.setUri('/index.html'); + assert.equal(cfEventData(inst).request.uri,'/index.html'); + inst.setUri(' // image.png '); + assert.equal(cfEventData(inst).request.uri,'/image.png'); + inst.setUri('image.png'); + assert.equal(cfEventData(inst).request.uri,'/image.png'); +} + +function testPropertyResponseHttpHeader(inst) { + assert.deepEqual(cfEventData(inst).response.headers,{}); + + // note: tested multiple HTTP header combos with `testPropertyRequestHttpHeader()` - so only limited testing here + inst + .addResponseHttpHeader('ETag','"e659e87d5a580948081a33d9d0e8d00e"') + .addResponseHttpHeader('Server','AmazonS3'); + + assert.deepEqual(cfEventData(inst).response.headers, + { + 'etag': [ + { key: 'ETag',value: '"e659e87d5a580948081a33d9d0e8d00e"' }, + ], + 'server': [ + { key: 'Server',value: 'AmazonS3' }, + ], + } + ); +} + +function testPropertyResponseStatus(inst) { + assert.equal(cfEventData(inst).response.status,'200'); + assert.equal(cfEventData(inst).response.statusDescription,'OK'); + + inst.setResponseHttpStatusCode(404); + assert.equal(cfEventData(inst).response.status,'404'); + assert.equal(cfEventData(inst).response.statusDescription,'Not Found'); + + inst.setResponseHttpStatusCode(666); + assert.equal(cfEventData(inst).response.status,'666'); + assert.equal(cfEventData(inst).response.statusDescription,''); +} + +function cfEventData(obj) { + return obj._event.Records[0].cf; +} + + +runner.execute(); diff --git a/test/test.sh b/test/test.sh new file mode 100755 index 0000000..d82e600 --- /dev/null +++ b/test/test.sh @@ -0,0 +1,9 @@ +#!/bin/bash -e + +DIRNAME=$(dirname "$0") + + +node "$DIRNAME/lib.test.js" +node "$DIRNAME/main-execute.test.js" +node "$DIRNAME/main-payloadverify.test.js" +node "$DIRNAME/main-property.test.js" diff --git a/test/util.js b/test/util.js new file mode 100644 index 0000000..864c328 --- /dev/null +++ b/test/util.js @@ -0,0 +1,27 @@ +'use strict'; + +class TestCaseRunner { + constructor() { + this._testCaseList = []; + } + + add(testCase) { + this._testCaseList.push(testCase); + } + + async execute() { + for (const testCase of this._testCaseList) { + try { + await testCase(); + } catch (ex) { + console.error(ex); + return; + } + } + } +} + + +module.exports = { + TestCaseRunner, +};