This repository has been archived on 2026-04-30. You can view files and clone it, but cannot push or open issues or pull requests.
next-deploy/packages/aws-component/__tests__/custom-inputs.test.ts
2020-07-05 22:30:21 -07:00

748 lines
22 KiB
TypeScript

import fse from 'fs-extra';
import path from 'path';
import { mockDomain } from 'aws-domain';
import { mockS3 } from '@serverless/aws-s3';
import { mockUpload } from 'aws-sdk';
import { mockLambda, mockLambdaPublish } from 'aws-lambda';
import { mockCloudFront } from 'aws-cloudfront';
import NextjsComponent, { DEFAULT_LAMBDA_CODE_DIR, API_LAMBDA_CODE_DIR } from '../src/component';
import { getDomains } from '../src/utils';
import { cleanupFixtureDirectory } from './test-utils';
const createNextComponent = (inputs) => {
const component = new NextjsComponent(inputs);
component.context.credentials = {
aws: {
accessKeyId: '123',
secretAccessKey: '456',
},
};
return component;
};
const mockServerlessComponentDependencies = ({ expectedDomain }) => {
mockS3.mockResolvedValue({
name: 'bucket-xyz',
});
mockLambda.mockResolvedValue({
arn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func',
});
mockLambdaPublish.mockResolvedValue({
version: 'v1',
});
mockCloudFront.mockResolvedValueOnce({
url: 'https://cloudfrontdistrib.amazonaws.com',
});
mockDomain.mockResolvedValueOnce({
domains: [expectedDomain],
});
};
describe('Custom inputs', () => {
let componentOutputs;
let consoleWarnSpy;
beforeEach(() => {
const realFseRemove = fse.remove.bind({});
jest.spyOn(fse, 'remove').mockImplementation((filePath) => {
// don't delete mocked .next/ files as they're needed for the tests and committed to source control
if (!filePath.includes('.next' + path.sep)) {
return realFseRemove(filePath);
}
});
consoleWarnSpy = jest.spyOn(console, 'warn').mockReturnValue();
});
afterEach(() => {
fse.remove.mockRestore();
consoleWarnSpy.mockRestore();
});
describe.each`
inputRegion | expectedRegion
${undefined} | ${'us-east-1'}
${'eu-west-2'} | ${'eu-west-2'}
`(`When input region is $inputRegion`, ({ inputRegion, expectedRegion }) => {
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
bucketRegion: inputRegion,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`passes the ${expectedRegion} region to s3 component`, () => {
expect(mockS3).toBeCalledWith(
expect.objectContaining({
region: expectedRegion,
})
);
});
});
describe.each`
inputDomains | expectedDomain
${['dev', 'example.com']} | ${'https://dev.example.com'}
${['www', 'example.com']} | ${'https://www.example.com'}
${'example.com'} | ${'https://www.example.com'}
${[undefined, 'example.com']} | ${'https://www.example.com'}
${'example.com'} | ${'https://www.example.com'}
`('Custom domain', ({ inputDomains, expectedDomain }) => {
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({
expectedDomain,
});
const component = createNextComponent({});
componentOutputs = await component({
policy: 'arn:aws:iam::aws:policy/CustomRole',
domain: inputDomains,
description: 'Custom description',
memory: 512,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it('uses domain to provision custom domain', () => {
const { domain, subdomain } = getDomains(inputDomains);
expect(mockDomain).toBeCalledWith({
defaultCloudfrontInputs: {},
domainType: 'both',
privateZone: false,
domain,
subdomains: {
[subdomain as string]: {
url: 'https://cloudfrontdistrib.amazonaws.com',
},
},
});
});
it('uses custom policy document provided', () => {
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
description: expect.stringContaining('Custom description'),
role: expect.objectContaining({
policy: {
arn: 'arn:aws:iam::aws:policy/CustomRole',
},
}),
})
);
});
it('outputs custom domain url', () => {
expect(componentOutputs.appUrl).toEqual(expectedDomain);
});
});
describe.each`
nextConfigDir | nextStaticDir | fixturePath
${'nextConfigDir'} | ${'nextStaticDir'} | ${path.join(__dirname, './fixtures/split-app')}
`('nextConfigDir=$nextConfigDir, nextStaticDir=$nextStaticDir', ({ fixturePath, ...inputs }) => {
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
nextConfigDir: inputs.nextConfigDir,
nextStaticDir: inputs.nextStaticDir,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it('uploads static assets to S3 correctly', () => {
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
Key: expect.stringMatching('_next/static/test-build-id/placeholder.js'),
})
);
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
Key: expect.stringMatching('static-pages/terms.html'),
})
);
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
Key: expect.stringMatching('static/donotdelete.txt'),
})
);
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
Key: expect.stringMatching('public/favicon.ico'),
})
);
});
});
describe.each`
publicDirectoryCache | expected
${undefined} | ${'public, max-age=31536000, must-revalidate'}
${false} | ${undefined}
${{ test: '/.(ico|png)$/i', value: 'public, max-age=306000' }} | ${'public, max-age=306000'}
`(
'input=inputPublicDirectoryCache, expected=$expectedPublicDirectoryCache',
({ publicDirectoryCache, expected }) => {
let tmpCwd;
const fixturePath = path.join(__dirname, './fixtures/simple-app');
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
publicDirectoryCache,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`sets the ${expected} Cache - Control header on ${publicDirectoryCache} `, () => {
expect(mockUpload).toBeCalledWith(
expect.objectContaining({
Key: expect.stringMatching('public/favicon.ico'),
CacheControl: expected,
})
);
});
}
);
describe.each([
[undefined, { defaultMemory: 512, apiMemory: 512 }],
[{}, { defaultMemory: 512, apiMemory: 512 }],
[1024, { defaultMemory: 1024, apiMemory: 1024 }],
[{ defaultLambda: 1024 }, { defaultMemory: 1024, apiMemory: 512 }],
[{ apiLambda: 2048 }, { defaultMemory: 512, apiMemory: 2048 }],
[
{ defaultLambda: 128, apiLambda: 2048 },
{ defaultMemory: 128, apiMemory: 2048 },
],
])('Lambda memory input', (inputMemory, expectedMemory) => {
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({
memory: inputMemory,
});
componentOutputs = await component({
memory: inputMemory,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`sets default lambda memory to ${expectedMemory.defaultMemory} and api lambda memory to ${expectedMemory.apiMemory} `, () => {
const { defaultMemory, apiMemory } = expectedMemory;
// default Lambda
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR),
memory: defaultMemory,
})
);
// api Lambda
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, API_LAMBDA_CODE_DIR),
memory: apiMemory,
})
);
});
});
describe.each`
inputTimeout | expectedTimeout
${undefined} | ${{ defaultTimeout: 10, apiTimeout: 10 }}
${{}} | ${{ defaultTimeout: 10, apiTimeout: 10 }}
${40} | ${{ defaultTimeout: 40, apiTimeout: 40 }}
${{ defaultLambda: 20 }} | ${{ defaultTimeout: 20, apiTimeout: 10 }}
${{ apiLambda: 20 }} | ${{ defaultTimeout: 10, apiTimeout: 20 }}
${{ defaultLambda: 15, apiLambda: 20 }} | ${{ defaultTimeout: 15, apiTimeout: 20 }}
`('Input timeout options', ({ inputTimeout, expectedTimeout }) => {
let tmpCwd;
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
timeout: inputTimeout,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`sets default lambda timeout to ${expectedTimeout.defaultTimeout} and api lambda timeout to ${expectedTimeout.apiTimeout} `, () => {
const { defaultTimeout, apiTimeout } = expectedTimeout;
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR),
timeout: defaultTimeout,
})
);
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, API_LAMBDA_CODE_DIR),
timeout: apiTimeout,
})
);
});
});
describe.each`
inputRuntime | expectedRuntime
${undefined} | ${{ defaultRuntime: 'nodejs12.x', apiRuntime: 'nodejs12.x' }}
${{}} | ${{ defaultRuntime: 'nodejs12.x', apiRuntime: 'nodejs12.x' }}
${'nodejs10.x'} | ${{ defaultRuntime: 'nodejs10.x', apiRuntime: 'nodejs10.x' }}
${{ defaultLambda: 'nodejs10.x' }} | ${{ defaultRuntime: 'nodejs10.x', apiRuntime: 'nodejs12.x' }}
${{ apiLambda: 'nodejs10.x' }} | ${{ defaultRuntime: 'nodejs12.x', apiRuntime: 'nodejs10.x' }}
${{ defaultLambda: 'nodejs10.x', apiLambda: 'nodejs10.x' }} | ${{ defaultRuntime: 'nodejs10.x', apiRuntime: 'nodejs10.x' }}
`('Input runtime options', ({ inputRuntime, expectedRuntime }) => {
let tmpCwd;
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
runtime: inputRuntime,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`sets default lambda runtime to ${expectedRuntime.defaultRuntime} and api lambda runtime to ${expectedRuntime.apiRuntime} `, () => {
const { defaultRuntime, apiRuntime } = expectedRuntime;
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR),
runtime: defaultRuntime,
})
);
expect(mockLambda).toBeCalledWith(
expect.objectContaining({
code: path.join(fixturePath, API_LAMBDA_CODE_DIR),
runtime: apiRuntime,
})
);
});
});
describe.each`
inputName | expectedName
${undefined} | ${{ defaultName: undefined, apiName: undefined }}
${{}} | ${{ defaultName: undefined, apiName: undefined }}
${'fooFunction'} | ${{ defaultName: 'fooFunction', apiName: 'fooFunction' }}
${{ defaultLambda: 'fooFunction' }} | ${{ defaultName: 'fooFunction', apiName: undefined }}
${{ apiLambda: 'fooFunction' }} | ${{ defaultName: undefined, apiName: 'fooFunction' }}
${{ defaultLambda: 'fooFunction', apiLambda: 'barFunction' }} | ${{ defaultName: 'fooFunction', apiName: 'barFunction' }}
`('Lambda name input', ({ inputName, expectedName }) => {
let tmpCwd;
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
name: inputName,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it(`sets default lambda name to ${expectedName.defaultName} and api lambda name to ${expectedName.apiName} `, () => {
const { defaultName, apiName } = expectedName;
const expectedDefaultObject = {
code: path.join(fixturePath, DEFAULT_LAMBDA_CODE_DIR),
};
if (defaultName) expectedDefaultObject.name = defaultName;
expect(mockLambda).toBeCalledWith(expect.objectContaining(expectedDefaultObject));
const expectedApiObject = {
code: path.join(fixturePath, API_LAMBDA_CODE_DIR),
};
if (apiName) expectedApiObject.name = apiName;
expect(mockLambda).toBeCalledWith(expect.objectContaining(expectedApiObject));
});
});
describe.each([
// no input
[undefined, {}],
// empty input
[{}, {}],
// ignores origin-request and origin-response triggers as they're reserved by serverless-next.js
[
{
defaults: {
ttl: 500,
'lambda@edge': {
'origin-request': 'ignored',
'origin-response': 'also ignored',
},
},
},
{ defaults: { ttl: 500 } },
],
// allow lamdba@edge triggers other than origin-request and origin-response
[
{
defaults: {
ttl: 500,
'lambda@edge': {
'viewer-request': 'used value',
},
},
},
{
defaults: {
ttl: 500,
'lambda@edge': { 'viewer-request': 'used value' },
},
},
],
[
{
defaults: {
forward: { cookies: 'all', headers: 'X', queryString: true },
},
},
{
defaults: {
forward: { cookies: 'all', headers: 'X', queryString: true },
},
},
],
// ignore custom lambda@edge origin-request trigger set on the api cache behaviour
[
{
'api/*': {
ttl: 500,
'lambda@edge': { 'origin-request': 'ignored value' },
},
},
{ 'api/*': { ttl: 500 } },
],
// allow other lambda@edge triggers on the api cache behaviour
[
{
'api/*': {
ttl: 500,
'lambda@edge': { 'origin-response': 'used value' },
},
},
{
'api/*': {
ttl: 500,
'lambda@edge': { 'origin-response': 'used value' },
},
},
],
// custom origins and expanding relative URLs to full S3 origin
[
{
origins: [
'http://some-origin',
'/relative',
{ url: 'http://diff-origin' },
{ url: '/diff-relative' },
],
},
{
origins: [
'http://some-origin',
'http://bucket-xyz.s3.amazonaws.com/relative',
{ url: 'http://diff-origin' },
{ url: 'http://bucket-xyz.s3.amazonaws.com/diff-relative' },
],
},
],
// custom page cache behaviours
[
{
'/terms': {
ttl: 5500,
'misc-param': 'misc-value',
'lambda@edge': {
'origin-request': 'ignored value',
},
},
},
{
'/terms': {
ttl: 5500,
'misc-param': 'misc-value',
},
},
],
[
{
'/customers/stan-sack': {
ttl: 5500,
},
},
{
'/customers/stan-sack': {
ttl: 5500,
},
},
],
])('Custom cloudfront inputs', (inputCloudfrontConfig, expectedInConfig) => {
let tmpCwd;
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
const { origins = [], defaults = {}, ...other } = expectedInConfig;
const expectedDefaultCacheBehaviour = {
...defaults,
'lambda@edge': {
'origin-request': 'arn:aws:lambda:us-east-1:123456789012:function:my-func:v1',
...defaults['lambda@edge'],
},
};
const expectedApiCacheBehaviour = {
...expectedInConfig['api/*'],
allowedHttpMethods: ['HEAD', 'DELETE', 'POST', 'GET', 'OPTIONS', 'PUT', 'PATCH'],
'lambda@edge': {
...(expectedInConfig['api/*'] && expectedInConfig['api/*']['lambda@edge']),
'origin-request': 'arn:aws:lambda:us-east-1:123456789012:function:my-func:v1',
},
};
const customPageCacheBehaviours = {};
Object.entries(other).forEach(([path, cacheBehaviour]) => {
customPageCacheBehaviours[path] = {
...cacheBehaviour,
'lambda@edge': {
'origin-request': 'arn:aws:lambda:us-east-1:123456789012:function:my-func:v1',
...(cacheBehaviour && cacheBehaviour['lambda@edge']),
},
};
});
const cloudfrontConfig = {
defaults: {
ttl: 0,
allowedHttpMethods: ['HEAD', 'GET'],
forward: {
cookies: 'all',
queryString: true,
},
compress: true,
...expectedDefaultCacheBehaviour,
},
origins: [
{
pathPatterns: {
...customPageCacheBehaviours,
'_next/static/*': {
...customPageCacheBehaviours['_next/static/*'],
ttl: 86400,
forward: {
headers: 'none',
cookies: 'none',
queryString: false,
},
},
'_next/data/*': {
ttl: 0,
allowedHttpMethods: ['HEAD', 'GET'],
'lambda@edge': {
'origin-request': 'arn:aws:lambda:us-east-1:123456789012:function:my-func:v1',
},
},
'api/*': {
ttl: 0,
...expectedApiCacheBehaviour,
},
'static/*': {
...customPageCacheBehaviours['static/*'],
ttl: 86400,
forward: {
headers: 'none',
cookies: 'none',
queryString: false,
},
},
},
private: true,
url: 'http://bucket-xyz.s3.amazonaws.com',
},
...origins,
],
};
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
const component = createNextComponent({});
componentOutputs = await component({
cloudfront: inputCloudfrontConfig,
});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it('Sets cloudfront options if present', () => {
expect(mockCloudFront).toBeCalledWith(expect.objectContaining(cloudfrontConfig));
});
});
describe.each`
cloudFrontInput | expectedErrorMessage
${{ 'some-invalid-page-route': { ttl: 100 } }} | ${'Could not find next.js pages for "some-invalid-page-route"'}
${{ '/api': { ttl: 100 } }} | ${'route "/api" is not supported'}
${{ api: { ttl: 100 } }} | ${'route "api" is not supported'}
${{ 'api/test': { ttl: 100 } }} | ${'route "api/test" is not supported'}
`('Invalid cloudfront inputs', ({ cloudFrontInput, expectedErrorMessage }) => {
const fixturePath = path.join(__dirname, './fixtures/generic-fixture');
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it('throws the correct error', async () => {
expect.assertions(1);
try {
await createNextComponent({})({
cloudfront: cloudFrontInput,
});
} catch (err) {
expect(err.message).toContain(expectedErrorMessage);
}
});
});
describe('Build using serverless trace target', () => {
const fixturePath = path.join(__dirname, './fixtures/simple-app');
let tmpCwd;
beforeEach(async () => {
tmpCwd = process.cwd();
process.chdir(fixturePath);
mockServerlessComponentDependencies({});
});
afterEach(() => {
process.chdir(tmpCwd);
return cleanupFixtureDirectory(fixturePath);
});
it('builds correctly', async () => {
await createNextComponent({})({
useServerlessTraceTarget: true,
});
});
});
});