serverless/test/unit/lib/classes/Variables.test.js
Mariusz Nowak 5b54ed2e26 refactor: Drop old variables engine related deprecation
Old variables engine will be removed with next major, so there's no point to communicate breaking changes that were supposed to introduced if it was to stay
2021-07-06 12:03:51 +02:00

2916 lines
108 KiB
JavaScript

'use strict';
/* eslint-disable no-unused-expressions */
const BbPromise = require('bluebird');
const chai = require('chai');
const jc = require('json-cycle');
const os = require('os');
const path = require('path');
const proxyquire = require('proxyquire');
const sinon = require('sinon');
const yaml = require('js-yaml');
const _ = require('lodash');
const overrideEnv = require('process-utils/override-env');
const AwsProvider = require('../../../../lib/plugins/aws/provider');
const fse = require('fs-extra');
const Serverless = require('../../../../lib/Serverless');
const slsError = require('../../../../lib/classes/Error');
const Utils = require('../../../../lib/classes/Utils');
const Variables = require('../../../../lib/classes/Variables');
const ServerlessError = require('../../../../lib/serverless-error');
const { getTmpDirPath } = require('../../../utils/fs');
const skipOnDisabledSymlinksInWindows = require('@serverless/test/skip-on-disabled-symlinks-in-windows');
const runServerless = require('../../../utils/run-serverless');
BbPromise.longStackTraces(true);
chai.use(require('chai-as-promised'));
chai.use(require('sinon-chai'));
chai.should();
const expect = chai.expect;
describe('Variables', () => {
let serverless;
let restoreEnv;
beforeEach(() => {
({ restoreEnv } = overrideEnv());
serverless = new Serverless({
commands: ['print'],
options: {},
serviceDir: process.cwd(),
configuration: {},
configurationFilename: 'serverless.yml',
});
});
const afterCallback = () => restoreEnv();
afterEach(afterCallback);
describe('#constructor()', () => {
it('should attach serverless instance', () => {
const variablesInstance = new Variables(serverless);
expect(variablesInstance.serverless).to.equal(serverless);
});
it('should not set variableSyntax in constructor', () => {
const variablesInstance = new Variables(serverless);
expect(variablesInstance.variableSyntax).to.be.undefined;
});
});
describe('#loadVariableSyntax()', () => {
it('should set variableSyntax', () => {
// eslint-disable-next-line no-template-curly-in-string
serverless.service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
expect(serverless.variables.variableSyntax).to.be.a('RegExp');
});
});
describe('#populateService()', () => {
it('should remove problematic attributes bofore calling populateObjectImpl with the service', () => {
const prepopulateServiceStub = sinon
.stub(serverless.variables, 'prepopulateService')
.returns(BbPromise.resolve());
const populateObjectStub = sinon
.stub(serverless.variables, 'populateObjectImpl')
.callsFake((val) => {
expect(val).to.equal(serverless.service);
expect(val.provider.variableSyntax).to.be.undefined;
expect(val.serverless).to.be.undefined;
return BbPromise.resolve();
});
return serverless.variables
.populateService()
.should.be.fulfilled.then()
.finally(() => {
prepopulateServiceStub.restore();
populateObjectStub.restore();
});
});
it('should clear caches and remaining state *before* [pre]populating service', () => {
const prepopulateServiceStub = sinon
.stub(serverless.variables, 'prepopulateService')
.callsFake((val) => {
expect(serverless.variables.deep).to.eql([]);
expect(serverless.variables.tracker.getAll()).to.eql([]);
return BbPromise.resolve(val);
});
const populateObjectStub = sinon
.stub(serverless.variables, 'populateObjectImpl')
.callsFake((val) => {
expect(serverless.variables.deep).to.eql([]);
expect(serverless.variables.tracker.getAll()).to.eql([]);
return BbPromise.resolve(val);
});
serverless.variables.deep.push('${foo:}');
const prms = BbPromise.resolve('foo');
serverless.variables.tracker.add('foo:', prms, '${foo:}');
prms.state = 'resolved';
return serverless.variables
.populateService()
.should.be.fulfilled.then()
.finally(() => {
prepopulateServiceStub.restore();
populateObjectStub.restore();
});
});
it('should clear caches and remaining *after* [pre]populating service', () => {
const prepopulateServiceStub = sinon
.stub(serverless.variables, 'prepopulateService')
.callsFake((val) => {
serverless.variables.deep.push('${foo:}');
const promise = BbPromise.resolve(val);
serverless.variables.tracker.add('foo:', promise, '${foo:}');
promise.state = 'resolved';
return BbPromise.resolve();
});
const populateObjectStub = sinon
.stub(serverless.variables, 'populateObjectImpl')
.callsFake((val) => {
serverless.variables.deep.push('${bar:}');
const promise = BbPromise.resolve(val);
serverless.variables.tracker.add('bar:', promise, '${bar:}');
promise.state = 'resolved';
return BbPromise.resolve();
});
return serverless.variables
.populateService()
.should.be.fulfilled.then(() => {
expect(serverless.variables.deep).to.eql([]);
expect(serverless.variables.tracker.getAll()).to.eql([]);
})
.finally(() => {
prepopulateServiceStub.restore();
populateObjectStub.restore();
});
});
});
describe('fallback', () => {
describe('should fallback if ${self} syntax fail to populate but fallback is provided', () => {
[
{ value: 'fallback123_*', description: 'regular ASCII characters' },
{ value: 'hello+world^*$@(!', description: 'different ASCII characters' },
{ value: '+++++', description: 'plus sign' },
{ value: 'システム管理者*', description: 'japanese characters' },
{ value: 'deす', description: 'mixed japanese ending' },
{ value: 'でsu', description: 'mixed japanese leading' },
{ value: 'suごi', description: 'mixed japanese middle' },
{ value: '①⑴⒈⒜Ⓐⓐⓟ ..▉가Ὠ', description: 'random unicode' },
].forEach((testCase) => {
it(testCase.description, () => {
serverless.variables.service.custom = {
settings: `\${self:nonExistent, "${testCase.value}"}`,
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: testCase.value,
});
});
});
});
});
it('should fallback if ${opt} syntax fail to populate but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${opt:nonExistent, "fallback"}',
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
});
});
it('should fallback if ${env} syntax fail to populate but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${env:nonExistent, "fallback"}',
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
});
});
describe('file syntax', () => {
it('should fallback if file does not exist but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${file(~/config.yml):xyz, "fallback"}',
};
const fileExistsStub = sinon.stub(serverless.utils, 'fileExistsSync').returns(false);
const realpathSync = sinon.stub(fse, 'realpathSync').returns(`${os.homedir()}/config.yml`);
return serverless.variables
.populateService()
.should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
})
.finally(() => {
fileExistsStub.restore();
realpathSync.restore();
});
});
it('should fallback if file exists but given key not found and fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${file(~/config.yml):xyz, "fallback"}',
};
const fileExistsStub = sinon.stub(serverless.utils, 'fileExistsSync').returns(true);
const realpathSync = sinon.stub(fse, 'realpathSync').returns(`${os.homedir()}/config.yml`);
const readFileSyncStub = sinon.stub(serverless.utils, 'readFileSync').returns({
test: 1,
test2: 'test2',
});
return serverless.variables
.populateService()
.should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
})
.finally(() => {
fileExistsStub.restore();
realpathSync.restore();
readFileSyncStub.restore();
});
});
});
describe('ensure unique instances', () => {
it('should not produce same instances for same variable patters used more than once', () => {
serverless.variables.service.custom = {
settings1: '${file(~/config.yml)}',
settings2: '${file(~/config.yml)}',
};
const fileExistsStub = sinon.stub(serverless.utils, 'fileExistsSync').returns(true);
const realpathSync = sinon.stub(fse, 'realpathSync').returns(`${os.homedir()}/config.yml`);
const readFileSyncStub = sinon.stub(serverless.utils, 'readFileSync').returns({
test: 1,
test2: 'test2',
});
return serverless.variables
.populateService()
.should.be.fulfilled.then((result) => {
expect(result.custom.settings1).to.not.equal(result.custom.settings2);
})
.finally(() => {
fileExistsStub.restore();
realpathSync.restore();
readFileSyncStub.restore();
});
});
});
describe('aws-specific syntax', () => {
let awsProvider;
let requestStub;
beforeEach(() => {
awsProvider = new AwsProvider(serverless, {});
requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(new ServerlessError('Not found.', 400)));
});
afterEach(() => {
requestStub.restore();
});
it('should fallback if ${s3} syntax fail to populate but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${s3:bucket/key, "fallback"}',
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
});
});
it('should fallback if ${cf} syntax fail to populate but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${cf:stack.value, "fallback"}',
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
});
});
it('should fallback if ${ssm} syntax fail to populate but fallback is provided', () => {
serverless.variables.service.custom = {
settings: '${ssm:/path/param, "fallback"}',
};
return serverless.variables.populateService().should.be.fulfilled.then((result) => {
expect(result.custom).to.be.deep.eql({
settings: 'fallback',
});
});
});
it('should throw an error if fallback fails too', () => {
serverless.variables.service.custom = {
settings: '${s3:bucket/key, ${ssm:/path/param}}',
};
return serverless.variables.populateService().should.be.rejected;
});
});
});
describe('#prepopulateService', () => {
// TL;DR: call populateService to test prepopulateService (note addition of 'pre')
//
// The prepopulateService resolver basically assumes invocation of of populateService (i.e. that
// variable syntax is loaded, and that the service object is cleaned up. Just use
// populateService to do that work.
let awsProvider;
let populateObjectImplStub;
let requestStub; // just in case... don't want to actually call...
beforeEach(() => {
awsProvider = new AwsProvider(serverless, {});
populateObjectImplStub = sinon.stub(serverless.variables, 'populateObjectImpl');
populateObjectImplStub.withArgs(serverless.variables.service).returns(BbPromise.resolve());
requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(new Error('unexpected')));
});
afterEach(() => {
populateObjectImplStub.restore();
requestStub.restore();
});
const prepopulatedProperties = [
{ name: 'region', getter: (provider) => provider.getRegion() },
{ name: 'stage', getter: (provider) => provider.getStage() },
{ name: 'profile', getter: (provider) => provider.getProfile() },
];
describe('basic population tests', () => {
prepopulatedProperties.forEach((property) => {
it(`should populate variables in ${property.name} values`, () => {
_.set(
awsProvider.serverless.service.provider,
property.name,
'${self:foobar, "default"}'
);
return serverless.variables
.populateService()
.should.be.fulfilled.then(() =>
expect(property.getter(awsProvider)).to.be.eql('default')
);
});
});
});
//
describe('dependent service rejections', () => {
const dependentConfigs = [
{ value: '${cf:stack.value}', name: 'CloudFormation' },
{ value: '${s3:bucket/key}', name: 'S3' },
{ value: '${ssm:/path/param}', name: 'SSM' },
];
prepopulatedProperties.forEach((property) => {
dependentConfigs.forEach((config) => {
it(`should reject ${config.name} variables in ${property.name} values`, () => {
_.set(awsProvider.serverless.service.provider, property.name, config.value);
return serverless.variables
.populateService()
.should.be.rejectedWith('Variable dependency failure');
});
it(`should reject recursively dependent ${config.name} service dependencies`, () => {
serverless.variables.service.custom = {
settings: config.value,
};
awsProvider.serverless.service.provider.region = '${self:custom.settings.region}';
return serverless.variables
.populateService()
.should.be.rejectedWith('Variable dependency failure');
});
});
});
});
describe('dependent service non-interference', () => {
const stateCombinations = [
{ region: 'foo', state: 'bar' },
{ region: 'foo', state: '${self:bar, "bar"}' },
{ region: '${self:foo, "foo"}', state: 'bar' },
{ region: '${self:foo, "foo"}', state: '${self:bar, "bar"}' },
];
stateCombinations.forEach((combination) => {
it('must leave the dependent services in their original state', () => {
const dependentMethods = [
{ name: 'getValueFromCf', original: serverless.variables.getValueFromCf },
{ name: 'getValueFromS3', original: serverless.variables.getValueFromS3 },
{ name: 'getValueFromSsm', original: serverless.variables.getValueFromSsm },
];
awsProvider.serverless.service.provider.region = combination.region;
awsProvider.serverless.service.provider.state = combination.state;
return serverless.variables.populateService().should.be.fulfilled.then(() => {
dependentMethods.forEach((method) => {
expect(serverless.variables[method.name]).to.equal(method.original);
});
});
});
});
});
});
describe('#getProperties', () => {
it('extracts all terminal properties of an object', () => {
const date = new Date();
const regex = /^.*$/g;
const func = () => {};
const obj = {
foo: {
bar: 'baz',
biz: 'buz',
},
b: [{ c: 'd' }, { e: 'f' }],
g: date,
h: regex,
i: func,
};
const expected = [
{ path: ['foo', 'bar'], value: 'baz' },
{ path: ['foo', 'biz'], value: 'buz' },
{ path: ['b', 0, 'c'], value: 'd' },
{ path: ['b', 1, 'e'], value: 'f' },
{ path: ['g'], value: date },
{ path: ['h'], value: regex },
{ path: ['i'], value: func },
];
const result = serverless.variables.getProperties(obj, true, obj);
expect(result).to.eql(expected);
});
it('ignores self references', () => {
const obj = {};
obj.self = obj;
const expected = [];
const result = serverless.variables.getProperties(obj, true, obj);
expect(result).to.eql(expected);
});
});
describe('#populateObject()', () => {
beforeEach(() => {
serverless.variables.loadVariableSyntax();
});
it('should populate object and return it', () => {
const object = {
stage: '${opt:stage}',
};
const expectedPopulatedObject = {
stage: 'prod',
};
sinon.stub(serverless.variables, 'populateValue').resolves('prod');
return serverless.variables
.populateObject(object)
.then((populatedObject) => {
expect(populatedObject).to.deep.equal(expectedPopulatedObject);
})
.finally(() => serverless.variables.populateValue.restore());
});
it('should persist keys with dot notation', () => {
const object = {
stage: '${opt:stage}',
};
object['some.nested.key'] = 'hello';
const expectedPopulatedObject = {
stage: 'prod',
};
expectedPopulatedObject['some.nested.key'] = 'hello';
const populateValueStub = sinon.stub(serverless.variables, 'populateValue').callsFake(
// eslint-disable-next-line no-template-curly-in-string
(val) => {
return val === '${opt:stage}' ? BbPromise.resolve('prod') : BbPromise.resolve(val);
}
);
return serverless.variables
.populateObject(object)
.should.become(expectedPopulatedObject)
.then()
.finally(() => populateValueStub.restore());
});
describe('significant variable usage corner cases', () => {
let service;
const makeDefault = () => ({
service: 'my-service',
provider: {
name: 'aws',
},
});
beforeEach(() => {
service = makeDefault();
// eslint-disable-next-line no-template-curly-in-string
service.provider.variableSyntax = '\\${([ ~:a-zA-Z0-9._@\'",\\-\\/\\(\\)*?]+?)}'; // default
serverless.service = serverless.variables.service = service;
serverless.variables.loadVariableSyntax();
delete service.provider.variableSyntax;
});
it('should properly replace self-references', () => {
service.custom = {
me: '${self:}',
};
const expected = makeDefault();
expected.custom = {
me: expected,
};
return expect(
serverless.variables.populateObject(service).then((result) => {
expect(jc.stringify(result)).to.eql(jc.stringify(expected));
})
).to.be.fulfilled;
});
it('should properly populate embedded variables', () => {
service.custom = {
val0: 'my value 0',
val1: '0', // eslint-disable-next-line no-template-curly-in-string
val2: '${self:custom.val${self:custom.val1}}',
};
const expected = {
val0: 'my value 0',
val1: '0',
val2: 'my value 0',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate an overwrite with a default value that is a string', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.NOT_A_VAL1, self:custom.NOT_A_VAL2, "string"}',
};
const expected = {
val0: 'my value',
val1: 'string',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate an overwrite with a default value that is the string *', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.NOT_A_VAL1, self:custom.NOT_A_VAL2, "*"}',
};
const expected = {
val0: 'my value',
val1: '*',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate an overwrite with a default value that is a string w/*', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.NOT_A_VAL1, self:custom.NOT_A_VAL2, "foo*"}',
};
const expected = {
val0: 'my value',
val1: 'foo*',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate overwrites where the first value is valid', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.val0, self:custom.NOT_A_VAL1, self:custom.NOT_A_VAL2}',
};
const expected = {
val0: 'my value',
val1: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it("should treat '*' literally when not wrapped in quotes", async () => {
service.custom = {
val0: "${self:custom.*, 'fallback'}",
};
const expected = { val0: 'fallback' };
const result = await serverless.variables.populateObject(service.custom);
expect(result).to.eql(expected);
});
it('should properly populate overwrites where the middle value is valid', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.NOT_A_VAL1, self:custom.val0, self:custom.NOT_A_VAL2}',
};
const expected = {
val0: 'my value',
val1: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate overwrites where the last value is valid', () => {
service.custom = {
val0: 'my value', // eslint-disable-next-line no-template-curly-in-string
val1: '${self:custom.NOT_A_VAL1, self:custom.NOT_A_VAL2, self:custom.val0}',
};
const expected = {
val0: 'my value',
val1: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate overwrites with nested variables in the first value', () => {
service.custom = {
val0: 'my value',
val1: 0, // eslint-disable-next-line no-template-curly-in-string
val2: '${self:custom.val${self:custom.val1}, self:custom.NO_1, self:custom.NO_2}',
};
const expected = {
val0: 'my value',
val1: 0,
val2: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate overwrites with nested variables in the middle value', () => {
service.custom = {
val0: 'my value',
val1: 0, // eslint-disable-next-line no-template-curly-in-string
val2: '${self:custom.NO_1, self:custom.val${self:custom.val1}, self:custom.NO_2}',
};
const expected = {
val0: 'my value',
val1: 0,
val2: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly populate overwrites with nested variables in the last value', () => {
service.custom = {
val0: 'my value',
val1: 0, // eslint-disable-next-line no-template-curly-in-string
val2: '${self:custom.NO_1, self:custom.NO_2, self:custom.val${self:custom.val1}}',
};
const expected = {
val0: 'my value',
val1: 0,
val2: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should properly replace duplicate variable declarations', () => {
service.custom = {
val0: 'my value',
val1: '${self:custom.val0}',
val2: '${self:custom.val0}',
};
const expected = {
val0: 'my value',
val1: 'my value',
val2: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
it('should recursively populate, regardless of order and duplication', () => {
service.custom = {
val1: '${self:custom.depVal}',
depVal: '${self:custom.val0}',
val0: 'my value',
val2: '${self:custom.depVal}',
};
const expected = {
val1: 'my value',
depVal: 'my value',
val0: 'my value',
val2: 'my value',
};
return expect(
serverless.variables.populateObject(service.custom).then((result) => {
expect(result).to.eql(expected);
})
).to.be.fulfilled;
});
// see https://github.com/serverless/serverless/pull/4713#issuecomment-366975172
it('should handle deep references into deep variables', () => {
service.provider.stage = 'dev';
service.custom = {
stage: '${env:stage, self:provider.stage}',
secrets: '${self:custom.${self:custom.stage}}',
dev: {
SECRET: 'secret',
},
environment: {
SECRET: '${self:custom.secrets.SECRET}',
},
};
const expected = {
stage: 'dev',
secrets: {
SECRET: 'secret',
},
dev: {
SECRET: 'secret',
},
environment: {
SECRET: 'secret',
},
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variables that reference overrides', () => {
service.custom = {
val1: '${self:not.a.value, "bar"}',
val2: '${self:custom.val1}',
};
const expected = {
val1: 'bar',
val2: 'bar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep references into deep variables', () => {
service.custom = {
val0: {
foo: 'bar',
},
val1: '${self:custom.val0}',
val2: '${self:custom.val1.foo}',
};
const expected = {
val0: {
foo: 'bar',
},
val1: {
foo: 'bar',
},
val2: 'bar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variables that reference overrides', () => {
service.custom = {
val1: '${self:not.a.value, "bar"}',
val2: 'foo${self:custom.val1}',
};
const expected = {
val1: 'bar',
val2: 'foobar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle referenced deep variables that reference overrides', () => {
service.custom = {
val1: '${self:not.a.value, "bar"}',
val2: '${self:custom.val1}',
val3: '${self:custom.val2}',
};
const expected = {
val1: 'bar',
val2: 'bar',
val3: 'bar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle partial referenced deep variables that reference overrides', () => {
service.custom = {
val1: '${self:not.a.value, "bar"}',
val2: '${self:custom.val1}',
val3: 'foo${self:custom.val2}',
};
const expected = {
val1: 'bar',
val2: 'bar',
val3: 'foobar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle referenced contained deep variables that reference overrides', () => {
service.custom = {
val1: '${self:not.a.value, "bar"}',
val2: 'foo${self:custom.val1}',
val3: '${self:custom.val2}',
};
const expected = {
val1: 'bar',
val2: 'foobar',
val3: 'foobar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle multiple referenced contained deep variables referencing overrides', () => {
service.custom = {
val0: '${self:not.a.value, "foo"}',
val1: '${self:not.a.value, "bar"}',
val2: '${self:custom.val0}:${self:custom.val1}',
val3: '${self:custom.val2}',
};
const expected = {
val0: 'foo',
val1: 'bar',
val2: 'foo:bar',
val3: 'foo:bar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle overrides that are populated by unresolvable deep variables', () => {
service.custom = {
val0: 'foo',
val1: '${self:custom.val0}',
val2: '${self:custom.val1.notAnAttribute, "fallback"}',
};
const expected = {
val0: 'foo',
val1: 'foo',
val2: 'fallback',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle embedded deep variable replacements in overrides', () => {
service.custom = {
foo: 'bar',
val0: 'foo',
val1: '${self:custom.val0, "fallback 1"}',
val2: '${self:custom.${self:custom.val0, self:custom.val1}, "fallback 2"}',
};
const expected = {
foo: 'bar',
val0: 'foo',
val1: 'foo',
val2: 'bar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should deal with overwites that reference embedded deep references', () => {
service.custom = {
val0: 'val',
val1: 'val0',
val2: '${self:custom.val1}',
val3: '${self:custom.${self:custom.val2}, "fallback"}',
val4: '${self:custom.val3, self:custom.val3}',
};
const expected = {
val0: 'val',
val1: 'val0',
val2: 'val0',
val3: 'val',
val4: 'val',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should preserve whitespace in double-quote literal fallback', () => {
service.custom = {
val0: '${self:custom.val, "rate(3 hours)"}',
};
const expected = {
val0: 'rate(3 hours)',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should preserve whitespace in single-quote literal fallback', () => {
service.custom = {
val0: "${self:custom.val, 'rate(1 hour)'}",
};
const expected = {
val0: 'rate(1 hour)',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should preserve question mark in single-quote literal fallback', () => {
service.custom = {
val0: "${self:custom.val, '?'}",
};
const expected = {
val0: '?',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should preserve question mark in single-quote literal fallback', () => {
service.custom = {
val0: "${self:custom.val, 'cron(0 0 * * ? *)'}",
};
const expected = {
val0: 'cron(0 0 * * ? *)',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should accept whitespace in variables', () => {
service.custom = {
val0: '${self: custom.val}',
val: 'foobar',
};
const expected = {
val: 'foobar',
val0: 'foobar',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variables regardless of custom variableSyntax', () => {
service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\\\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete service.provider.variableSyntax;
service.custom = {
my0thStage: 'DEV',
my1stStage: '${{self:custom.my0thStage}}',
my2ndStage: '${{self:custom.my1stStage}}',
};
const expected = {
my0thStage: 'DEV',
my1stStage: 'DEV',
my2ndStage: 'DEV',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variable continuations regardless of custom variableSyntax', () => {
service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\\\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete service.provider.variableSyntax;
service.custom = {
my0thStage: { we: 'DEV' },
my1stStage: '${{self:custom.my0thStage}}',
my2ndStage: '${{self:custom.my1stStage.we}}',
};
const expected = {
my0thStage: { we: 'DEV' },
my1stStage: { we: 'DEV' },
my2ndStage: 'DEV',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variables regardless of recursion into custom variableSyntax', () => {
service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\\\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete service.provider.variableSyntax;
service.custom = {
my0thIndex: '0th',
my1stIndex: '1st',
my0thStage: 'DEV',
my1stStage: '${{self:custom.my${{self:custom.my0thIndex}}Stage}}',
my2ndStage: '${{self:custom.my${{self:custom.my1stIndex}}Stage}}',
};
const expected = {
my0thIndex: '0th',
my1stIndex: '1st',
my0thStage: 'DEV',
my1stStage: 'DEV',
my2ndStage: 'DEV',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should handle deep variables in complex recursions of custom variableSyntax', () => {
service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\\\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete service.provider.variableSyntax;
service.custom = {
i0: '0',
s0: 'DEV',
s1: '${{self:custom.s0}}! ${{self:custom.s0}}',
s2: 'I am a ${{self:custom.s0}}! A ${{self:custom.s${{self:custom.i0}}}}!',
s3: '${{self:custom.s0}}!, I am a ${{self:custom.s1}}!, ${{self:custom.s2}}',
};
const expected = {
i0: '0',
s0: 'DEV',
s1: 'DEV! DEV',
s2: 'I am a DEV! A DEV!',
s3: 'DEV!, I am a DEV! DEV!, I am a DEV! A DEV!',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
describe('file reading cases', () => {
let tmpDirPath;
beforeEach(() => {
tmpDirPath = getTmpDirPath();
fse.mkdirsSync(tmpDirPath);
serverless.serviceDir = tmpDirPath;
});
afterEach(() => {
fse.removeSync(tmpDirPath);
});
const makeTempFile = (fileName, fileContent) => {
fse.outputFileSync(path.join(tmpDirPath, fileName), fileContent);
};
const asyncFileName = 'async.load.js';
const asyncContent = `'use strict';
let i = 0
const str = () => new Promise((resolve) => {
setTimeout(() => {
i += 1 // side effect
resolve(\`my-async-value-\${i}\`)
}, 200);
});
const obj = () => new Promise((resolve) => {
setTimeout(() => {
i += 1 // side effect
resolve({
val0: \`my-async-value-\${i}\`,
val1: '\${opt:stage}',
});
}, 200);
});
module.exports = {
str,
obj,
};
`;
it('should populate any given variable only once', () => {
makeTempFile(asyncFileName, asyncContent);
service.custom = {
val1: '${self:custom.val0}',
val2: '${self:custom.val1}',
val0: `\${file(${asyncFileName}):str}`,
};
const expected = {
val1: 'my-async-value-1',
val2: 'my-async-value-1',
val0: 'my-async-value-1',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should still work with a default file name in double or single quotes', () => {
makeTempFile(asyncFileName, asyncContent);
service.custom = {
val1: '${self:custom.val0}',
val2: '${self:custom.val1}',
val3: `\${file(\${self:custom.nonexistent, "${asyncFileName}"}):str}`,
val0: `\${file(\${self:custom.nonexistent, '${asyncFileName}'}):str}`,
};
const expected = {
val1: 'my-async-value-1',
val2: 'my-async-value-1',
val3: 'my-async-value-1',
val0: 'my-async-value-1',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should populate any given variable only once regardless of ordering or reference count', () => {
makeTempFile(asyncFileName, asyncContent);
service.custom = {
val9: '${self:custom.val7}',
val7: '${self:custom.val5}',
val5: '${self:custom.val3}',
val3: '${self:custom.val1}',
val1: '${self:custom.val0}',
val2: '${self:custom.val1}',
val4: '${self:custom.val3}',
val6: '${self:custom.val5}',
val8: '${self:custom.val7}',
val0: `\${file(${asyncFileName}):str}`,
};
const expected = {
val9: 'my-async-value-1',
val7: 'my-async-value-1',
val5: 'my-async-value-1',
val3: 'my-async-value-1',
val1: 'my-async-value-1',
val2: 'my-async-value-1',
val4: 'my-async-value-1',
val6: 'my-async-value-1',
val8: 'my-async-value-1',
val0: 'my-async-value-1',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it('should populate async objects with contained variables', () => {
makeTempFile(asyncFileName, asyncContent);
serverless.variables.options = {
stage: 'dev',
};
service.custom = {
obj: `\${file(${asyncFileName}):obj}`,
};
const expected = {
obj: {
val0: 'my-async-value-1',
val1: 'dev',
},
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
it("should populate variables from filesnames including '@', e.g scoped npm packages", () => {
const fileName = `./node_modules/@scoped-org/${asyncFileName}`;
makeTempFile(fileName, asyncContent);
service.custom = {
val0: `\${file(${fileName}):str}`,
};
const expected = {
val0: 'my-async-value-1',
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
const selfFileName = 'self.yml';
const selfContent = `foo: baz
bar: \${self:custom.self.foo}
`;
it('should populate a "cyclic" reference across an unresolved dependency (issue #4687)', () => {
makeTempFile(selfFileName, selfContent);
service.custom = {
self: `\${file(${selfFileName})}`,
};
const expected = {
self: {
foo: 'baz',
bar: 'baz',
},
};
return serverless.variables.populateObject(service.custom).should.become(expected);
});
const emptyFileName = 'empty.js';
const emptyContent = `'use strict';
module.exports = {
func: () => ({ value: 'a value' }),
}
`;
it('should reject population of an attribute not exported from a file', () => {
makeTempFile(emptyFileName, emptyContent);
service.custom = {
val: `\${file(${emptyFileName}):func.notAValue}`,
};
return serverless.variables
.populateObject(service.custom)
.should.be.rejectedWith(
ServerlessError,
'Invalid variable syntax when referencing file'
);
});
});
});
});
describe('#populateProperty()', () => {
beforeEach(() => {
serverless.variables.loadVariableSyntax();
});
it('should call overwrite if overwrite syntax provided', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${opt:stage, self:provider.stage}';
serverless.variables.options = { stage: 'dev' };
serverless.service.provider.stage = 'prod';
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is dev');
});
it('should allow a single-quoted string if overwrite syntax provided', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = "my stage is ${opt:stage, 'prod'}";
serverless.variables.options = {};
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is prod');
});
it('should allow a double-quoted string if overwrite syntax provided', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${opt:stage, "prod"}';
serverless.variables.options = {};
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is prod');
});
it('should not allow partially double-quoted string', async () => {
const property = '${opt:stage, prefix"prod"suffix}';
serverless.variables.options = {};
const handleUnresolvedSpy = sinon.spy(serverless.variables, 'handleUnresolved');
try {
expect(await serverless.variables.populateProperty(property)).to.equal(undefined);
expect(handleUnresolvedSpy.callCount).to.equal(1);
} finally {
handleUnresolvedSpy.restore();
}
});
it('should allow a boolean with value true if overwrite syntax provided', () => {
const property = '${opt:stage, true}';
serverless.variables.options = {};
return serverless.variables.populateProperty(property).should.eventually.eql(true);
});
it('should allow a boolean with value false if overwrite syntax provided', () => {
const property = '${opt:stage, false}';
serverless.variables.options = {};
return serverless.variables.populateProperty(property).should.eventually.eql(false);
});
it('should not match a boolean with value containing word true or false if overwrite syntax provided', () => {
const property = '${opt:stage, foofalsebar}';
serverless.variables.options = {};
const handleUnresolvedSpy = sinon.spy(serverless.variables, 'handleUnresolved');
return serverless.variables
.populateProperty(property)
.should.become(undefined)
.then(() => {
expect(handleUnresolvedSpy.callCount).to.equal(1);
})
.finally(() => {
handleUnresolvedSpy.restore();
});
});
it('should allow an integer if overwrite syntax provided', () => {
const property = '${opt:quantity, 123}';
serverless.variables.options = {};
return serverless.variables.populateProperty(property).should.eventually.eql(123);
});
it('should call getValueFromSource if no overwrite syntax provided', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${opt:stage}';
serverless.variables.options = { stage: 'prod' };
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is prod');
});
it('should warn if an SSM parameter does not exist', () => {
const options = {
stage: 'prod',
region: 'us-east-1',
};
serverless.variables.options = options;
const awsProvider = new AwsProvider(serverless, options);
const param = '/some/path/to/invalidparam';
const property = `\${ssm:${param}}`;
const error = Object.assign(new Error(`Parameter ${param} not found.`), {
providerError: { statusCode: 400 },
});
const requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(error));
const handleUnresolvedSpy = sinon.spy(serverless.variables, 'handleUnresolved');
return serverless.variables
.populateProperty(property)
.should.become(undefined)
.then(() => {
expect(requestStub.callCount).to.equal(1);
expect(handleUnresolvedSpy.callCount).to.equal(1);
})
.finally(() => {
requestStub.restore();
handleUnresolvedSpy.restore();
});
});
it('should throw an Error if the SSM request fails', () => {
const options = {
stage: 'prod',
region: 'us-east-1',
};
serverless.variables.options = options;
const awsProvider = new AwsProvider(serverless, options);
const param = '/some/path/to/invalidparam';
const property = `\${ssm:${param}}`;
const error = new ServerlessError('Some random failure.', 123);
const requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(error));
return serverless.variables
.populateProperty(property)
.should.be.rejectedWith(ServerlessError)
.then(() => expect(requestStub.callCount).to.equal(1))
.finally(() => requestStub.restore());
});
it('should run recursively if nested variables provided', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${env:${opt:name}}';
process.env.TEST_VAR = 'dev';
serverless.variables.options = { name: 'TEST_VAR' };
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is dev');
});
it('should run recursively through many nested variables', () => {
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${env:${opt:name}}';
process.env.TEST_VAR = 'dev';
serverless.variables.options = {
name: 'T${opt:lvl0}',
lvl0: 'E${opt:lvl1}',
lvl1: 'S${opt:lvl2}',
lvl2: 'T${opt:lvl3}',
lvl3: '_${opt:lvl4}',
lvl4: 'V${opt:lvl5}',
lvl5: 'A${opt:lvl6}',
lvl6: 'R',
};
return serverless.variables
.populateProperty(property)
.should.eventually.eql('my stage is dev');
});
});
describe('#populateVariable()', () => {
it('should populate string variables as sub string', () => {
const valueToPopulate = 'dev';
const matchedString = '${opt:stage}';
// eslint-disable-next-line no-template-curly-in-string
const property = 'my stage is ${opt:stage}';
serverless.variables
.populateVariable(property, matchedString, valueToPopulate)
.should.eql('my stage is dev');
});
it('should populate number variables as sub string', () => {
const valueToPopulate = 5;
const matchedString = '${opt:number}';
// eslint-disable-next-line no-template-curly-in-string
const property = 'your account number is ${opt:number}';
serverless.variables
.populateVariable(property, matchedString, valueToPopulate)
.should.eql('your account number is 5');
});
it('should populate non string variables', () => {
const valueToPopulate = 5;
const matchedString = '${opt:number}';
const property = '${opt:number}';
return serverless.variables
.populateVariable(property, matchedString, valueToPopulate)
.should.equal(5);
});
it('should throw error if populating non string or non number variable as sub string', () => {
const valueToPopulate = {};
const matchedString = '${opt:object}';
// eslint-disable-next-line no-template-curly-in-string
const property = 'your account number is ${opt:object}';
return expect(() =>
serverless.variables.populateVariable(property, matchedString, valueToPopulate)
).to.throw(ServerlessError);
});
});
describe('#splitByComma', () => {
it('should return a given empty string', () => {
const input = '';
const expected = [input];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should return a undelimited string', () => {
const input = 'foo:bar';
const expected = [input];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should split basic comma delimited strings', () => {
const input = 'my,values,to,split';
const expected = ['my', 'values', 'to', 'split'];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should remove leading and following white space', () => {
const input = ' \t\nfoobar\n\t ';
const expected = ['foobar'];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should remove white space surrounding commas', () => {
const input = 'a,b ,c , d, e , f\t,g\n,h,\ti,\nj,\t\n , \n\tk';
const expected = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should ignore quoted commas', () => {
const input = '",", \',\', ",\', \',\'", "\',\', \',\'", \',", ","\', \'",", ","\'';
const expected = ['","', "','", "\",', ','\"", "\"',', ','\"", '\',", ","\'', '\'",", ","\''];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
it('should deal with a combination of these cases', () => {
const input = " \t\n'a'\t\n , \n\t\"foo,bar\", opt:foo, \",\", ',', \"',', ','\", foo\n\t ";
const expected = ["'a'", '"foo,bar"', 'opt:foo', '","', "','", "\"',', ','\"", 'foo'];
expect(serverless.variables.splitByComma(input)).to.eql(expected);
});
});
describe('#overwrite()', () => {
beforeEach(() => {
serverless.service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete serverless.service.provider.variableSyntax;
});
it('should overwrite undefined and null values', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves(undefined);
getValueFromSourceStub.onCall(1).resolves(null);
getValueFromSourceStub.onCall(2).resolves('variableValue');
return serverless.variables
.overwrite(['opt:stage', 'env:stage', 'self:provider.stage'])
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal('variableValue');
expect(getValueFromSourceStub).to.have.been.calledThrice;
})
.finally(() => getValueFromSourceStub.restore());
});
it('should overwrite empty object values', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves({});
getValueFromSourceStub.onCall(1).resolves('variableValue');
return serverless.variables
.overwrite(['opt:stage', 'env:stage'])
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal('variableValue');
expect(getValueFromSourceStub).to.have.been.calledTwice;
})
.finally(() => getValueFromSourceStub.restore());
});
it('should overwrite values with an array even if it is empty', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves(undefined);
getValueFromSourceStub.onCall(1).resolves([]);
return serverless.variables
.overwrite(['opt:stage', 'env:stage'])
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.deep.equal([]);
expect(getValueFromSourceStub).to.have.been.calledTwice;
})
.finally(() => getValueFromSourceStub.restore());
});
it('should not overwrite 0 values', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves(0);
getValueFromSourceStub.onCall(1).resolves('variableValue');
return serverless.variables
.overwrite(['opt:stage', 'env:stage'])
.should.become(0)
.then()
.finally(() => getValueFromSourceStub.restore());
});
it('should not overwrite false values', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves(false);
getValueFromSourceStub.onCall(1).resolves('variableValue');
return serverless.variables
.overwrite(['opt:stage', 'env:stage'])
.should.become(false)
.then()
.finally(() => getValueFromSourceStub.restore());
});
it('should skip getting values once a value has been found', () => {
const getValueFromSourceStub = sinon.stub(serverless.variables, 'getValueFromSource');
getValueFromSourceStub.onCall(0).resolves(undefined);
getValueFromSourceStub.onCall(1).resolves('variableValue');
getValueFromSourceStub.onCall(2).resolves('variableValue2');
return serverless.variables
.overwrite(['opt:stage', 'env:stage', 'self:provider.stage'])
.should.be.fulfilled.then((valueToPopulate) =>
expect(valueToPopulate).to.equal('variableValue')
)
.finally(() => getValueFromSourceStub.restore());
});
it('should properly handle string values containing commas', () => {
const str = '"foo,bar"';
const getValueFromSourceStub = sinon
.stub(serverless.variables, 'getValueFromSource')
.resolves(undefined);
return serverless.variables
.overwrite(['opt:stage', str])
.should.be.fulfilled.then(() =>
expect(getValueFromSourceStub.getCall(1).args[0]).to.eql(str)
)
.finally(() => getValueFromSourceStub.restore());
});
});
describe('#getValueFromSource()', () => {
const variableValue = 'variableValue';
let getValueFromSlsStub;
let getValueFromEnvStub;
let getValueFromOptionsStub;
let getValueFromSelfStub;
let getValueFromFileStub;
let getValueFromCfStub;
let getValueFromS3Stub;
let getValueFromSsmStub;
beforeEach(() => {
getValueFromSlsStub = sinon
.stub(serverless.variables.variableResolvers[0], 'resolver')
.resolves('variableValue');
getValueFromEnvStub = sinon
.stub(serverless.variables.variableResolvers[1], 'resolver')
.resolves('variableValue');
getValueFromOptionsStub = sinon
.stub(serverless.variables.variableResolvers[2], 'resolver')
.resolves('variableValue');
getValueFromSelfStub = sinon
.stub(serverless.variables.variableResolvers[3], 'resolver')
.resolves('variableValue');
getValueFromFileStub = sinon
.stub(serverless.variables.variableResolvers[4], 'resolver')
.resolves('variableValue');
getValueFromCfStub = sinon
.stub(serverless.variables.variableResolvers[5], 'resolver')
.resolves('variableValue');
getValueFromS3Stub = sinon
.stub(serverless.variables.variableResolvers[6], 'resolver')
.resolves('variableValue');
getValueFromSsmStub = sinon
.stub(serverless.variables.variableResolvers[10], 'resolver')
.resolves('variableValue');
});
afterEach(() => {
serverless.variables.variableResolvers[0].resolver.restore();
serverless.variables.variableResolvers[1].resolver.restore();
serverless.variables.variableResolvers[2].resolver.restore();
serverless.variables.variableResolvers[3].resolver.restore();
serverless.variables.variableResolvers[4].resolver.restore();
serverless.variables.variableResolvers[5].resolver.restore();
serverless.variables.variableResolvers[6].resolver.restore();
serverless.variables.variableResolvers[10].resolver.restore();
});
it('should call getValueFromSls if referencing sls var', () =>
serverless.variables
.getValueFromSource('sls:instanceId')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromSlsStub).to.have.been.called;
expect(getValueFromSlsStub).to.have.been.calledWith('sls:instanceId');
}));
it('should call getValueFromEnv if referencing env var', () =>
serverless.variables
.getValueFromSource('env:TEST_VAR')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromEnvStub).to.have.been.called;
expect(getValueFromEnvStub).to.have.been.calledWith('env:TEST_VAR');
}));
it('should call getValueFromOptions if referencing an option', () =>
serverless.variables
.getValueFromSource('opt:stage')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromOptionsStub).to.have.been.called;
expect(getValueFromOptionsStub).to.have.been.calledWith('opt:stage');
}));
it('should call getValueFromSelf if referencing from self', () =>
serverless.variables
.getValueFromSource('self:provider')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromSelfStub).to.have.been.called;
expect(getValueFromSelfStub).to.have.been.calledWith('self:provider');
}));
it('should call getValueFromFile if referencing from another file', () =>
serverless.variables
.getValueFromSource('file(./config.yml)')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromFileStub).to.have.been.called;
expect(getValueFromFileStub).to.have.been.calledWith('file(./config.yml)');
}));
it('should call getValueFromCf if referencing CloudFormation Outputs', () =>
serverless.variables
.getValueFromSource('cf:test-stack.testOutput')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromCfStub).to.have.been.called;
expect(getValueFromCfStub).to.have.been.calledWith('cf:test-stack.testOutput');
}));
it('should call getValueFromS3 if referencing variable in S3', () =>
serverless.variables
.getValueFromSource('s3:test-bucket/path/to/key')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromS3Stub).to.have.been.called;
expect(getValueFromS3Stub).to.have.been.calledWith('s3:test-bucket/path/to/key');
}));
it('should call getValueFromSsm if referencing variable in SSM', () =>
serverless.variables
.getValueFromSource('ssm:/test/path/to/param')
.should.be.fulfilled.then((valueToPopulate) => {
expect(valueToPopulate).to.equal(variableValue);
expect(getValueFromSsmStub).to.have.been.called;
expect(getValueFromSsmStub).to.have.been.calledWith('ssm:/test/path/to/param');
}));
it('should reject invalid sources', () =>
serverless.variables
.getValueFromSource('weird:source')
.should.be.rejectedWith(ServerlessError));
describe('caching', () => {
const sources = [
{ functionIndex: 0, function: 'getValueFromSls', variableString: 'sls:instanceId' },
{ functionIndex: 1, function: 'getValueFromEnv', variableString: 'env:NODE_ENV' },
{ functionIndex: 2, function: 'getValueFromOptions', variableString: 'opt:stage' },
{ functionIndex: 3, function: 'getValueFromSelf', variableString: 'self:provider' },
{ functionIndex: 4, function: 'getValueFromFile', variableString: 'file(./config.yml)' },
{
functionIndex: 5,
function: 'getValueFromCf',
variableString: 'cf:test-stack.testOutput',
},
{
functionIndex: 6,
function: 'getValueFromS3',
variableString: 's3:test-bucket/path/to/ke',
},
{
functionIndex: 10,
function: 'getValueFromSsm',
variableString: 'ssm:/test/path/to/param',
},
];
sources.forEach((source) => {
it(`should only call ${source.function} once, returning the cached value otherwise`, () => {
const getValueFunctionStub =
serverless.variables.variableResolvers[source.functionIndex].resolver;
return BbPromise.all([
serverless.variables
.getValueFromSource(source.variableString)
.should.become(variableValue),
BbPromise.delay(100).then(() =>
serverless.variables
.getValueFromSource(source.variableString)
.should.become(variableValue)
),
]).then(() => {
expect(getValueFunctionStub).to.have.been.calledOnce;
expect(getValueFunctionStub).to.have.been.calledWith(source.variableString);
});
});
});
});
});
describe('#getValueFromSls()', () => {
it('should get variable from Serverless Framework provided variables', () => {
serverless.instanceId = 12345678;
return serverless.variables.getValueFromSls('sls:instanceId').then((valueToPopulate) => {
expect(valueToPopulate).to.equal(12345678);
});
});
});
describe('#getValueFromEnv()', () => {
it('should get variable from environment variables', () => {
process.env.TEST_VAR = 'someValue';
return serverless.variables.getValueFromEnv('env:TEST_VAR').should.become('someValue');
});
it('should allow top-level references to the environment variables hive', () => {
process.env.TEST_VAR = 'someValue';
return serverless.variables.getValueFromEnv('env:').then((valueToPopulate) => {
expect(valueToPopulate.TEST_VAR).to.be.equal('someValue');
});
});
});
describe('#getValueFromOptions()', () => {
it('should get variable from options', () => {
serverless.variables.options = { stage: 'prod' };
return serverless.variables.getValueFromOptions('opt:stage').should.become('prod');
});
it('should allow top-level references to the options hive', () => {
serverless.variables.options = { stage: 'prod' };
return serverless.variables
.getValueFromOptions('opt:')
.should.become(serverless.variables.options);
});
});
describe('#getValueFromSelf()', () => {
beforeEach(() => {
serverless.service.provider.variableSyntax = '\\${{([ ~:a-zA-Z0-9._@\'",\\-\\/\\(\\)*?]+?)}}';
serverless.variables.loadVariableSyntax();
delete serverless.service.provider.variableSyntax;
});
it('should get variable from self serverless.yml file', () => {
serverless.variables.service = {
service: 'testService',
provider: serverless.service.provider,
};
return serverless.variables.getValueFromSelf('self:service').should.become('testService');
});
it('should redirect ${self:service.name} to ${self:service}', () => {
serverless.variables.service = {
service: 'testService',
provider: serverless.service.provider,
};
return serverless.variables
.getValueFromSelf('self:service.name')
.should.become('testService');
});
it('should redirect ${self:provider} to ${self:provider.name}', () => {
serverless.variables.service = {
service: 'testService',
provider: { name: 'aws' },
};
return serverless.variables.getValueFromSelf('self:provider').should.become('aws');
});
it('should redirect ${self:service.awsKmsKeyArn} to ${self:serviceObject.awsKmsKeyArn}', () => {
const keyArn = 'arn:aws:kms:us-east-1:xxxxxxxxxxxx:key/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';
serverless.variables.service = {
service: 'testService',
serviceObject: {
name: 'testService',
awsKmsKeyArn: keyArn,
},
};
return serverless.variables
.getValueFromSelf('self:service.awsKmsKeyArn')
.should.become(keyArn);
});
it('should handle self-references to the root of the serverless.yml file', () => {
serverless.variables.service = {
service: 'testService',
provider: 'testProvider',
defaults: serverless.service.defaults,
};
return serverless.variables
.getValueFromSelf('self:')
.should.eventually.equal(serverless.variables.service);
});
});
describe('#getValueFromFile()', () => {
it('should work for absolute paths with ~ ', () => {
const expectedFileName = `${os.homedir()}/somedir/config.yml`;
const configYml = {
test: 1,
test2: 'test2',
testObj: {
sub: 2,
prob: 'prob',
},
};
const fileExistsStub = sinon.stub(serverless.utils, 'fileExistsSync').returns(true);
const realpathSync = sinon.stub(fse, 'realpathSync').returns(expectedFileName);
const readFileSyncStub = sinon.stub(serverless.utils, 'readFileSync').returns(configYml);
return serverless.variables
.getValueFromFile('file(~/somedir/config.yml)')
.should.be.fulfilled.then((valueToPopulate) => {
expect(realpathSync).to.not.have.been.called;
expect(fileExistsStub).to.have.been.calledWithMatch(expectedFileName);
expect(readFileSyncStub).to.have.been.calledWithMatch(expectedFileName);
expect(valueToPopulate).to.deep.equal(configYml);
})
.finally(() => {
realpathSync.restore();
readFileSyncStub.restore();
fileExistsStub.restore();
});
});
it('should populate an entire variable file', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const configYml = {
test: 1,
test2: 'test2',
testObj: {
sub: 2,
prob: 'prob',
},
};
SUtils.writeFileSync(path.join(tmpDirPath, 'config.yml'), yaml.dump(configYml));
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./config.yml)')
.should.eventually.eql(configYml);
});
it('should get undefined if non existing file and the second argument is true', () => {
const tmpDirPath = getTmpDirPath();
serverless.serviceDir = tmpDirPath;
const realpathSync = sinon.spy(fse, 'realpathSync');
const existsSync = sinon.spy(fse, 'existsSync');
return serverless.variables
.getValueFromFile('file(./non-existing.yml)')
.should.be.fulfilled.then((valueToPopulate) => {
expect(realpathSync).to.not.have.been.called;
expect(existsSync).to.have.been.calledOnce;
expect(valueToPopulate).to.be.undefined;
})
.finally(() => {
realpathSync.restore();
existsSync.restore();
});
});
it('should populate non json/yml files', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
SUtils.writeFileSync(path.join(tmpDirPath, 'someFile'), 'hello world');
serverless.serviceDir = tmpDirPath;
return serverless.variables.getValueFromFile('file(./someFile)').should.become('hello world');
});
it('should populate symlinks', function () {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const realFilePath = path.join(tmpDirPath, 'someFile');
const symlinkPath = path.join(tmpDirPath, 'refSomeFile');
SUtils.writeFileSync(realFilePath, 'hello world');
try {
fse.ensureSymlinkSync(realFilePath, symlinkPath);
} catch (error) {
skipOnDisabledSymlinksInWindows(error, this, afterCallback);
throw error;
}
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./refSomeFile)')
.should.become('hello world')
.then()
.finally(() => {
fse.removeSync(realFilePath);
fse.removeSync(symlinkPath);
});
});
it('should trim trailing whitespace and new line character', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
SUtils.writeFileSync(path.join(tmpDirPath, 'someFile'), 'hello world \n');
serverless.serviceDir = tmpDirPath;
return serverless.variables.getValueFromFile('file(./someFile)').should.become('hello world');
});
it('should populate from another file when variable is of any type', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const configYml = {
test0: 0,
test1: 'test1',
test2: {
sub: 2,
prob: 'prob',
},
};
SUtils.writeFileSync(path.join(tmpDirPath, 'config.yml'), yaml.dump(configYml));
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./config.yml):test2.sub')
.should.become(configYml.test2.sub);
});
it('should populate from a javascript file that exports a function', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports.hello=function(){return "hello world";};';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js):hello')
.should.become('hello world');
});
it('should populate from a javascript file that exports a string', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports.hello="hello world";';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js):hello')
.should.become('hello world');
});
it('should populate an entire variable exported by a javascript file function', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports=function(){return { hello: "hello world" };};';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js)')
.should.become({ hello: 'hello world' });
});
it('should populate an entire variable exported by a javascript file object', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports={ hello: "hello world" };';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js)')
.should.become({ hello: 'hello world' });
});
it('should populate an entire variable exported by a javascript file literal', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports="hello world";';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables.getValueFromFile('file(./hello.js)').should.become('hello world');
});
it('should populate deep object from a javascript file', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = `module.exports.hello=function(){
return {one:{two:{three: 'hello world'}}}
};`;
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
serverless.variables.loadVariableSyntax();
return serverless.variables
.getValueFromFile('file(./hello.js):hello.one.two.three')
.should.become('hello world');
});
it('should preserve the exported function context when executing', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = `
module.exports.one = {two: {three: 'hello world'}}
module.exports.hello=function(){ return this; };`;
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
serverless.variables.loadVariableSyntax();
return serverless.variables
.getValueFromFile('file(./hello.js):hello.one.two.three')
.should.become('hello world');
});
it('should file variable not using ":" syntax', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const configYml = {
test: 1,
test2: 'test2',
testObj: {
sub: 2,
prob: 'prob',
},
};
SUtils.writeFileSync(path.join(tmpDirPath, 'config.yml'), yaml.dump(configYml));
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./config.yml).testObj.sub')
.should.be.rejectedWith(ServerlessError);
});
it('should throw an error if resolved value is undefined', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports=undefined;';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js)')
.should.be.rejectedWith(ServerlessError);
});
it('should throw an error if resolved value is a symbol', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports=Symbol()';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js)')
.should.be.rejectedWith(ServerlessError);
});
it('should throw an error if resolved value is a function', () => {
const SUtils = new Utils();
const tmpDirPath = getTmpDirPath();
const jsData = 'module.exports=function(){ return function(){}; };';
SUtils.writeFileSync(path.join(tmpDirPath, 'hello.js'), jsData);
serverless.serviceDir = tmpDirPath;
return serverless.variables
.getValueFromFile('file(./hello.js)')
.should.be.rejectedWith(ServerlessError);
});
});
describe('#getValueFromCf()', () => {
it('should get variable from CloudFormation', () => {
const options = {
stage: 'prod',
region: 'us-west-2',
};
const awsProvider = new AwsProvider(serverless, options);
serverless.setProvider('aws', awsProvider);
serverless.variables.options = options;
const awsResponseMock = {
Stacks: [
{
Outputs: [
{
OutputKey: 'MockExport',
OutputValue: 'MockValue',
},
],
},
],
};
const cfStub = sinon
.stub(serverless.getProvider('aws'), 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromCf('cf:some-stack.MockExport')
.should.become('MockValue')
.then(() => {
expect(cfStub).to.have.been.calledOnce;
expect(cfStub).to.have.been.calledWithExactly(
'CloudFormation',
'describeStacks',
{ StackName: 'some-stack' },
{ useCache: true }
);
})
.finally(() => cfStub.restore());
});
it('should get variable from CloudFormation of different region', () => {
const options = {
stage: 'prod',
region: 'us-west-2',
};
const awsProvider = new AwsProvider(serverless, options);
serverless.setProvider('aws', awsProvider);
serverless.variables.options = options;
const awsResponseMock = {
Stacks: [
{
Outputs: [
{
OutputKey: 'MockExport',
OutputValue: 'MockValue',
},
],
},
],
};
const cfStub = sinon
.stub(serverless.getProvider('aws'), 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromCf('cf.us-east-1:some-stack.MockExport')
.should.become('MockValue')
.then(() => {
expect(cfStub).to.have.been.calledOnce;
expect(cfStub).to.have.been.calledWithExactly(
'CloudFormation',
'describeStacks',
{ StackName: 'some-stack' },
{ region: 'us-east-1', useCache: true }
);
})
.finally(() => cfStub.restore());
});
it('should reject CloudFormation variables that do not exist', () => {
const options = {
stage: 'prod',
region: 'us-west-2',
};
const awsProvider = new AwsProvider(serverless, options);
serverless.setProvider('aws', awsProvider);
serverless.variables.options = options;
const awsResponseMock = {
Stacks: [
{
Outputs: [
{
OutputKey: 'MockExport',
OutputValue: 'MockValue',
},
],
},
],
};
const cfStub = sinon
.stub(serverless.getProvider('aws'), 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromCf('cf:some-stack.DoestNotExist')
.should.be.rejectedWith(
ServerlessError,
/to request a non exported variable from CloudFormation/
)
.then(() => {
expect(cfStub).to.have.been.calledOnce;
expect(cfStub).to.have.been.calledWithExactly(
'CloudFormation',
'describeStacks',
{ StackName: 'some-stack' },
{ useCache: true }
);
})
.finally(() => cfStub.restore());
});
});
describe('#getValueFromS3()', () => {
let awsProvider;
beforeEach(() => {
const options = {
stage: 'prod',
region: 'us-west-2',
};
awsProvider = new AwsProvider(serverless, options);
serverless.setProvider('aws', awsProvider);
serverless.variables.options = options;
});
it('should get variable from S3', () => {
const awsResponseMock = {
Body: 'MockValue',
};
const s3Stub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromS3('s3:some.bucket/path/to/key')
.should.become('MockValue')
.then(() => {
expect(s3Stub).to.have.been.calledOnce;
expect(s3Stub).to.have.been.calledWithExactly(
'S3',
'getObject',
{
Bucket: 'some.bucket',
Key: 'path/to/key',
},
{ useCache: true }
);
})
.finally(() => s3Stub.restore());
});
it('should throw error if error getting value from S3', () => {
const error = new Error('The specified bucket is not valid');
const requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(error));
return expect(serverless.variables.getValueFromS3('s3:some.bucket/path/to/key'))
.to.be.rejectedWith(
ServerlessError,
'Error getting value for s3:some.bucket/path/to/key. The specified bucket is not valid'
)
.then()
.finally(() => requestStub.restore());
});
});
describe('#getValueFromSsm()', () => {
const param = 'Param-01_valid.chars';
const value = 'MockValue';
const awsResponseMock = {
Parameter: {
Value: value,
},
};
let awsProvider;
beforeEach(() => {
const options = {
stage: 'prod',
region: 'us-west-2',
};
awsProvider = new AwsProvider(serverless, options);
serverless.setProvider('aws', awsProvider);
serverless.variables.options = options;
});
it('should get variable from Ssm using regular-style param', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get variable from Ssm of different region', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm.us-east-1:${param}`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ region: 'us-east-1', useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get variable from Ssm using path-style param', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get encrypted variable from Ssm using extended syntax', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}~true`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: true,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get unencrypted variable from Ssm using extended syntax', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}~false`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should ignore bad values for extended syntax', () => {
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}~badvalue`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get split StringList variable from Ssm using extended syntax', () => {
const stringListValue = 'MockValue1,MockValue2';
const parsedValue = ['MockValue1', 'MockValue2'];
const stringListResponseMock = {
Parameter: {
Value: stringListValue,
Type: 'StringList',
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(stringListResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}~split`)
.should.become(parsedValue)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should get unsplit StringList variable from Ssm by default', () => {
const stringListValue = 'MockValue1,MockValue2';
const stringListResponseMock = {
Parameter: {
Value: stringListValue,
Type: 'StringList',
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(stringListResponseMock));
return serverless.variables
.getValueFromSsm(`ssm:${param}`)
.should.become(stringListValue)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
})
.finally(() => ssmStub.restore());
});
it('should warn when attempting to split a non-StringList Ssm variable', () => {
const logWarningSpy = sinon.spy(slsError, 'logWarning');
const consoleLogStub = sinon.stub(console, 'log').returns();
const ProxyQuiredVariables = proxyquire('../../../../lib/classes/Variables.js', {
'./Error': logWarningSpy,
});
const varProxy = new ProxyQuiredVariables(serverless);
const stringListResponseMock = {
Parameter: {
Value: value,
Type: 'String',
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(stringListResponseMock));
return varProxy
.getValueFromSsm(`ssm:${param}~split`)
.should.become(value)
.then(() => {
expect(ssmStub).to.have.been.calledOnce;
expect(ssmStub).to.have.been.calledWithExactly(
'SSM',
'getParameter',
{
Name: param,
WithDecryption: false,
},
{ useCache: true }
);
expect(logWarningSpy).to.have.been.calledWithExactly(
`Cannot split SSM parameter '${param}' of type 'String'. Must be 'StringList'.`
);
})
.finally(() => {
ssmStub.restore();
logWarningSpy.restore();
consoleLogStub.restore();
});
});
describe('Referencing to AWS SecretsManager', () => {
it('should NOT parse value as json if not referencing to AWS SecretsManager', () => {
const secretParam = '/path/to/foo-bar';
const jsonLikeText = '{"str":"abc","num":123}';
const awsResponse = {
Parameter: {
Value: jsonLikeText,
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponse));
return serverless.variables
.getValueFromSsm(`ssm:${secretParam}~true`)
.should.become(jsonLikeText)
.then()
.finally(() => ssmStub.restore());
});
it('should parse value as json if returned value is json-like', () => {
const secretParam = '/aws/reference/secretsmanager/foo-bar';
const jsonLikeText = '{"str":"abc","num":123}';
const json = {
str: 'abc',
num: 123,
};
const awsResponse = {
Parameter: {
Value: jsonLikeText,
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponse));
return serverless.variables
.getValueFromSsm(`ssm:${secretParam}~true`)
.should.become(json)
.then()
.finally(() => ssmStub.restore());
});
it('should get value as text if returned value is NOT json-like', () => {
const secretParam = '/aws/reference/secretsmanager/foo-bar';
const plainText = 'I am plain text';
const awsResponse = {
Parameter: {
Value: plainText,
},
};
const ssmStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.resolve(awsResponse));
return serverless.variables
.getValueFromSsm(`ssm:${secretParam}~true`)
.should.become(plainText)
.then()
.finally(() => ssmStub.restore());
});
});
it('should return undefined if SSM parameter does not exist', () => {
const error = Object.assign(new Error(`Parameter ${param} not found.`), {
providerError: { statusCode: 400 },
});
const requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(error));
return serverless.variables
.getValueFromSsm(`ssm:${param}`)
.should.become(undefined)
.then()
.finally(() => requestStub.restore());
});
it('should reject if SSM request returns unexpected error', () => {
const error = new Error(
'User: <arn> is not authorized to perform: ssm:GetParameter on resource: <arn>'
);
const requestStub = sinon
.stub(awsProvider, 'request')
.callsFake(() => BbPromise.reject(error));
return serverless.variables
.getValueFromSsm(`ssm:${param}`)
.should.be.rejected.then()
.finally(() => requestStub.restore());
});
});
describe('#getValueStrToBool()', () => {
const errMessage = 'Unexpected strToBool input; expected either "true", "false", "0", or "1".';
beforeEach(() => {
serverless.variables.service = {
service: 'testService',
provider: serverless.service.provider,
};
serverless.variables.loadVariableSyntax();
});
it('regex for "true" input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(true)}')).to.equal(true);
});
it('regex for "false" input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(false)}')).to.equal(true);
});
it('regex for "0" input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(0)}')).to.equal(true);
});
it('regex for "1" input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(1)}')).to.equal(true);
});
it('regex for "null" input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(null)}')).to.equal(true);
});
it('regex for truthy input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool(anything)}')).to.equal(true);
});
it('regex for empty input', () => {
expect(serverless.variables.strToBoolRefSyntax.test('${strToBool()}')).to.equal(false);
});
it('true (string) should return true (boolean)', () => {
return serverless.variables.getValueStrToBool('strToBool(true)').should.become(true);
});
it('false (string) should return false (boolean)', () => {
return serverless.variables.getValueStrToBool('strToBool(false)').should.become(false);
});
it('1 (string) should return true (boolean)', () => {
return serverless.variables.getValueStrToBool('strToBool(1)').should.become(true);
});
it('0 (string) should return false (boolean)', () => {
return serverless.variables.getValueStrToBool('strToBool(0)').should.become(false);
});
it('truthy string should throw an error', () => {
return serverless.variables
.getValueStrToBool('strToBool(anything)')
.catch((err) => err.message)
.should.become(errMessage);
});
it('null (string) should throw an error', () => {
return serverless.variables
.getValueStrToBool('strToBool(null)')
.catch((err) => err.message)
.should.become(errMessage);
});
it('strToBool(true) as an input to strToBool', () => {
const input = serverless.variables.getValueStrToBool('strToBool(true)');
return serverless.variables.getValueStrToBool(input).should.become(true);
});
it('strToBool(false) as an input to strToBool', () => {
const input = serverless.variables.getValueStrToBool('strToBool(false)');
return serverless.variables.getValueStrToBool(input).should.become(true);
});
});
describe('#getDeeperValue()', () => {
it('should get deep values', () => {
const valueToPopulateMock = {
service: 'testService',
custom: {
subProperty: {
deep: 'deepValue',
},
},
};
serverless.variables.loadVariableSyntax();
return serverless.variables
.getDeeperValue(['custom', 'subProperty', 'deep'], valueToPopulateMock)
.should.become('deepValue');
});
it('should not throw error if referencing invalid properties', () => {
const valueToPopulateMock = {
service: 'testService',
custom: {
subProperty: 'hello',
},
};
serverless.variables.loadVariableSyntax();
return serverless.variables
.getDeeperValue(['custom', 'subProperty', 'deep', 'deeper'], valueToPopulateMock)
.should.eventually.deep.equal({});
});
it('should return a simple deep variable when final deep value is variable', () => {
serverless.variables.service = {
service: 'testService',
custom: {
subProperty: {
// eslint-disable-next-line no-template-curly-in-string
deep: '${self:custom.anotherVar.veryDeep}',
},
},
provider: serverless.service.provider,
};
serverless.variables.loadVariableSyntax();
return serverless.variables
.getDeeperValue(['custom', 'subProperty', 'deep'], serverless.variables.service)
.should.become('${deep:0}');
});
it('should return a deep continuation when middle deep value is variable', () => {
serverless.variables.service = {
service: 'testService',
custom: {
anotherVar: '${self:custom.var}',
},
provider: serverless.service.provider,
};
serverless.variables.loadVariableSyntax();
return serverless.variables
.getDeeperValue(['custom', 'anotherVar', 'veryDeep'], serverless.variables.service)
.should.become('${deep:0.veryDeep}');
});
});
describe('#handleUnresolved()', () => {
let logWarningSpy;
let consoleLogStub;
let varProxy;
beforeEach(() => {
logWarningSpy = sinon.spy(slsError, 'logWarning');
consoleLogStub = sinon.stub(console, 'log').returns();
const ProxyQuiredVariables = proxyquire('../../../../lib/classes/Variables.js', {
'./Error': logWarningSpy,
});
varProxy = new ProxyQuiredVariables(serverless);
});
afterEach(() => {
logWarningSpy.restore();
consoleLogStub.restore();
});
it('should do nothing if variable has valid value.', () => {
varProxy.handleUnresolved('self:service', 'a-valid-value');
expect(logWarningSpy).to.not.have.been.calledOnce;
});
describe('when variable string does not match any of syntax', () => {
// These situation happen when deep variable population fails
it('should do nothing if variable has null value.', () => {
varProxy.handleUnresolved('', null);
expect(logWarningSpy).to.not.have.been.calledOnce;
});
it('should do nothing if variable has undefined value.', () => {
varProxy.handleUnresolved('', undefined);
expect(logWarningSpy).to.not.have.been.calledOnce;
});
it('should do nothing if variable has empty object value.', () => {
varProxy.handleUnresolved('', {});
expect(logWarningSpy).to.not.have.been.calledOnce;
});
});
it('should log if variable has null value.', () => {
varProxy.handleUnresolved('self:service', null);
expect(logWarningSpy).to.have.been.calledOnce;
});
it('should log if variable has undefined value.', () => {
varProxy.handleUnresolved('self:service', undefined);
expect(logWarningSpy).to.have.been.calledOnce;
});
it('should log if variable has empty object value.', () => {
varProxy.handleUnresolved('self:service', {});
expect(logWarningSpy).to.have.been.calledOnce;
});
it('should not log if variable has empty array value.', () => {
varProxy.handleUnresolved('self:service', []);
expect(logWarningSpy).to.not.have.been.called;
});
it('should detect the "environment variable" variable type', () => {
varProxy.handleUnresolved('env:service', null);
expect(logWarningSpy).to.have.been.calledOnce;
expect(logWarningSpy.args[0][0]).to.contain('environment variable');
});
it('should detect the "option" variable type', () => {
varProxy.handleUnresolved('opt:service', null);
expect(logWarningSpy).to.have.been.calledOnce;
expect(logWarningSpy.args[0][0]).to.contain('option');
});
it('should detect the "service attribute" variable type', () => {
varProxy.handleUnresolved('self:service', null);
expect(logWarningSpy).to.have.been.calledOnce;
expect(logWarningSpy.args[0][0]).to.contain('service attribute');
});
it('should detect the "file" variable type', () => {
varProxy.handleUnresolved('file(service)', null);
expect(logWarningSpy).to.have.been.calledOnce;
expect(logWarningSpy.args[0][0]).to.contain('file');
});
});
});
describe('test/unit/lib/classes/Variables.test.js', () => {
let processedConfig = null;
before(async () => {
const result = await runServerless({
fixture: 'variables-legacy',
command: 'print',
shouldUseLegacyVariablesResolver: true,
});
processedConfig = result.serverless.service;
});
it('should support ${file(...)} syntax', () => {
expect(processedConfig.custom.importedFile).to.deep.equal({
foo: 'bar',
});
});
it('should support ${file(...):key} syntax', () => {
expect(processedConfig.custom.importedFileWithKey).to.equal('bar');
});
it('should support ${file(...)} syntax for Terraform state', () => {
expect(processedConfig.custom.importedTerraformState).to.deep.equal({
version: 4,
terraform_version: '0.14.4',
serial: 11,
lineage: '12ab3c45-abc1-0a1b-1a23-a12b34567c89',
outputs: {
listenerarn: {
value:
'arn:aws:elasticloadbalancing:us-west-2:123456789876:listener/app/myapp/1a2b3c4f1a23456b/a1b23c45de6789fa',
type: 'string',
},
},
resources: [],
});
});
it('should support ${file(...):key} syntax for Terraform state', () => {
expect(processedConfig.custom.importedTerraformStateWithKey).to.equal('string');
});
it('should ignore native CloudFormation variables', () => {
expect(processedConfig.custom.awsVariable).to.equal('${AWS::Region}');
});
it('should ignore CloudFormation references', () => {
expect(processedConfig.custom.cloudFormationReference).to.equal('${AnotherResource}');
});
it('should support ${self:key} syntax', () => {
expect(processedConfig.custom.selfReference).to.equal('bar');
});
it('should support ${self:} syntax', () => {
expect(processedConfig.custom.serviceReference).to.equal(processedConfig);
});
it('should support nested resolution', () => {
expect(processedConfig.custom.nestedReference).to.equal('resolvedNested');
});
it('should handle resolving variables when `prototype` is part of the path', async () => {
expect(processedConfig.custom.prototype.nestedInPrototype).to.equal('bar-in-prototype');
});
describe('variable resolving', () => {
describe('when unresolvedVariablesNotificationMode is set to "error"', () => {
it('should error for missing "environment variable" type variables', async () => {
await expect(
runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
unresolvedVariablesNotificationMode: 'error',
custom: { myVariable: '${env:missingEnvVar}' },
},
shouldUseLegacyVariablesResolver: true,
})
).to.eventually.be.rejected.and.have.property('code', 'UNRESOLVED_CONFIG_VARIABLE');
});
it('should error for missing "option" type variables', async () => {
await expect(
runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
unresolvedVariablesNotificationMode: 'error',
custom: { myVariable: '${opt:missingOpt}' },
},
shouldUseLegacyVariablesResolver: true,
})
).to.eventually.be.rejected.and.have.property('code', 'UNRESOLVED_CONFIG_VARIABLE');
});
it('should error for missing "service attribute" type variables', async () => {
await expect(
runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
unresolvedVariablesNotificationMode: 'error',
custom: { myVariable: '${self:missingAttribute}' },
},
shouldUseLegacyVariablesResolver: true,
})
).to.eventually.be.rejected.and.have.property('code', 'UNRESOLVED_CONFIG_VARIABLE');
});
it('should error for missing "file" type variables', async () => {
await expect(
runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
unresolvedVariablesNotificationMode: 'error',
custom: { myVariable: '${file(./missingFile)}' },
},
shouldUseLegacyVariablesResolver: true,
})
).to.eventually.be.rejected.and.have.property('code', 'UNRESOLVED_CONFIG_VARIABLE');
});
});
describe('when unresolvedVariablesNotificationMode is set to "warn"', () => {
it('should warn', async () => {
const { stdoutData } = await runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
unresolvedVariablesNotificationMode: 'warn',
custom: {
myVariable1: '${env:missingEnvVar}',
myVariable2: '${opt:missingOpt}',
myVariable3: '${self:missingAttribute}',
myVariable4: '${file(./missingFile)}',
},
},
shouldUseLegacyVariablesResolver: true,
});
expect(stdoutData).to.include('Serverless Warning');
expect(stdoutData).to.include('A valid environment variable to satisfy the declaration');
expect(stdoutData).to.include('A valid option to satisfy the declaration');
expect(stdoutData).to.include('A valid service attribute to satisfy the declaration');
expect(stdoutData).to.include('A valid file to satisfy the declaration');
});
});
describe('when unresolvedVariablesNotificationMode is not set', () => {
it('should warn', async () => {
const { stdoutData } = await runServerless({
fixture: 'variables-legacy',
command: 'print',
configExt: {
custom: {
myVariable1: '${env:missingEnvVar}',
myVariable2: '${opt:missingOpt}',
myVariable3: '${self:missingAttribute}',
myVariable4: '${file(./missingFile)}',
},
},
shouldUseLegacyVariablesResolver: true,
});
expect(stdoutData).to.include('Serverless Warning');
expect(stdoutData).to.include('A valid environment variable to satisfy the declaration');
expect(stdoutData).to.include('A valid option to satisfy the declaration');
expect(stdoutData).to.include('A valid service attribute to satisfy the declaration');
expect(stdoutData).to.include('A valid file to satisfy the declaration');
});
});
});
});