lambda-envelope
Version:
Envelope for AWS Lambda responses that supports raw invocation response parsing
537 lines (428 loc) • 18.6 kB
JavaScript
;
const chai = require('chai');
const chaiAsPromised = require('chai-as-promised').default;
const chaiUUID = require('chai-uuid');
const nock = require('nock');
const { S3Client } = require('@aws-sdk/client-s3');
const { S3RequestPresigner } = require('@aws-sdk/s3-request-presigner');
const sinon = require('sinon');
const zlib = require('zlib');
const { Response, ResponseBuilder } = require('..');
chai.use(chaiAsPromised);
chai.use(chaiUUID);
const { expect } = chai;
const RESPONSE = new Response({
statusCode: 500,
encoding: 'test',
body: {
data: 'foo'
}
});
describe('ResponseBuilder', function() {
before(function() {
this.sandbox = sinon.createSandbox();
});
beforeEach(function() {
this.url = 'https://test-bucket.test-url';
this.s3client = new S3Client({
endpoint: 'https://test-url',
credentials: {
accessKeyId: '',
secretAccessKey: ''
},
region: 'us-east-1'
});
this.sandbox.stub(this.s3client, 'send');
this.sandbox.stub(S3RequestPresigner.prototype, 'presign').resolvesArg(0);
this.builder = new ResponseBuilder({
bucket: 'test-bucket',
s3client: this.s3client
});
});
afterEach(function() {
nock.cleanAll();
this.sandbox.restore();
});
describe('#constructor', function() {
it('should handle case when options object is not passed', function() {
expect(() => new ResponseBuilder())
.to.throw('bucket is required');
});
it('should handle case when bucket is not passed', function() {
expect(() => new ResponseBuilder({}))
.to.throw('bucket is required');
});
it('should use passed properties to initialize state', function() {
const bucket = 'test-bucket';
const s3client = { thisIsClient: true };
const threshold = 12345;
const urlTTL = 54321;
const builder = new ResponseBuilder({
bucket,
s3client,
threshold,
urlTTL
});
expect(builder).to.have.property('bucket', bucket);
expect(builder).to.have.property('threshold', threshold);
expect(builder).to.have.property('urlTTL', urlTTL);
expect(builder)
.to.have.property('s3client')
.that.is.deep.equal(s3client);
});
it('should use proper defaults to initialize state', function() {
const bucket = 'test-bucket';
const builder = new ResponseBuilder({ bucket });
expect(builder).to.have.property('bucket', bucket);
expect(builder).to.have.property('threshold', 6291456);
expect(builder).to.have.property('urlTTL', 30);
expect(builder)
.to.have.property('s3client')
.that.is.instanceof(S3Client);
});
});
describe('#_getByteSize', function() {
it('should calculate byte size correctly', function() {
const size = ResponseBuilder._getByteSize(RESPONSE);
expect(size).to.equal(58);
});
});
describe('#_parseCompressedResponse', function() {
it('should be able to handle compressed response', async function() {
const compressed = await this.builder._buildCompressedResponse(RESPONSE);
const parsed = await ResponseBuilder._parseCompressedResponse(compressed);
expect(parsed).to.deep.equal(RESPONSE);
});
it('should throw when response is not base64 encoded', function() {
const response = {
body: {}
};
return expect(ResponseBuilder._parseCompressedResponse(response))
.to.eventually.be.rejectedWith('failed to parse compressed response')
.and.to.have.nested.property('jse_cause.message', 'The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received an instance of Object');
});
it('should throw when response is not gzip encoded', function() {
const response = {
body: Buffer.from('not gzip').toString('base64')
};
return expect(ResponseBuilder._parseCompressedResponse(response))
.to.eventually.be.rejectedWith('failed to parse compressed response')
.and.to.have.nested.property('jse_cause.message', 'incorrect header check');
});
it('should throw when decompressed response is not a valid JSON', async function() {
const response = 'not a JSON';
const compressed = await this.builder._buildCompressedResponse(response);
return expect(ResponseBuilder._parseCompressedResponse(compressed))
.to.eventually.be.rejectedWith('failed to parse compressed response')
.and.to.have.nested.property('jse_cause.message', 'Unexpected token \'o\', "not a JSON" is not valid JSON');
});
});
describe('#_parseResponse', function() {
it('should be able to handle compressed response', async function() {
const compressed = await this.builder._buildCompressedResponse(RESPONSE);
const parsed = await ResponseBuilder._parseResponse(compressed);
expect(parsed).to.deep.equal(RESPONSE);
});
it('should be able to handle S3 response', async function() {
const request = nock(this.url).get(/^\//).reply(200, RESPONSE.toString());
const s3 = await this.builder._buildS3Response(RESPONSE);
const parsed = await ResponseBuilder._parseResponse(s3);
request.done();
expect(parsed).to.deep.equal(RESPONSE);
});
it('should be able to handle raw response', async function() {
const parsed = await ResponseBuilder._parseResponse(RESPONSE);
expect(parsed).to.deep.equal(RESPONSE);
});
});
describe('#_parseS3Response', function() {
it('should be able to handle S3 response', async function() {
const request = nock(this.url).get(/^\//).reply(200, RESPONSE.toString());
const s3 = await this.builder._buildS3Response(RESPONSE);
const parsed = await ResponseBuilder._parseS3Response(s3);
request.done();
expect(parsed).to.deep.equal(RESPONSE);
});
it('should throw when S3 request fails', async function() {
const request = nock(this.url).get(/^\//).replyWithError('resource not found');
const s3 = await this.builder._buildS3Response(RESPONSE);
await expect(ResponseBuilder._parseS3Response(s3))
.to.eventually.be.rejectedWith('failed to parse s3 response')
.and.to.have.nested.property('jse_cause.message', 'resource not found');
request.done();
});
});
describe('#fromAWSResponse', function() {
it('should throw when payload can`t be parsed', function() {
const awsResponse = {
Payload: ''
};
return expect(ResponseBuilder.fromAWSResponse(awsResponse))
.to.be.rejectedWith('failed to parse response payload');
});
it('should use payload as body if payload is not an object', async function() {
const payload = 'data';
const awsResponse = {
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 200);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body', payload);
});
it('should use payload as body if unhandled error is returned', async function() {
const payload = {
data: 'test data'
};
const awsResponse = {
FunctionError: 'Unhandled',
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.deep.equals(payload);
});
it('should use payload as body if handled error is returned, but payload has more than one field', async function() {
const payload = {
errorMessage: 'message',
data: 'test data'
};
const awsResponse = {
FunctionError: 'Handled',
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.deep.equals(payload);
});
it('should use payload as body if handled error is returned, but there is no errorMessage field in payload', async function() {
const payload = {
data: 'test data'
};
const awsResponse = {
FunctionError: 'Handled',
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.deep.equals(payload);
});
it('should use errorMessage as body if handled error is returned, but errorMessage can`t be parsed', async function() {
const payload = {
errorMessage: 'abc'
};
const awsResponse = {
FunctionError: 'Handled',
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.equals(payload.errorMessage);
});
it('should use parsed errorMessage as body if errorMessage can be parsed, but has no body field in it', async function() {
const messageData = {
data: 'some data'
};
const awsResponse = {
FunctionError: 'Handled',
Payload: JSON.stringify({
errorMessage: JSON.stringify(messageData)
})
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.deep.equals(messageData);
});
it('should use parsed body and encoding fields from errorMessage if errorMessage can be parsed', async function() {
const messageData = {
body: { data: 'some data' },
encoding: 'gzip'
};
const awsResponse = {
FunctionError: 'Handled',
Payload: JSON.stringify({
errorMessage: JSON.stringify(messageData)
})
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 500);
expect(response).to.have.property('encoding', messageData.encoding);
expect(response).to.have.property('body').that.deep.equals(messageData.body);
});
it('should use payload as body if there is no body field in it', async function() {
const payload = {
data: 'test data'
};
const awsResponse = {
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', 200);
expect(response).to.have.property('encoding', 'identity');
expect(response).to.have.property('body').that.deep.equals(payload);
});
it('should use statusCode, encoding and body from payload when present', async function() {
const payload = {
statusCode: 201,
encoding: 'identity',
body: {
data: 'test data'
}
};
const awsResponse = {
Payload: JSON.stringify(payload)
};
const response = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(response).to.have.property('statusCode', payload.statusCode);
expect(response).to.have.property('encoding', payload.encoding);
expect(response).to.have.property('body').that.deep.equals(payload.body);
});
it('should use statusCode, encoding and body from falsy payload when present', async function() {
const testPayload = {
statusCode: 201,
encoding: 'identity',
body: false
};
const testAWSResponse = {
Payload: JSON.stringify(testPayload)
};
const response = await ResponseBuilder.fromAWSResponse(testAWSResponse);
expect(response).to.have.property('statusCode', testPayload.statusCode);
expect(response).to.have.property('encoding', testPayload.encoding);
expect(response).to.have.property('body').that.deep.equals(testPayload.body);
});
it('should be able to handle compressed response', async function() {
const compressed = await this.builder._buildCompressedResponse(RESPONSE);
const awsResponse = {
Payload: JSON.stringify(compressed)
};
const parsed = await ResponseBuilder.fromAWSResponse(awsResponse);
expect(parsed).to.deep.equal(RESPONSE);
});
it('should be able to handle S3 response', async function() {
const request = nock(this.url).get(/^\//).reply(200, RESPONSE.toString());
const s3 = await this.builder._buildS3Response(RESPONSE);
const awsResponse = {
Payload: JSON.stringify(s3)
};
const parsed = await ResponseBuilder.fromAWSResponse(awsResponse);
request.done();
expect(parsed).to.deep.equal(RESPONSE);
});
});
describe('#_buildCompressedResponse', function() {
it('should be able to create compressed response', async function() {
const compressed = await this.builder._buildCompressedResponse(RESPONSE);
expect(compressed).to.have.property('statusCode', RESPONSE.statusCode);
expect(compressed).to.have.property('encoding', 'gzip');
expect(compressed).to.have.property('body');
const decodedBody = JSON.parse(zlib.gunzipSync(Buffer.from(compressed.body, 'base64')));
expect(decodedBody).to.deep.equal(RESPONSE);
});
it('should throw when compression fails', function() {
const error = new Error('compression error');
this.sandbox.stub(zlib, 'gzip').yields(error);
return expect(this.builder._buildCompressedResponse(RESPONSE))
.to.eventually.be.rejectedWith('failed to compress response')
.and.to.have.nested.property('jse_cause.message', error.message);
});
});
describe('#_buildRawResponse', function() {
it('should be able to create raw response', async function() {
const response = this.builder._buildRawResponse(RESPONSE);
expect(response).to.be.an.instanceof(Response);
expect(response).to.have.property('statusCode', RESPONSE.statusCode);
expect(response).to.have.property('encoding', RESPONSE.encoding);
expect(response)
.to.have.property('body')
.that.is.deep.equal(RESPONSE.body);
});
it('should handle case when options are not passed', async function() {
const response = this.builder._buildRawResponse();
expect(response).to.be.an.instanceof(Response);
});
});
describe('#_buildS3Response', function() {
it('should be able to create compressed response', async function() {
const s3 = await this.builder._buildS3Response(RESPONSE);
expect(s3).to.be.an.instanceof(Response);
expect(s3).to.have.property('statusCode', RESPONSE.statusCode);
expect(s3).to.have.property('encoding', 's3');
expect(s3).to.have.property('body').contains(this.url);
sinon.assert.calledOnce(this.s3client.send);
const putParams = this.s3client.send.firstCall.args[0];
expect(putParams.input).to.have.property('Bucket', this.builder.bucket);
expect(putParams.input).to.have.property('Body', RESPONSE.toString());
expect(putParams.input)
.to.have.property('Key')
.that.is.a.uuid('v4');
sinon.assert.calledOnce(S3RequestPresigner.prototype.presign);
const getArgs = S3RequestPresigner.prototype.presign.firstCall.args;
expect(getArgs[0]).to.have.property('hostname', 'test-bucket.test-url');
expect(getArgs[0]).to.have.property('method', 'GET');
expect(getArgs[1]).to.have.property('expiresIn', this.builder.urlTTL);
});
it('should throw when putObject call fails', function() {
const error = new Error('upload error');
this.s3client.send.rejects(error);
return expect(this.builder._buildS3Response(RESPONSE))
.to.eventually.be.rejectedWith('failed to upload response object to S3')
.and.to.have.nested.property('jse_cause.message', error.message);
});
it('should throw when getSignedUrl call fails', function() {
const error = new Error('upload error');
// FIXME: calling S3RequestPresigner.prototype.rejects(error) does not work for some reason
S3RequestPresigner.prototype.presign.restore();
this.sandbox.stub(S3RequestPresigner.prototype, 'presign').rejects(error);
return expect(this.builder._buildS3Response(RESPONSE))
.to.eventually.be.rejectedWith('failed to generate S3 pre-signed url')
.and.to.have.nested.property('jse_cause.message', error.message);
});
});
describe('#build', function() {
it('should build raw response when its size is lower than threshold', function () {
this.builder.threshold = 1000;
const responseData = {
statusCode: 500,
encoding: 'test',
body: {
data: 'foo'
}
};
return expect(this.builder.build(responseData))
.to.eventually.be.an.instanceof(Response)
.and.to.have.property('encoding', responseData.encoding);
});
it('should build compressed response when when raw response is larger than threshold', function () {
this.builder.threshold = 1000;
const responseData = {
statusCode: 500,
encoding: 'test',
body: {
data: new Array(1000).fill('a')
}
};
return expect(this.builder.build(responseData))
.to.eventually.be.an.instanceof(Response)
.and.to.have.property('encoding', 'gzip');
});
it('should build s3 response when when compressed response is larger than threshold', function () {
this.builder.threshold = 50;
const responseData = {
statusCode: 500,
encoding: 'test',
body: {
data: new Array(1000).fill('a')
}
};
return expect(this.builder.build(responseData))
.to.eventually.be.an.instanceof(Response)
.and.to.have.property('encoding', 's3');
});
});
});