/edgy

Harness to assist in authoring tests for Node.js based AWS CloudFront Lambda@Edge functions.

Primary LanguageJavaScriptMIT LicenseMIT

Edgy

Test

A harness to assist in the authoring of tests for Node.js based AWS CloudFront Lambda@Edge functions.

Installation

$ npm install @magnetikonline/edgy

What can this do?

Edgy provides the following:

  • Generation of Lambda@Edge event structures 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 handlers are supported.
  • Implements various checks and bounds (duck typing) of payloads returned from edge functions, with the execute(handler) 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 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 event payload and executing a dummy function against it:

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 Lambda@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:

OriginRequest()

An example of crafting a origin request event payload and executing a dummy function against it:

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')
    .setOriginS3('mybucket.s3.ap-southeast-2.amazonaws.com','ap-southeast-2');

  const resp = await oReq.execute(
    // example Lambda@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:

OriginResponse()

An example of crafting a origin response event payload and executing a dummy function against it:

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 Lambda@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:

ViewerResponse()

An example of crafting a viewer response event payload and executing a dummy function against it:

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 Lambda@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:

Methods

setDistributionDomainName(name)

setDistributionId(id)

setRequestId(id)

Methods to set properties related to the CloudFront distribution:

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:

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'
        }
      }
    }
  ]
}
*/

setBody(data[,isTruncated])

Adds a collection of request body properties. The given data will be automatically base64 encoded:

const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();
harness.setBody('data payload',false);

/*
{
  Records: [
    {
      cf: {
        request: {
          body: {
            action: 'read-only',
            data: 'ZGF0YSBwYXlsb2Fk',
            encoding: 'base64',
            inputTruncated: false
          }
        }
      }
    }
  ]
}
*/

setRequestHttpHeader(key[,value])

addRequestHttpHeader(key,value)

Sets/adds HTTP headers to the request payload:

const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();
harness
  .addRequestHttpHeader('User-Agent','curl/8.4.0')
  .addRequestHttpHeader('X-Custom-Header','apples')
  .addRequestHttpHeader('X-Custom-Header','oranges');

/*
{
  Records: [
    {
      cf: {
        request: {
          headers: {
            'user-agent': [ { key: 'User-Agent', value: 'curl/8.4.0' } ],
            'x-custom-header': [
              { key: 'X-Custom-Header', value: 'apples' },
              { key: 'X-Custom-Header', value: 'oranges' }
            ]
          }
        }
      }
    }
  ]
}
*/

harness
  .setRequestHttpHeader('User-Agent','xyz')
  .setRequestHttpHeader('X-Custom-Header'); // remove HTTP header

/*
{
  Records: [
    {
      cf: {
        request: {
          headers: {
            'user-agent': [ { key: 'User-Agent', value: 'xyz' } ]
          }
        }
      }
    }
  ]
}
*/

setOriginCustom(domainName[,path])

setOriginKeepaliveTimeout(timeout)

setOriginPort(port)

setOriginHttps(isHttps)

setOriginReadTimeout(timeout)

setOriginSslProtocolList(protocolList)

Methods to define a custom origin property set for the request event payload:

const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();
harness
  .setOriginCustom('example.org','/custom/origin/path')
  .setOriginKeepaliveTimeout(35)
  .setOriginPort(1234)
  .setOriginHttps(true)
  .setOriginReadTimeout(25)
  .setOriginSslProtocolList(['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' ]
            }
          }
        }
      }
    }
  ]
}
*/

setOriginS3(domainName[,region][,path])

setOriginOAI(isOAI)

Methods to define an S3 origin property set for the request event payload:

const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();
harness
  .setOriginS3(
    'mybucket.s3.ap-southeast-2.amazonaws.com',
    'ap-southeast-2',
    '/s3/bucket/path')
  .setOriginOAI(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'
            }
          }
        }
      }
    }
  ]
}
*/

setOriginHttpHeader(key[,value])

addOriginHttpHeader(key,value)

Sets/adds HTTP headers to the origin request payload for custom and S3 targets:

const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();
harness
  .setOriginS3(
    'mybucket.s3.ap-southeast-2.amazonaws.com',
    'ap-southeast-2',
    '/s3/bucket/path')
  .addOriginHttpHeader('X-Custom-Header','apples')
  .addOriginHttpHeader('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' }
                ]
              }
            }
          }
        }
      }
    }
  ]
}
*/

harness.setOriginHttpHeader('X-Custom-Header'); // remove HTTP header

/*
{
  Records: [
    {
      cf: {
        request: {
          origin: {
            s3: {
              customHeaders: {}
            }
          }
        }
      }
    }
  ]
}
*/

setResponseHttpStatusCode(code)

setResponseHttpHeader(key[,value])

addResponseHttpHeader(key,value)

Methods to set properties related to the response received from the upstream CloudFront target:

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 function handlers.

After successful execution:

  • A series of validations are performed against the returned payload, verifying it should be a usable response for CloudFront to accept. In no way consider this to be comprehensive or complete - but should catch many obvious malformed payloads.
  • Return the transformed payload from the executed Lambda@Edge function, where additional assertions can then be performed.
const harness = new edgy.EVENT_TYPE_CONSTRUCTOR();

// -- construct event payload using instance methods --
// .setHttpMethod()
// .setUri()
// .setQuerystring()
// etc.

// execute function against payload
const resp = await harness.execute(
  // example Lambda@Edge function
  async function(event) {
    return event.Records[0].cf.response;
  }
);

Reference