Implementing Unit Testing

When developing any dynamic functionality it is best practice to implement testing. This will ensure anytime the code is deployed it is in a working state and will provide a good user experience to the visitors/users of your service.

# Pre-requisites

This article will be based on the blog described in Hexo, AWS and Serverless Framework,

Securing A Test Environment Using AWS WAF and Securing S3 Bucket from Direct Access; however, the basic concepts

should be applicable to any NodeJS functionality or any static site.

# Install a test framework for Javascript

Although it’s possible to write a custom test framework, it’s much easier to use an existing solution. This example will use Jest. Start by installing Jest with NPM.

npm i jest --save-dev

# Create a test

The next step is to create a test. As the example project only has one JavaScript function these instructions will only implement a test for it. There is no reason many more tests can’t be created. To make it easy to find tests the new test will be created in tests/functions/urlRewrite.test.js.

// Load the file to test
const urlRewrite = require('../../functions/urlRewrite');
// Load some data that can be reused for other lambda@Edge functions
const lambdaAtEdgeFixture = require('../fixtures/lambdaAtEdge');

test(
    'url-rewrite handler appends index.html to root request',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.root_object.Records[0].cf.request.uri + 'index.html' };
        expect(
            urlRewrite.handler(
                lambdaAtEdgeFixture.event.root_object,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler appends index.html to directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.subdirectory.Records[0].cf.request.uri + 'index.html' };
        expect(
            urlRewrite.handler(
                lambdaAtEdgeFixture.event.subdirectory,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler does not append index.html to root-directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.file.Records[0].cf.request.uri };
        expect(
            urlRewrite.handler(
                lambdaAtEdgeFixture.event.file,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler does not append index.html to non-directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.file_in_subdirectory.Records[0].cf.request.uri };
        expect(
            urlRewrite.handler(
                lambdaAtEdgeFixture.event.file_in_subdirectory,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

The 4th line of the test includes a file that doesn’t exist yet. This file contains some sample values that can be reused if we need to test other Lambda@Edge functions. Create the file at tests/fixtures/lambdaAtEdge.js (this is a large file so the content is at the bottom of this post).

Test that the test suite works.

jest

Now that the javascript is tested, it’s worth testing the links on the static site to make sure they are valid. For this broken-link-checker-local can do all the hard work, so install it.

npm i broken-link-checker-local --save-dev

As long as the site has been deployed previously (it has been if you’re following all the blog posts) it’s possible to test for broken links.

blcl --filter-level 3 --get --recursive public

# Add NPM scripts to test more easily

Remembering the right commands, especially when managing multiple sites, can be confusing and easy to forget. To avoid this, it’s possible to define some scripts in the pacakge.json file. The commands to add are as follows:

  • clean: a simple wrapper for hexo clean
  • build: a wrapper for hexo generate
  • buildclean: a wrapper for both clean and build
  • linkcheck: run the link checking script
  • jest: run the jest tests
  • test: run an end-to-end test
  "scripts": {
    "clean": "hexo clean",
    "build": "hexo generate",
    "cleanbuild": "npm run clean && npm run build",
    "linkcheck": "blcl --filter-level 3 --get --recursive public",
    "jest": "jest",
    "test": "npm run cleanbuild && npm run jest && npm run linkcheck"
  }

# Ignore static site files when running Jest

Because the static site can contain files that Jest will assume are tests a configuration needs to be added to package.json to ignore these files.

  "jest": {
    "testPathIgnorePatterns": [
      "/node_modules/",
      "/source/code/",
      "/public/"
    ]
  }

# Run the full test suite

Running a full test is now as simple as using NPM to run the test command.

npm run test

Based on the posts undertaken in the examples the following links will show as broken in a number of files:

  • atom.xml
  • favicon.png

# The Final Configuration Files

// Load the file to test
const urlRewriteTest = require('../../functions/urlRewrite');
// Load some data that can be reused for other lambda@Edge functions
const lambdaAtEdgeFixture = require('../fixtures/lambdaAtEdge');

test(
    'url-rewrite handler appends index.html to root request',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.root_object.Records[0].cf.request.uri + 'index.html' };
        expect(
            urlRewriteTest.handler(
                lambdaAtEdgeFixture.event.root_object,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler appends index.html to directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.subdirectory.Records[0].cf.request.uri + 'index.html' };
        expect(
            urlRewriteTest.handler(
                lambdaAtEdgeFixture.event.subdirectory,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler does not append index.html to root-directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.file.Records[0].cf.request.uri };
        expect(
            urlRewriteTest.handler(
                lambdaAtEdgeFixture.event.file,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);

test(
    'url-rewrite handler does not append index.html to non-directory requests',
    () => {
        const expectedResponse = { uri: lambdaAtEdgeFixture.event.file_in_subdirectory.Records[0].cf.request.uri };
        expect(
            urlRewriteTest.handler(
                lambdaAtEdgeFixture.event.file_in_subdirectory,
                lambdaAtEdgeFixture.context.webClient,
                lambdaAtEdgeFixture.callback
            )
        ).toEqual(expect.objectContaining(expectedResponse));
    }
);
{
  "name": "hexo-site",
  "version": "0.0.0",
  "private": true,
  "hexo": {
    "version": "3.8.0"
  },
  "devDependencies": {
    "@silvermine/serverless-plugin-cloudfront-lambda-edge": "^2.1.1",
    "broken-link-checker-local": "^0.2.0",
    "hexo": "^3.7.0",
    "hexo-generator-archive": "^0.1.5",
    "hexo-generator-category": "^0.1.3",
    "hexo-generator-index": "^0.2.1",
    "hexo-generator-tag": "^0.2.0",
    "hexo-renderer-ejs": "^0.3.1",
    "hexo-renderer-marked": "^0.3.2",
    "hexo-renderer-stylus": "^0.3.3",
    "hexo-server": "^0.3.1",
    "jest": "^24.0.0",
    "serverless-lambda-version": "^0.1.2",
    "serverless-plugin-scripts": "^1.0.2",
    "serverless-s3-deploy": "^0.8.0"
  },
  "scripts": {
    "clean": "hexo clean",
    "build": "hexo generate",
    "cleanbuild": "npm run clean && npm run build",
    "linkcheck": "blcl --filter-level 3 --get --recursive --exclude http://2019-01-28-securing-s3.demo.alphageek.com.au.s3-website-us-east-1.amazonaws.com --exclude http://localhost:4000 --exclude http://dev./%3Cyour_domain%3E/ public",
    "jest": "jest",
    "test": "npm run cleanbuild && npm run jest && npm run linkcheck"
  },
  "jest": {
    "testPathIgnorePatterns": [
      "/node_modules/",
      "/source/code/",
      "/public/"
    ]
  }
}
# The name of your project
service: **project**

# Plugins for additional Serverless functionality
plugins:
  - serverless-s3-deploy
  - serverless-plugin-scripts
  - '@silvermine/serverless-plugin-cloudfront-lambda-edge'

# Configuration for AWS
provider:
  name: aws
  runtime: nodejs8.10
  profile: serverless
  # Some future functionality requires us to use us-east-1 at this time
  region: us-east-1

  # This enables us to use the default stage definition, but override it from the command line
  stage: ${opt:stage, self:provider.stage}
  # This enables us to prepend the stage name for non-production environments
  domain:
    fulldomain:
      prod: ${self:custom.domain.domain}
      other: ${self:custom.stage}.${self:custom.domain.domain}
    # This value has been customised so I can maintain multiple demonstration sites
    domain: ${self:custom.postname}.${self:custom.domain.zonename}
    domainname: ${self:custom.domain.fulldomain.${self:custom.stage}, self:custom.domain.fulldomain.other}
    # DNS Zone name (this is only required so I can maintain multiple demonstration sites)
    zonename: alphageek.com.au
    cacheControlMaxAgeHTMLByStage:
      # HTML Cache time for production environment
      prod: 3600
      # HTML Cache time for other environments
      other: 0
    cacheControlMaxAgeHTML: ${self:custom.domain.cacheControlMaxAgeHTMLByStage.${self:custom.stage}, self:custom.domain.cacheControlMaxAgeHTMLByStage.other}
    sslCertificateARN: arn:aws:acm:us-east-1:165657443288:certificate/61d202ea-12f2-4282-b602-9c3b83183c7a
  assets:
    targets:
      # Configuration for HTML files (overriding the default cache control age)
      - bucket:
          Ref: WebsiteS3Bucket
        acl: private
        files:
          - source: ./public/
            headers:
              CacheControl: max-age=${self:custom.domain.cacheControlMaxAgeHTML}
            empty: true
            globs:
              - '**/*.html'
      # Configuration for all assets
      - bucket:
          Ref: WebsiteS3Bucket
        acl: private
        files:
          - source: ./public/
            empty: true
            globs:
              - '**/*.js'
              - '**/*.css'
              - '**/*.jpg'
              - '**/*.png'
              - '**/*.gif'
  scripts:
    hooks:
      # Run these commands when creating the deployment artifacts
      package:createDeploymentArtifacts: >
        hexo clean &&
        hexo generate
      # Run these commands after infrastructure changes have been completed
      deploy:finalize: >
        sls s3deploy -s ${self:custom.stage}
  # AWS Region to S3 website hostname mapping
  s3DNSName:
    us-east-2: s3-website.us-east-2.amazonaws.com
    us-east-1: s3-website-us-east-1.amazonaws.com
    us-west-1: s3-website-us-west-1.amazonaws.com
    us-west-2: s3-website-us-west-2.amazonaws.com
    ap-south-1: s3-website.ap-south-1.amazonaws.com
    ap-northeast-3: s3-website.ap-northeast-3.amazonaws.com
    ap-northeast-2: s3-website.ap-northeast-2.amazonaws.com
    ap-southeast-1: s3-website-ap-southeast-1.amazonaws.com
    ap-southeast-2: s3-website-ap-southeast-2.amazonaws.com
    ap-northeast-1: s3-website-ap-northeast-1.amazonaws.com
    ca-central-1: s3-website.ca-central-1.amazonaws.com
    eu-central-1: s3-website.eu-central-1.amazonaws.com
    eu-west-1: s3-website-eu-west-1.amazonaws.com
    eu-west-2: s3-website.eu-west-2.amazonaws.com
    eu-west-3: s3-website.eu-west-3.amazonaws.com
    eu-north-1: s3-website.eu-north-1.amazonaws.com
    sa-east-1: s3-website-sa-east-1.amazonaws.com
  # Determine what resources file to include based on the current stage
  customConfigFile: ${self:custom.customConfigFiles.${self:custom.stage}, self:custom.customConfigFiles.other}
  customConfigFiles:
    prod: prod
    other: other

# Define the Lambda functions for the site
functions:
  # This function will be deployed to Lambda@Edge and rewrite URLs to include index.html
  urlrewrite:
    name: ${self:service}-${self:custom.stage}-cf-url-rewriter
    handler: functions/urlRewrite.handler
    memorySize: 128
    timeout: 1
    lambdaAtEdge:
      distribution: WebsiteCloudFrontDistribution
      eventType: origin-request

# Define the resources we will need to host the site
resources:
  # Include the resources file
  - ${file(config/resources.yml)}
  # Include the outputs file
  - ${file(config/outputs.yml)}
  # Include a custom configuration file based on the environment
  - ${file(config/resources/environment/${self:custom.customConfigFile}.yml)}
Resources:
  # Set-up an S3 bucket to store the site
  WebsiteS3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      AccessControl: BucketOwnerFullControl
      BucketName: ${self:custom.domain.domainname}
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
  # Set-up a policy on the bucket so it can be used as a website
  WebsiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      PolicyDocument:
        Id:
          Fn::Join:
            - ""
            - - ${self:service.name}
              - BucketPolicy
        Statement:
          - Sid: CloudFrontForGetBucketObjects
            Effect: Allow
            Principal:
              CanonicalUser:
                Fn::GetAtt:
                  - CloudFrontIdentity
                  - S3CanonicalUserId
            Action: 's3:GetObject'
            Resource:
              Fn::Join:
                - ''
                -
                  - 'arn:aws:s3:::'
                  - Ref: WebsiteS3Bucket
                  - /*
      Bucket:
        Ref: WebsiteS3Bucket
  # Configure CloudFront to get all content from S3
  WebsiteCloudFrontDistribution:
    Type: 'AWS::CloudFront::Distribution'
    Properties:
      DistributionConfig:
        WebACLId:
          Ref: CustomAuthorizationHeaderRestriction
        Aliases:
          - ${self:custom.domain.domainname}
          - www.${self:custom.domain.domainname}
        CustomErrorResponses:
          - ErrorCode: '404'
            ResponsePagePath: "/error.html"
            ResponseCode: '200'
            ErrorCachingMinTTL: '30'
        DefaultCacheBehavior:
          Compress: true
          ForwardedValues:
            QueryString: false
            Cookies:
              Forward: all
          SmoothStreaming: false
          TargetOriginId: defaultOrigin
          ViewerProtocolPolicy: redirect-to-https
        DefaultRootObject: index.html
        Enabled: true
        Origins:
          - DomainName:
              Fn::GetAtt:
                - WebsiteS3Bucket
                - DomainName
            Id: defaultOrigin
            S3OriginConfig:
              OriginAccessIdentity:
                Fn::Join:
                  - "/"
                  - - origin-access-identity
                    - cloudfront
                    - Ref: CloudFrontIdentity
        PriceClass: PriceClass_All
        ViewerCertificate:
          AcmCertificateArn: ${self:custom.domain.sslCertificateARN}
          SslSupportMethod: sni-only
  # DNS Record for the domain
  WebsiteDNSRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
            - WebsiteCloudFrontDistribution
            - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName: ${self:custom.domain.domain}.
      Name: ${self:custom.domain.domainname}
      Type: 'A'
  # DNS Record for www.domain
  WebsiteWWWDNSRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      AliasTarget:
        DNSName:
          Fn::GetAtt:
            - WebsiteCloudFrontDistribution
            - DomainName
        HostedZoneId: Z2FDTNDATAQYW2
      HostedZoneName: ${self:custom.domain.domain}.
      Name: www.${self:custom.domain.domainname}
      Type: 'A'
  # Predicate to match the authorization header
  CustomAuthorizationHeader:
    Type: AWS::WAF::ByteMatchSet
    Properties:
      ByteMatchTuples:
        -
          FieldToMatch:
            Type: HEADER
            Data: Authorization
          TargetString:
            Fn::Join:
              - " "
              - - Custom
                - "**Password**"
          TextTransformation: NONE
          PositionalConstraint: EXACTLY
      Name:
        Fn::Join:
          - "_"
          - - ${self:custom.domain.domainname}
            - Authorization
            - Header
  CustomAuthorizationHeaderRule:
    Type: AWS::WAF::Rule
    Properties:
      Name:
        Fn::Join:
          - "_"
          - - ${self:custom.domain.domainname}
            - Authorization
            - Header
            - Rule
      MetricName:
        Fn::Join:
          - ""
          - - ${self:custom.stage}
            - ${self:service.name}
            - Authorization
            - Header
            - Rule
      Predicates:
        -
          DataId:
            Ref: CustomAuthorizationHeader
          Negated: false
          Type: ByteMatch
Outputs:
  WebsiteURL:
    Value:
      Fn::GetAtt:
        - WebsiteS3Bucket
        - WebsiteURL
    Description: URL for my website hosted on S3
  S3BucketSecureURL:
    Value:
      Fn::Join:
        - ''
        -
          - 'https://'
          - Fn::GetAtt:
              - WebsiteS3Bucket
              - DomainName
    Description: Secure URL of S3 bucket to hold website content
Resources:
  # Allow the custom authorisation header in the production environment
  CustomAuthorizationHeaderRestriction:
    Type: AWS::WAF::WebACL
    Properties:
      DefaultAction:
        Type: ALLOW
      Name:
        Fn::Join:
          - "_"
          - - ${self:custom.domain.domainname}
            - Authorization
            - Header
            - Restriction
      MetricName:
        Fn::Join:
          - ""
          - - ${self:custom.stage}
            - ${self:service.name}
            - Authorization
            - Header
            - Restriction
      Rules:
        -
          Action:
            Type: ALLOW
          Priority: 1
          RuleId:
            Ref: CustomAuthorizationHeaderRule
Resources:
  # Require the custom authorisation header with the correct password in non-production environment
  CustomAuthorizationHeaderRestriction:
    Type: AWS::WAF::WebACL
    Properties:
      DefaultAction:
        Type: BLOCK
      Name:
        Fn::Join:
          - "_"
          - - ${self:custom.domain.domainname}
            - Authorization
            - Header
            - Restriction
      MetricName:
        Fn::Join:
          - ""
          - - ${self:custom.stage}
            - ${self:service.name}
            - Authorization
            - Header
            - Restriction
      Rules:
        -
          Action: ALLOW
          Priority: 1
          RuleId:
            Ref: CustomAuthorizationHeaderRule

# Example Site