diff --git a/docker-compose.yml b/docker-compose.yml index 4415e88..1d86a3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,18 @@ -version: "3.3" +version: '3.3' services: - localstack: - container_name: "k6-jslib-aws-localstack" - image: "localstack/localstack:2.0.2" - ports: - - "127.0.0.1:4566:4566" # LocalStack Gateway - - "127.0.0.1:4510-4559:4510-4559" # external services port range - environment: - - SERVICES=s3,secretsmanager,sqs,kms,ssm,kinesis - - S3_SKIP_SIGNATURE_VALIDATION=0 # enable signature validation - - DEBUG=${DEBUG-} - - PERSISTENCE=${PERSISTENCE-} - volumes: - - "${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack" - - "./localstack/init:/etc/localstack/init" - - "/var/run/docker.sock:/var/run/docker.sock" + localstack: + container_name: 'k6-jslib-aws-localstack' + image: 'localstack/localstack:2.0.2' + ports: + - '127.0.0.1:4566:4566' # LocalStack Gateway + - '127.0.0.1:4510-4559:4510-4559' # external services port range + environment: + - SERVICES=s3,secretsmanager,sqs,kms,ssm,kinesis,lambda + - S3_SKIP_SIGNATURE_VALIDATION=0 # enable signature validation + - DEBUG=${DEBUG-} + - PERSISTENCE=${PERSISTENCE-} + volumes: + - '${LOCALSTACK_VOLUME_DIR:-./volume}:/var/lib/localstack' + - './localstack/init:/etc/localstack/init' + - '/var/run/docker.sock:/var/run/docker.sock' diff --git a/localstack/init/ready.d/lambda.sh b/localstack/init/ready.d/lambda.sh new file mode 100755 index 0000000..e9491bb --- /dev/null +++ b/localstack/init/ready.d/lambda.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +FUNCTION_NAME="test-jslib-aws-lambda" + +# Create a dummy lambda function responding with a static string "Hello World!" +cat >index.js < + ) { + const errorCode = response.error_code + if (errorCode === 0) { + return + } + + const error = response.json() as JSONObject + if (errorCode >= 1400 && errorCode <= 1499) { + // In the event of certain errors, the message is not set. + // Also, note the inconsistency in casing... + const errorMessage: string = + (error.Message as string) || (error.message as string) || (error.__type as string) + + // Handle specifically the case of an invalid signature + if (error.__type === 'InvalidSignatureException') { + throw new InvalidSignatureError(errorMessage, error.__type) + } + + // Otherwise throw a standard service error + throw new LambdaServiceError(errorMessage, error.__type as string, operation) + } + + if (errorCode === 1500) { + throw new LambdaServiceError( + 'An error occured on the server side', + 'InternalServiceError', + operation + ) + } + } +} + +enum LambdaOperation { + Invoke = 'Invoke', +} + + +/** + * Represents the input for an Invoke operation. + */ +interface InvokeInput { + /** + * The name of the Lambda function, version, or alias. + * + * Supported names formats: + * - Function name: `my-function` (name-only), `my-function:v1` (with alias). + * - Function ARM: `arn:aws:lambda:us-west-2:123456789012:function:my-function`. + * - Partial ARN: `123456789012:function:my-function`. + */ + FunctionName: string + /** + * Defines whether the function is invoked synchronously or asynchronously. + * - `RequestResponse` (default): Invoke the function synchronously. + * - `Event`: Invoke the function asynchronously. + * - `DryRun`: Validate parameter values and verify that the user or role has permission to invoke the function. + */ + InvocationType: 'RequestResponse' | 'Event' | 'DryRun' + /** + * Set to `Tail` to include the execution log in the response. Applies to synchronously invoked functions only. + */ + LogType?: 'None' | 'Tail' + /** + * Up to 3,583 bytes of base64-encoded data about the invoking client to pass to the function in the context object. + */ + ClientContext?: string + /** + * Specify a version or alias to invoke a published version of the function. + */ + Qualifier?: string + Payload?: string +} + +export class LambdaServiceError extends AWSError { + operation: LambdaOperation + + /** + * Constructs a LambdaServiceError + * + * @param {string} message - human readable error message + * @param {string} code - A unique short code representing the error that was emitted + * @param {string} operation - Name of the failed Operation + */ + constructor(message: string, code: string, operation: LambdaOperation) { + super(message, code) + this.name = 'LambdaServiceError' + this.operation = operation + } +} diff --git a/src/lambda.ts b/src/lambda.ts new file mode 100644 index 0000000..f7130c6 --- /dev/null +++ b/src/lambda.ts @@ -0,0 +1,7 @@ +// Re-Export public symbols +export { AWSConfig, InvalidAWSConfigError } from './internal/config' +export { InvalidSignatureError } from './internal/signature' +export { + LambdaServiceError, + LambdaClient +} from './internal/lambda' diff --git a/tests/index.js b/tests/index.js index eb50e08..10824e1 100644 --- a/tests/index.js +++ b/tests/index.js @@ -10,6 +10,7 @@ import { kinesisTestSuite } from './internal/kinesis.js' import { signatureV4TestSuite } from './internal/signature.js' import { sqsTestSuite } from './internal/sqs.js' import { eventBridgeTestSuite } from './internal/event-bridge.js' +import { lambdaTestSuite } from './internal/lambda.js' // Must know: // * end2end tests such as these rely on the localstack @@ -94,4 +95,5 @@ export default async function testSuite() { await ssmTestSuite(testData) await kinesisTestSuite(testData) await eventBridgeTestSuite(testData) + await lambdaTestSuite(testData) } diff --git a/tests/internal/lambda.js b/tests/internal/lambda.js new file mode 100644 index 0000000..385c4d9 --- /dev/null +++ b/tests/internal/lambda.js @@ -0,0 +1,43 @@ +import { asyncDescribe } from './helpers.js' +import { LambdaServiceError, LambdaClient } from '../../build/lambda.js' + +const functionName = 'test-jslib-aws-lambda'; + +export async function lambdaTestSuite(data) { + const lambda = new LambdaClient(data.awsConfig) + + await asyncDescribe('lambda.invoke - RequestResponse', async (expect) => { + let lambdaError + + try { + const result = await lambda.invoke({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + }) + + expect(result).to.be.string('Hello World!') + } catch (error) { + lambdaError = error + } + + expect(lambdaError).to.be.undefined + }) + + await asyncDescribe('lambda.invoke - Event', async (expect) => { + let lambdaError + + try { + const result = await lambda.invoke({ + FunctionName: functionName, + InvocationType: 'Event', + Payload: { foo: 'bar' }, + }) + + expect(result).to.be.undefined + } catch (error) { + lambdaError = error + } + + expect(lambdaError).to.be.undefined + }) +} diff --git a/tests/internal/s3.js b/tests/internal/s3.js index 1af7dc5..3ced76e 100644 --- a/tests/internal/s3.js +++ b/tests/internal/s3.js @@ -11,13 +11,15 @@ export async function s3TestSuite(data) { s3Client.endpoint = s3Endpoint await asyncDescribe('s3.listBuckets', async (expect) => { + let buckets; // Act - const buckets = await s3Client.listBuckets() + buckets = await s3Client.listBuckets() // Assert expect(buckets).to.be.an('array') - expect(buckets).to.have.lengthOf(1) - expect(buckets[0].name).to.equal(data.s3.testBucketName) + // Because other tests may have created buckets, we can't assume there is only one bucket. + expect(buckets).to.have.lengthOf.above(1) + expect(buckets.map((b) => b.name)).to.contain(data.s3.testBucketName) }) await asyncDescribe('s3.listObjects', async (expect) => { diff --git a/webpack.config.js b/webpack.config.js index c2ffd65..7858e13 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,6 +19,7 @@ module.exports = { kms: path.resolve(__dirname, 'src/kms.ts'), kinesis: path.resolve(__dirname, 'src/kinesis.ts'), 'event-bridge': path.resolve(__dirname, 'src/event-bridge.ts'), + lambda: path.resolve(__dirname, 'src/lambda.ts'), // AWS signature v4 signature: path.resolve(__dirname, 'src/signature.ts'),