From 9906be79072de0a0ab8a899c2ee23f40c2754193 Mon Sep 17 00:00:00 2001 From: "wuychloe@amazon.com chloe1818" Date: Wed, 30 Jul 2025 15:32:35 -0700 Subject: [PATCH 1/5] Added example workflow file, updated README.md, and added more unit tests. --- .gitignore | 1 - README.md | 217 ++--- __tests__ /code_artifacts.test.js | 20 + {__tests__ => __tests__ }/deep_equal.test.js | 0 __tests__ /dry_run_mode.test.js | 41 + __tests__ /error_handling.test.js | 150 ++++ __tests__ /file_permission_error.test.js | 32 + .../function_create.test.js | 0 .../has_configuration_changed.test.js | 0 .../package_artifacts.test.js | 2 +- .../s3_bucket_operations.test.js | 119 +++ __tests__ /s3_key_generation.test.js | 53 ++ .../update_function_code.test.js | 0 .../update_function_config.test.js | 0 {__tests__ => __tests__ }/validations.test.js | 92 ++ .../wait_for_function_active.test.js | 0 .../wait_for_function_updated.test.js | 0 __tests__/code_artifacts.test.js | 192 ----- __tests__/dry_run_mode.test.js | 111 --- __tests__/error_handling.test.js | 797 ------------------ deploy-lambda.yml | 82 ++ 21 files changed, 700 insertions(+), 1209 deletions(-) create mode 100644 __tests__ /code_artifacts.test.js rename {__tests__ => __tests__ }/deep_equal.test.js (100%) create mode 100644 __tests__ /dry_run_mode.test.js create mode 100644 __tests__ /error_handling.test.js create mode 100644 __tests__ /file_permission_error.test.js rename {__tests__ => __tests__ }/function_create.test.js (100%) rename {__tests__ => __tests__ }/has_configuration_changed.test.js (100%) rename {__tests__ => __tests__ }/package_artifacts.test.js (99%) rename {__tests__ => __tests__ }/s3_bucket_operations.test.js (80%) create mode 100644 __tests__ /s3_key_generation.test.js rename {__tests__ => __tests__ }/update_function_code.test.js (100%) rename {__tests__ => __tests__ }/update_function_config.test.js (100%) rename {__tests__ => __tests__ }/validations.test.js (93%) rename {__tests__ => __tests__ }/wait_for_function_active.test.js (100%) rename {__tests__ => __tests__ }/wait_for_function_updated.test.js (100%) delete mode 100644 __tests__/code_artifacts.test.js delete mode 100644 __tests__/dry_run_mode.test.js delete mode 100644 __tests__/error_handling.test.js create mode 100644 deploy-lambda.yml diff --git a/.gitignore b/.gitignore index be72404b..71863884 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,4 @@ dist .DS_Store coverage repolinter -deploy-lambda.yml Config \ No newline at end of file diff --git a/README.md b/README.md index 91437cb7..d881c66e 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Updates the code and configuration of AWS Lambda functions - [Usage](#usage) + * [Update Function Configuration](#update-configuration-only) * [Using S3 Deployment Method](#using-s3-deployment-method) - * [Update Configuration Only](#update-configuration-only) * [Dry Run Mode](#dry-run-mode) +- [Build from Source](#build-from-source) - [Inputs](#inputs) - [Outputs](#outputs) - [Credentials and Region](#credentials-and-region) - * [OpenID Connect (OIDC) - Recommended Approach](#openid-connect-oidc---recommended-approach) - [Permissions](#permissions) - [License Summary](#license-summary) - [Security Disclosures](#security-disclosures) @@ -23,124 +23,142 @@ Updates the code and configuration of AWS Lambda functions ## Usage ```yaml -name: Deploy Lambda Function +name: Deploy to AWS Lambda on: push: - branches: [main, master] + branches: [ "main" ] + +permissions: + id-token: write # This is required for OIDC authentication + contents: read # This is required to checkout the repository jobs: deploy: + name: Deploy runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC authentication - contents: read # Required to check out the repository + environment: production + steps: - - uses: actions/checkout@v3 - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v2 - with: - role-to-assume: arn:aws:iam::123456789012:role/GitHubActionRole - - name: Deploy Lambda function - uses: aws-actions/amazon-lambda-deploy@v1 - with: - function-name: my-lambda-function - code-artifacts-dir: ./dist + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ env.AWS_REGION }} + # The role-to-assume should be the ARN of the IAM role you created for GitHub Actions OIDC + + - name: Deploy Lambda Function + uses: aws-actions/aws-lambda-deploy@v1 + with: + function-name: my-function-name + code-artifacts-dir: my-code-artifacts-dir + # handler: my-handler + # runtime: my-runtime + # Add any additional inputs your action supports ``` -### Using S3 Deployment Method +The required parameters to deploy are function name, code artifacts directory, handler, and runtime. The function name and code artifacts directory need to be provided by the user. However, the handler and runtime do not and will default to index.handler and nodejs20.x if not provided. + +### Update Function Configuration ```yaml -name: Deploy Lambda Function with S3 + - name: Update Lambda configuration + uses: aws-actions/aws-lambda-deploy@v1 + with: + function-name: my-function-name + code-artifacts-dir: my-code-artifacts-dir + memory-size: 512 + timeout: 60 + environment: '{"ENV":"production","DEBUG":"true"}' +``` -on: - push: - branches: [main, master] +### Using S3 Deployment Method -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC authentication - contents: read # Required to check out the repository - steps: - - uses: actions/checkout@v3 - - - name: Configure AWS credentials with OIDC - uses: aws-actions/configure-aws-credentials@v2 - with: - role-to-assume: arn:aws:iam::123456789012:role/GitHubActionRole - +```yaml - name: Deploy Lambda function via S3 - uses: aws-actions/amazon-lambda-deploy@v1 + uses: aws-actions/aws-lambda-deploy@v1 with: - function-name: my-lambda-function - code-artifacts-dir: ./dist - s3-bucket: my-lambda-deployment-bucket + function-name: my-function-name + code-artifacts-dir: my-code-artifacts-dir + s3-bucket: my-s3-bucket # s3-key is optional - a key will be auto-generated if not specified ``` -### Update Configuration Only +### Dry Run Mode ```yaml -name: Update Lambda Configuration + - name: Deploy on dry run mode + uses: aws-actions/aws-lambda-deploy@v1 + with: + function-name: my-function-name + code-artifacts-dir: my-code-artifacts-dir + dry-run: true +``` +## Build from Source -on: - push: - branches: [main, master] +To automate building your source code, add the step that corresponds to your runtime: -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC authentication - contents: read # Required to check out the repository - steps: - - uses: actions/checkout@v3 - - - name: Configure AWS credentials with OIDC - uses: aws-actions/configure-aws-credentials@v2 - with: - role-to-assume: arn:aws:iam::123456789012:role/GitHubActionRole - - name: Update Lambda configuration - uses: aws-actions/amazon-lambda-deploy@v1 - with: - function-name: my-lambda-function - code-artifacts-dir: ./dist - memory-size: 512 - timeout: 60 - environment: '{"ENV":"production","DEBUG":"true"}' +### Node.js + +```yaml + - name: Build source code + run: | + # Install dependencies + npm ci + + # Build + npm run build ``` +### Python -### Dry Run Mode +```yaml + - name: Build source code using setup tools + run: | + # Install dependencies + pip install -r requirement.txt + + # Build + python -m build +``` + +### Ruby ```yaml -name: Validate Lambda Deployment + - name: Build source code using Rake + run: | + # Install dependencies + bundle install + + # Build + bundle exec rake [task_name] +``` -on: - pull_request: - branches: [main, master] +### Java + +```yaml + - name: Build source code using Maven + run: | + # Install dependencies + mvn dependency:resolve clean install -DskipTests + + # Build + mvn clean package + +``` +### .NET + +```yaml + - name: Build source code + run: | + # Install dependencies + dotnet restore + + # Build + dotnet build -jobs: - validate: - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC authentication - contents: read # Required to check out the repository - steps: - - uses: actions/checkout@v3 - - - name: Configure AWS credentials with OIDC - uses: aws-actions/configure-aws-credentials@v2 - with: - role-to-assume: arn:aws:iam::123456789012:role/GitHubActionRole - - name: Validate Lambda deployment (no changes) - uses: aws-actions/amazon-lambda-deploy@v1 - with: - function-name: my-lambda-function - code-artifacts-dir: ./dist - dry-run: true ``` ## Inputs @@ -187,32 +205,17 @@ jobs: This action relies on the [default behavior of the AWS SDK for JavaScript](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html) to determine AWS credentials and region. Use the [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) action to configure the GitHub Actions environment for AWS authentication. -### OpenID Connect (OIDC) - Recommended Approach +### OpenID Connect (OIDC) We **highly recommend** using OpenID Connect (OIDC) to authenticate with AWS. OIDC allows your GitHub Actions workflows to access AWS resources without storing AWS credentials as long-lived GitHub secrets. Here's an example of using OIDC with the aws-actions/configure-aws-credentials action: ```yaml -jobs: - deploy: - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC authentication - contents: read # Required to check out the repository - steps: - - uses: actions/checkout@v3 - - name: Configure AWS credentials with OIDC uses: aws-actions/configure-aws-credentials@v2 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionRole - - - name: Deploy Lambda function - uses: aws-actions/amazon-lambda-deploy@v1 - with: - function-name: my-lambda-function - code-artifacts-dir: ./dist ``` To use OIDC authentication, you must configure a trust policy in AWS IAM that allows GitHub Actions to assume an IAM role. Here's an example trust policy: diff --git a/__tests__ /code_artifacts.test.js b/__tests__ /code_artifacts.test.js new file mode 100644 index 00000000..b78df1f2 --- /dev/null +++ b/__tests__ /code_artifacts.test.js @@ -0,0 +1,20 @@ +const { packageCodeArtifacts } = require('../index'); + +jest.mock('@actions/core'); +jest.mock('fs/promises'); +jest.mock('adm-zip'); +jest.mock('path'); + +describe('Code Artifacts Tests', () => { + test('should throw error when artifactsDir is not provided', async () => { + await expect(packageCodeArtifacts()).rejects.toThrow('Code artifacts directory path must be provided'); + }); + + test('should throw error when artifactsDir is null', async () => { + await expect(packageCodeArtifacts(null)).rejects.toThrow('Code artifacts directory path must be provided'); + }); + + test('should throw error when artifactsDir is empty string', async () => { + await expect(packageCodeArtifacts('')).rejects.toThrow('Code artifacts directory path must be provided'); + }); +}); \ No newline at end of file diff --git a/__tests__/deep_equal.test.js b/__tests__ /deep_equal.test.js similarity index 100% rename from __tests__/deep_equal.test.js rename to __tests__ /deep_equal.test.js diff --git a/__tests__ /dry_run_mode.test.js b/__tests__ /dry_run_mode.test.js new file mode 100644 index 00000000..5a4e5303 --- /dev/null +++ b/__tests__ /dry_run_mode.test.js @@ -0,0 +1,41 @@ +const core = require('@actions/core'); +const { LambdaClient } = require('@aws-sdk/client-lambda'); +const mainModule = require('../index'); +const validations = require('../validations'); + +jest.mock('@actions/core'); +jest.mock('@aws-sdk/client-lambda'); +jest.mock('@aws-sdk/client-s3'); +jest.mock('@aws-sdk/client-sts'); +jest.mock('fs/promises'); +jest.mock('adm-zip'); +jest.mock('path'); + +describe('Dry Run Mode Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.AWS_REGION = 'us-east-1'; + }); + + test('should skip configuration updates in dry run mode when config changed', async () => { + const mockClient = { + send: jest.fn().mockResolvedValue({ Runtime: 'nodejs18.x', MemorySize: 256 }) + }; + LambdaClient.mockImplementation(() => mockClient); + + validations.validateAllInputs = jest.fn().mockReturnValue({ + valid: true, + functionName: 'test-function', + parsedEnvironment: {}, + dryRun: true, + role: 'arn:aws:iam::123456789012:role/lambda-role' + }); + + jest.spyOn(mainModule, 'checkFunctionExists').mockResolvedValue(true); + jest.spyOn(mainModule, 'packageCodeArtifacts').mockResolvedValue('/tmp/test.zip'); + jest.spyOn(mainModule, 'createFunction').mockResolvedValue(); + jest.spyOn(mainModule, 'hasConfigurationChanged').mockReturnValue(true); + + await mainModule.run(); + }); +}); \ No newline at end of file diff --git a/__tests__ /error_handling.test.js b/__tests__ /error_handling.test.js new file mode 100644 index 00000000..cbe6bbe3 --- /dev/null +++ b/__tests__ /error_handling.test.js @@ -0,0 +1,150 @@ +const core = require('@actions/core'); +const { LambdaClient } = require('@aws-sdk/client-lambda'); +const mainModule = require('../index'); +const validations = require('../validations'); + +jest.mock('@actions/core'); +jest.mock('@aws-sdk/client-lambda'); +jest.mock('@aws-sdk/client-s3'); +jest.mock('@aws-sdk/client-sts'); +jest.mock('fs/promises'); +jest.mock('adm-zip'); +jest.mock('path'); + +describe('Deployment Completion Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + + process.env.AWS_REGION = 'us-east-1'; + + core.getInput = jest.fn().mockImplementation((name) => { + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': '/mock/artifacts', + 'role': 'arn:aws:iam::123456789012:role/lambda-role', + 'runtime': 'nodejs18.x', + 'handler': 'index.handler' + }; + return inputs[name] || ''; + }); + + // Mock validations + validations.validateAllInputs = jest.fn().mockReturnValue({ + valid: true, + functionName: 'test-function', + codeArtifactsDir: '/mock/artifacts', + role: 'arn:aws:iam::123456789012:role/lambda-role', + runtime: 'nodejs18.x', + handler: 'index.handler', + parsedEnvironment: {}, + dryRun: false, + publish: true + }); + }); + + test('should log success message on successful deployment', async () => { + const mockClient = { + send: jest.fn().mockResolvedValue({ FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function' }) + }; + LambdaClient.mockImplementation(() => mockClient); + + jest.spyOn(mainModule, 'checkFunctionExists').mockResolvedValue(true); + jest.spyOn(mainModule, 'packageCodeArtifacts').mockResolvedValue('/tmp/test.zip'); + jest.spyOn(mainModule, 'createFunction').mockResolvedValue(); + jest.spyOn(mainModule, 'hasConfigurationChanged').mockReturnValue(false); + jest.spyOn(mainModule, 'updateFunctionCode').mockResolvedValue(); + + await mainModule.run(); + }); + + test('should handle ThrottlingException error', async () => { + const error = new Error('Rate limit exceeded'); + error.name = 'ThrottlingException'; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Rate limit exceeded and maximum retries reached: Rate limit exceeded'); + }); + + test('should handle TooManyRequestsException error', async () => { + const error = new Error('Too many requests'); + error.name = 'TooManyRequestsException'; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Rate limit exceeded and maximum retries reached: Too many requests'); + }); + + test('should handle 429 status code error', async () => { + const error = new Error('Rate limited'); + error.$metadata = { httpStatusCode: 429 }; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Rate limit exceeded and maximum retries reached: Rate limited'); + }); + + test('should handle server error (500+ status code)', async () => { + const error = new Error('Internal server error'); + error.$metadata = { httpStatusCode: 500 }; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Server error (500): Internal server error. All retry attempts failed.'); + }); + + test('should handle AccessDeniedException error', async () => { + const error = new Error('Access denied'); + error.name = 'AccessDeniedException'; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Action failed with error: Permissions error: Access denied. Check IAM roles.'); + }); + + test('should handle generic error', async () => { + const error = new Error('Generic error'); + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.setFailed).toHaveBeenCalledWith('Action failed with error: Generic error'); + }); + + test('should call core.debug with error stack when available', async () => { + const error = new Error('Error with stack'); + error.stack = 'Error stack trace'; + + validations.validateAllInputs.mockImplementation(() => { + throw error; + }); + + await mainModule.run(); + + expect(core.debug).toHaveBeenCalledWith('Error stack trace'); + }); +}); \ No newline at end of file diff --git a/__tests__ /file_permission_error.test.js b/__tests__ /file_permission_error.test.js new file mode 100644 index 00000000..23dd807d --- /dev/null +++ b/__tests__ /file_permission_error.test.js @@ -0,0 +1,32 @@ +const core = require('@actions/core'); +const fs = require('fs/promises'); +const { createFunction } = require('../index'); + +jest.mock('@actions/core'); +jest.mock('fs/promises'); + +describe('File Permission Error Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should handle EACCES error when reading zip file', async () => { + const mockClient = { send: jest.fn() }; + const error = new Error('Permission denied'); + error.code = 'EACCES'; + + fs.readFile = jest.fn().mockRejectedValue(error); + + const inputs = { + functionName: 'test-function', + finalZipPath: '/tmp/test.zip', + dryRun: false, + role: 'arn:aws:iam::123456789012:role/lambda-role' + }; + + await expect(createFunction(mockClient, inputs, false)).rejects.toThrow(); + + expect(core.setFailed).toHaveBeenCalledWith('Failed to read Lambda deployment package: Permission denied'); + expect(core.error).toHaveBeenCalledWith('Permission denied. Check file access permissions.'); + }); +}); \ No newline at end of file diff --git a/__tests__/function_create.test.js b/__tests__ /function_create.test.js similarity index 100% rename from __tests__/function_create.test.js rename to __tests__ /function_create.test.js diff --git a/__tests__/has_configuration_changed.test.js b/__tests__ /has_configuration_changed.test.js similarity index 100% rename from __tests__/has_configuration_changed.test.js rename to __tests__ /has_configuration_changed.test.js diff --git a/__tests__/package_artifacts.test.js b/__tests__ /package_artifacts.test.js similarity index 99% rename from __tests__/package_artifacts.test.js rename to __tests__ /package_artifacts.test.js index 0d5d1d03..64145dc3 100644 --- a/__tests__/package_artifacts.test.js +++ b/__tests__ /package_artifacts.test.js @@ -14,7 +14,7 @@ jest.mock('path'); jest.mock('os'); jest.mock('../validations'); -describe('Package Code Artifacts Tests', () => { +describe('Package Artifacts Tests', () => { const mockTimestamp = 1234567890; beforeEach(() => { jest.resetAllMocks(); diff --git a/__tests__/s3_bucket_operations.test.js b/__tests__ /s3_bucket_operations.test.js similarity index 80% rename from __tests__/s3_bucket_operations.test.js rename to __tests__ /s3_bucket_operations.test.js index 19215cc5..e5f0c08f 100644 --- a/__tests__/s3_bucket_operations.test.js +++ b/__tests__ /s3_bucket_operations.test.js @@ -387,6 +387,125 @@ describe('S3 Bucket Operations Tests', () => { }); }); + describe('uploadToS3 error handling', () => { + test('should handle NoSuchBucket error', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const noSuchBucketError = new Error('No such bucket'); + noSuchBucketError.code = 'NoSuchBucket'; + + mockS3Send.mockRejectedValueOnce(noSuchBucketError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'non-existent-bucket', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('No such bucket'); + + expect(core.error).toHaveBeenCalledWith('Bucket non-existent-bucket does not exist and could not be created automatically. Please create it manually or check your permissions.'); + }); + + test('should handle AccessDenied error with code', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const accessDeniedError = new Error('Access denied'); + accessDeniedError.code = 'AccessDenied'; + + mockS3Send.mockRejectedValueOnce(accessDeniedError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'test-bucket', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('Access denied'); + + expect(core.error).toHaveBeenCalledWith('Access denied. Ensure your AWS credentials have the following permissions:'); + expect(core.error).toHaveBeenCalledWith('- s3:HeadBucket (to check if the bucket exists)'); + expect(core.error).toHaveBeenCalledWith('- s3:CreateBucket (to create the bucket if it doesn\'t exist)'); + expect(core.error).toHaveBeenCalledWith('- s3:PutObject (to upload the file to the bucket)'); + expect(core.error).toHaveBeenCalledWith('See s3-troubleshooting.md for a complete IAM policy template.'); + }); + + test('should handle AccessDenied error with name', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const accessDeniedError = new Error('Access denied'); + accessDeniedError.name = 'AccessDenied'; + + mockS3Send.mockRejectedValueOnce(accessDeniedError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'test-bucket', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('Access denied'); + + expect(core.error).toHaveBeenCalledWith('Access denied. Ensure your AWS credentials have the following permissions:'); + }); + + test('should handle AccessDenied error with 403 status code', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const accessDeniedError = new Error('Access denied'); + accessDeniedError.$metadata = { httpStatusCode: 403 }; + + mockS3Send.mockRejectedValueOnce(accessDeniedError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'test-bucket', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('Access denied'); + }); + + test('should handle CredentialsProviderError', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const credentialsError = new Error('Credentials not found'); + credentialsError.name = 'CredentialsProviderError'; + + mockS3Send.mockRejectedValueOnce(credentialsError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'test-bucket', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('Credentials not found'); + + expect(core.error).toHaveBeenCalledWith('AWS credentials not found or invalid. Check your AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.'); + }); + + test('should handle InvalidBucketName error', async () => { + fs.readFile.mockResolvedValue(Buffer.from('test file content')); + fs.access.mockResolvedValue(undefined); + + const invalidBucketError = new Error('Invalid bucket name'); + invalidBucketError.name = 'InvalidBucketName'; + + mockS3Send.mockRejectedValueOnce(invalidBucketError); + + await expect(mainModule.uploadToS3( + '/path/to/file.zip', + 'Invalid_Bucket_Name', + 'key.zip', + 'us-east-1' + )).rejects.toThrow('Invalid bucket name'); + + expect(core.error).toHaveBeenCalledWith('Invalid bucket name: Invalid_Bucket_Name. Bucket names must follow S3 naming rules.'); + expect(core.error).toHaveBeenCalledWith('See s3-troubleshooting.md for S3 bucket naming rules.'); + }); + }); + describe('End-to-End S3 Deployment Flow', () => { let originalRun; beforeAll(() => { diff --git a/__tests__ /s3_key_generation.test.js b/__tests__ /s3_key_generation.test.js new file mode 100644 index 00000000..7b101e4b --- /dev/null +++ b/__tests__ /s3_key_generation.test.js @@ -0,0 +1,53 @@ +const core = require('@actions/core'); +const { LambdaClient } = require('@aws-sdk/client-lambda'); +const mainModule = require('../index'); +const validations = require('../validations'); + +jest.mock('@actions/core'); +jest.mock('@aws-sdk/client-lambda'); +jest.mock('@aws-sdk/client-s3'); +jest.mock('@aws-sdk/client-sts'); +jest.mock('fs/promises'); +jest.mock('adm-zip'); +jest.mock('path'); + +describe('S3 Key Generation and Dry Run Tests', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.AWS_REGION = 'us-east-1'; + }); + + test('should auto-generate S3 key when not provided', async () => { + validations.validateAllInputs = jest.fn().mockReturnValue({ + valid: true, + functionName: 'test-function', + s3Bucket: 'test-bucket', + s3Key: '', + useS3Method: true + }); + + jest.spyOn(mainModule, 'generateS3Key').mockReturnValue('auto-generated-key.zip'); + jest.spyOn(mainModule, 'checkFunctionExists').mockResolvedValue(true); + jest.spyOn(mainModule, 'packageCodeArtifacts').mockResolvedValue('/tmp/test.zip'); + jest.spyOn(mainModule, 'createFunction').mockResolvedValue(); + jest.spyOn(mainModule, 'hasConfigurationChanged').mockReturnValue(false); + jest.spyOn(mainModule, 'updateFunctionCode').mockResolvedValue(); + + await mainModule.run(); + }); + + test('should log dry run message and fail when function does not exist', async () => { + validations.validateAllInputs = jest.fn().mockReturnValue({ + valid: true, + functionName: 'test-function', + dryRun: true + }); + + jest.spyOn(mainModule, 'checkFunctionExists').mockResolvedValue(false); + + await mainModule.run(); + + expect(core.info).toHaveBeenCalledWith('DRY RUN MODE: No AWS resources will be created or modified'); + expect(core.setFailed).toHaveBeenCalledWith('DRY RUN MODE can only be used for updating function code of existing functions'); + }); +}); \ No newline at end of file diff --git a/__tests__/update_function_code.test.js b/__tests__ /update_function_code.test.js similarity index 100% rename from __tests__/update_function_code.test.js rename to __tests__ /update_function_code.test.js diff --git a/__tests__/update_function_config.test.js b/__tests__ /update_function_config.test.js similarity index 100% rename from __tests__/update_function_config.test.js rename to __tests__ /update_function_config.test.js diff --git a/__tests__/validations.test.js b/__tests__ /validations.test.js similarity index 93% rename from __tests__/validations.test.js rename to __tests__ /validations.test.js index 318977c2..b1b23b34 100644 --- a/__tests__/validations.test.js +++ b/__tests__ /validations.test.js @@ -1,6 +1,8 @@ const core = require('@actions/core'); const { validateAndResolvePath } = require('../validations'); const originalValidations = jest.requireActual('../validations'); +const index = require('../index'); +const validations = require('../validations'); jest.mock('@actions/core'); jest.mock('../validations', () => { @@ -363,6 +365,20 @@ describe('Validations Tests', () => { expect.stringContaining('Invalid KMS key ARN format') ); }); + test('should reject invalid source-kms-key-arn format', () => { + core.getInput.mockImplementation((name) => { + if (name === 'source-kms-key-arn') return 'invalid:source:kms:arn'; + if (name === 'function-name') return 'test-function'; + if (name === 'region') return 'us-east-1'; + if (name === 'code-artifacts-dir') return './artifacts'; + return ''; + }); + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(false); + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('Invalid KMS key ARN format') + ); + }); }); }); describe('JSON Input Validations', () => { @@ -1142,6 +1158,65 @@ describe('Validations Tests', () => { ); }); }); + describe('image-config validation', () => { + test('should accept valid image-config', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'image-config') { + return '{"EntryPoint":["/app/entrypoint.sh"],"Command":["handler"]}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedImageConfig).toEqual({ + EntryPoint: ['/app/entrypoint.sh'], + Command: ['handler'] + }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + }); + describe('logging-config validation', () => { + test('should accept valid logging-config', () => { + const mockGetInput = jest.fn((name) => { + if (name === 'logging-config') { + return '{"LogFormat":"JSON","ApplicationLogLevel":"INFO"}'; + } + const inputs = { + 'function-name': 'test-function', + 'region': 'us-east-1', + 'code-artifacts-dir': './test-dir' + }; + return inputs[name] || ''; + }); + core.getInput = mockGetInput; + const result = originalValidations.validateAllInputs(); + expect(result.valid).toBe(true); + expect(result.parsedLoggingConfig).toEqual({ + LogFormat: 'JSON', + ApplicationLogLevel: 'INFO' + }); + expect(core.setFailed).not.toHaveBeenCalled(); + }); + }); + }); + describe('getAdditionalInputs function', () => { + test('should handle invalid publish input and default to false', () => { + const mockGetBooleanInput = jest.fn((name) => { + if (name === 'publish') { + throw new Error('Invalid boolean input'); + } + return false; + }); + core.getBooleanInput = mockGetBooleanInput; + const result = originalValidations.getAdditionalInputs(); + expect(result.publish).toBe(false); + }); }); describe('validateAndResolvePath function', () => { let originalPlatform; @@ -1255,5 +1330,22 @@ describe('Validations Tests', () => { expect(result).toBe('/base/path/file.js'); }); }); + describe('Early Return on Invalid Inputs', () => { + test('run should return early when validations fail', async () => { + const mockValidateAllInputs = jest.fn().mockReturnValue({ valid: false }); + const originalValidateAllInputs = validations.validateAllInputs; + validations.validateAllInputs = mockValidateAllInputs; + + const coreSpy = jest.spyOn(require('@actions/core'), 'info'); + + await index.run(); + + expect(mockValidateAllInputs).toHaveBeenCalledTimes(1); + expect(coreSpy).not.toHaveBeenCalledWith(expect.stringMatching(/Creating Lambda function with deployment package/)); + + validations.validateAllInputs = originalValidateAllInputs; + }); + + }); }); diff --git a/__tests__/wait_for_function_active.test.js b/__tests__ /wait_for_function_active.test.js similarity index 100% rename from __tests__/wait_for_function_active.test.js rename to __tests__ /wait_for_function_active.test.js diff --git a/__tests__/wait_for_function_updated.test.js b/__tests__ /wait_for_function_updated.test.js similarity index 100% rename from __tests__/wait_for_function_updated.test.js rename to __tests__ /wait_for_function_updated.test.js diff --git a/__tests__/code_artifacts.test.js b/__tests__/code_artifacts.test.js deleted file mode 100644 index 2dd0bef7..00000000 --- a/__tests__/code_artifacts.test.js +++ /dev/null @@ -1,192 +0,0 @@ -jest.mock('@actions/core'); -jest.mock('@aws-sdk/client-lambda'); -jest.mock('../index', () => { - const actualModule = jest.requireActual('../index'); - const originalRun = actualModule.run; - - return { - ...actualModule, - run: jest.fn().mockImplementation(async () => { - const fs = require('fs/promises'); - const AdmZip = require('adm-zip'); - const { glob } = require('glob'); - const core = require('@actions/core'); - - await fs.mkdir('/mock/cwd/lambda-package', { recursive: true }); - await glob('**/*', { cwd: '/mock/artifacts', dot: true }); - const zip = new AdmZip(); - zip.addLocalFolder('/mock/cwd/lambda-package'); - - core.info('Packaging code artifacts from /mock/artifacts'); - core.info('Lambda function deployment completed successfully'); - }), - packageCodeArtifacts: jest.fn().mockResolvedValue('/mock/cwd/lambda-function.zip'), - parseJsonInput: actualModule.parseJsonInput, - validateRoleArn: actualModule.validateRoleArn, - validateCodeSigningConfigArn: actualModule.validateCodeSigningConfigArn, - validateKmsKeyArn: actualModule.validateKmsKeyArn, - checkFunctionExists: jest.fn().mockResolvedValue(false), - waitForFunctionUpdated: jest.fn().mockResolvedValue(undefined) - }; -}); -jest.mock('fs/promises', () => ({ - mkdir: jest.fn().mockResolvedValue(undefined), - stat: jest.fn().mockImplementation(async (path) => ({ - isDirectory: () => path.includes('directory') - })), - copyFile: jest.fn().mockResolvedValue(undefined), - readFile: jest.fn().mockResolvedValue(Buffer.from('mock file content')) -})); -jest.mock('glob', () => ({ - glob: jest.fn().mockResolvedValue(['file1.js', 'directory/file2.js', 'directory']) -})); -jest.mock('adm-zip', () => - jest.fn().mockImplementation(() => ({ - addLocalFolder: jest.fn(), - writeZip: jest.fn() - })) -); -jest.mock('path'); - -const core = require('@actions/core'); -const { LambdaClient } = require('@aws-sdk/client-lambda'); -const fs = require('fs/promises'); -const path = require('path'); -const { glob } = require('glob'); -const AdmZip = require('adm-zip'); -const mainModule = require('../index'); - -describe('Code Artifacts Tests', () => { - beforeEach(() => { - jest.clearAllMocks(); - - process.cwd = jest.fn().mockReturnValue('/mock/cwd'); - - path.join.mockImplementation((...parts) => parts.join('/')); - path.dirname.mockImplementation((p) => p.substring(0, p.lastIndexOf('/'))); - - core.getInput.mockImplementation((name) => { - const inputs = { - 'function-name': 'test-function', - 'region': 'us-east-1', - 'code-artifacts-dir': '/mock/artifacts', - 'role': 'arn:aws:iam::123456789012:role/lambda-role', - }; - return inputs[name] || ''; - }); - - core.getBooleanInput.mockImplementation(() => false); - core.info.mockImplementation(() => {}); - core.error.mockImplementation(() => {}); - core.setFailed.mockImplementation(() => {}); - - const mockLambdaResponse = { - $metadata: { httpStatusCode: 200 }, - Configuration: { - FunctionName: 'test-function', - Runtime: 'nodejs18.x', - Handler: 'index.handler', - Role: 'arn:aws:iam::123456789012:role/lambda-role' - } - }; - - LambdaClient.prototype.send = jest.fn().mockResolvedValue(mockLambdaResponse); - }); - - test('should package artifacts and deploy to Lambda', async () => { - mainModule.run.mockImplementationOnce(async () => { - await fs.mkdir('/mock/cwd/lambda-package', { recursive: true }); - const files = await glob('**/*', { cwd: '/mock/artifacts', dot: true }); - const zip = new AdmZip(); - zip.addLocalFolder('/mock/cwd/lambda-package'); - core.info('Packaging code artifacts from /mock/artifacts'); - }); - - await mainModule.run(); - - - expect(fs.mkdir).toHaveBeenCalledWith('/mock/cwd/lambda-package', { recursive: true }); - - expect(glob).toHaveBeenCalledWith('**/*', { cwd: '/mock/artifacts', dot: true }); - - expect(AdmZip).toHaveBeenCalled(); - const zipInstance = AdmZip.mock.results[0].value; - expect(zipInstance.addLocalFolder).toHaveBeenCalledWith('/mock/cwd/lambda-package'); - - expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Packaging code artifacts')); - expect(core.setFailed).not.toHaveBeenCalled(); - }); - - test('should handle artifacts packaging failure', async () => { - - const packageError = new Error('Failed to create package'); - fs.mkdir.mockRejectedValueOnce(packageError); - - mainModule.run.mockImplementationOnce(async () => { - try { - await fs.mkdir('/mock/cwd/lambda-package', { recursive: true }); - } catch (error) { - core.setFailed(`Action failed with error: ${error.message}`); - } - }); - - - await mainModule.run(); - expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Action failed with error')); - }); - - test('should correctly use code-artifacts-dir when provided', async () => { - - core.getInput.mockImplementation((name) => { - const inputs = { - 'function-name': 'test-function', - 'region': 'us-east-1', - 'code-artifacts-dir': '/mock/different-artifacts', - 'role': 'arn:aws:iam::123456789012:role/lambda-role', - }; - return inputs[name] || ''; - }); - - mainModule.run.mockImplementationOnce(async () => { - core.info('Lambda function deployment completed successfully'); - }); - - await mainModule.run(); - - expect(fs.mkdir).not.toHaveBeenCalled(); - expect(glob).not.toHaveBeenCalled(); - expect(core.setFailed).not.toHaveBeenCalled(); - }); - - test('should fail when code-artifacts-dir is missing', async () => { - - core.getInput.mockImplementation((name) => { - const inputs = { - 'function-name': 'test-function', - 'region': 'us-east-1', - 'role': 'arn:aws:iam::123456789012:role/lambda-role', - }; - return inputs[name] || ''; - }); - - mainModule.run.mockImplementationOnce(async () => { - const codeArtifactsDir = core.getInput('code-artifacts-dir'); - - if (!codeArtifactsDir) { - core.setFailed('Code-artifacts-dir must be provided'); - return; - } - - await fs.mkdir('/mock/cwd/lambda-package', { recursive: true }); - }); - - await mainModule.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Code-artifacts-dir must be provided' - ); - - expect(fs.mkdir).not.toHaveBeenCalled(); - expect(glob).not.toHaveBeenCalled(); - }); -}); \ No newline at end of file diff --git a/__tests__/dry_run_mode.test.js b/__tests__/dry_run_mode.test.js deleted file mode 100644 index 6f604fc0..00000000 --- a/__tests__/dry_run_mode.test.js +++ /dev/null @@ -1,111 +0,0 @@ -jest.mock('@actions/core'); -jest.mock('@aws-sdk/client-lambda'); -jest.mock('fs/promises', () => ({ - mkdir: jest.fn().mockResolvedValue(undefined), - readdir: jest.fn().mockResolvedValue(['index.js', 'package.json']), - rm: jest.fn().mockResolvedValue(undefined), - cp: jest.fn().mockResolvedValue(undefined), - readFile: jest.fn().mockResolvedValue(Buffer.from('mock file content')) -})); -jest.mock('path', () => ({ - join: jest.fn((...args) => args.join('/')) -})); -jest.mock('adm-zip', () => { - return jest.fn().mockImplementation(() => { - return { - addLocalFolder: jest.fn(), - writeZip: jest.fn() - }; - }); -}); - -const core = require('@actions/core'); -const { LambdaClient } = require('@aws-sdk/client-lambda'); -const fs = require('fs/promises'); - -describe('Dry Run Mode Tests', () => { - let index; - let mockLambdaClient; - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - - core.info = jest.fn(); - core.setFailed = jest.fn(); - core.setOutput = jest.fn(); - - mockLambdaClient = { - send: jest.fn().mockResolvedValue({ - FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', - Version: '$LATEST' - }) - }; - LambdaClient.prototype.send = mockLambdaClient.send; - - index = require('../index'); - }); - - test('No function creation in dry run mode', async () => { - - const functionName = 'test-function'; - const dryRun = true; - if (dryRun && !await index.checkFunctionExists({ send: jest.fn().mockRejectedValue({ name: 'ResourceNotFoundException' }) }, functionName)) { - core.setFailed('DRY RUN MODE can only be used for updating function code of existing functions'); - } - - expect(core.setFailed).toHaveBeenCalledWith( - 'DRY RUN MODE can only be used for updating function code of existing functions' - ); - }); - - test('Skip configuration updates in dry run mode', async () => { - - const configChanged = true; - const dryRun = true; - if (configChanged && dryRun) { - core.info('[DRY RUN] Configuration updates are not simulated in dry run mode'); - - } - - expect(core.info).toHaveBeenCalledWith( - '[DRY RUN] Configuration updates are not simulated in dry run mode' - ); - }); - - test('Add DryRun flag', async () => { - - const functionName = 'test-function'; - const dryRun = true; - const region = 'us-east-1'; - - if (dryRun) { - core.info('DRY RUN MODE: No AWS resources will be created or modified'); - const codeInput = { - FunctionName: functionName, - ZipFile: await fs.readFile('/path/to/lambda-function.zip'), - DryRun: true - }; - core.info(`[DRY RUN] Would update function code with parameters:`); - core.info(JSON.stringify({ ...codeInput, ZipFile: '' }, null, 2)); - - const mockResponse = { - FunctionArn: `arn:aws:lambda:${region}:000000000000:function:${functionName}`, - Version: '$LATEST' - }; - core.info('[DRY RUN] Function code validation passed'); - core.setOutput('function-arn', mockResponse.FunctionArn); - core.setOutput('version', mockResponse.Version); - core.info('[DRY RUN] Function code update simulation completed'); - } - - expect(core.info).toHaveBeenCalledWith('DRY RUN MODE: No AWS resources will be created or modified'); - expect(core.info).toHaveBeenCalledWith('[DRY RUN] Would update function code with parameters:'); - expect(core.info).toHaveBeenCalledWith(expect.stringContaining('DryRun')); - expect(core.info).toHaveBeenCalledWith('[DRY RUN] Function code validation passed'); - expect(core.info).toHaveBeenCalledWith('[DRY RUN] Function code update simulation completed'); - - expect(core.setOutput).toHaveBeenCalledWith('function-arn', `arn:aws:lambda:${region}:000000000000:function:${functionName}`); - expect(core.setOutput).toHaveBeenCalledWith('version', '$LATEST'); - }); -}); diff --git a/__tests__/error_handling.test.js b/__tests__/error_handling.test.js deleted file mode 100644 index 36f5be28..00000000 --- a/__tests__/error_handling.test.js +++ /dev/null @@ -1,797 +0,0 @@ -const core = require('@actions/core'); -const { LambdaClient, UpdateFunctionConfigurationCommand,UpdateFunctionCodeCommand,GetFunctionConfigurationCommand,waitUntilFunctionUpdated} = require('@aws-sdk/client-lambda'); -const fs = require('fs/promises'); -const path = require('path'); -const mainModule = require('../index'); -const validations = require('../validations'); - -jest.mock('@actions/core'); -jest.mock('@aws-sdk/client-lambda'); -jest.mock('@aws-sdk/client-s3', () => { - const original = jest.requireActual('@aws-sdk/client-s3'); - return { - ...original, - S3Client: jest.fn(), - PutObjectCommand: jest.fn(), - CreateBucketCommand: jest.fn(), - HeadBucketCommand: jest.fn() - }; -}); -jest.mock('@smithy/node-http-handler', () => ({ - NodeHttpHandler: jest.fn().mockImplementation(() => ({ - })) -})); -jest.mock('https', () => ({ - Agent: jest.fn().mockImplementation(() => ({ - })) -})); -jest.mock('fs/promises', () => ({ - mkdir: jest.fn().mockResolvedValue(undefined), - stat: jest.fn().mockImplementation(async (path) => ({ - isDirectory: () => path.includes('directory'), - size: 1024 - })), - readFile: jest.fn().mockResolvedValue(Buffer.from('mock file content')), - readdir: jest.fn().mockImplementation((dir, options) => { - if (options && options.withFileTypes) { - return Promise.resolve([ - { name: 'file1.js', isDirectory: () => false }, - { name: 'directory', isDirectory: () => true } - ]); - } else { - return Promise.resolve(['file1.js', 'directory']); - } - }), - cp: jest.fn().mockResolvedValue(undefined), - rm: jest.fn().mockResolvedValue(undefined), - access: jest.fn().mockResolvedValue(undefined) -})); -jest.mock('adm-zip', () => { - const mockEntries = [ - { entryName: 'file1.js', header: { size: 1024 } }, - { entryName: 'directory/file2.js', header: { size: 2048 } } - ]; - return jest.fn().mockImplementation((zipPath) => { - if (zipPath) { - return { - getEntries: jest.fn().mockReturnValue(mockEntries) - }; - } - return { - addLocalFolder: jest.fn(), - addLocalFile: jest.fn(), - writeZip: jest.fn() - }; - }); -}); -jest.mock('path'); - -const simplifiedIndex = { - run: async function() { - try { - const inputs = validations.validateAllInputs(); - if (!inputs.valid) { - return; - } - const client = new LambdaClient({ - region: inputs.region - }); - - let functionExists = false; - try { - await client.send({ FunctionName: inputs.functionName }); - functionExists = true; - } catch (error) { - if (error.name !== 'ResourceNotFoundException') { - throw error; - } - } - - if (!functionExists) { - try { - await client.send({ functionName: inputs.functionName }); - } catch (error) { - core.setFailed(`Failed to create function: ${error.message}`); - if (error.stack) { - core.debug(error.stack); - } - throw error; - } - } else { - const config = await client.send({ functionName: inputs.functionName }); - - const configChanged = true; - if (configChanged) { - try { - await client.send({ functionName: inputs.functionName }); - - } catch (error) { - core.setFailed(`Failed to update function configuration: ${error.message}`); - if (error.stack) { - core.debug(error.stack); - } - throw error; - } - } - - try { - const zipPath = '/path/to/function.zip'; - const zipContent = await fs.readFile(zipPath); - - await client.send({ - FunctionName: inputs.functionName, - ZipFile: zipContent - }); - } catch (error) { - if (error.code === 'ENOENT') { - core.setFailed(`Failed to read Lambda deployment package at /path/to/function.zip: ${error.message}`); - core.error(`File not found. Ensure the code artifacts directory is correct.`); - } else { - core.setFailed(`Failed to update function code: ${error.message}`); - } - if (error.stack) { - core.debug(error.stack); - } - return; - } - } - } catch (error) { - if (error.name === 'ThrottlingException' || error.name === 'TooManyRequestsException' || error.$metadata?.httpStatusCode === 429) { - core.setFailed(`Rate limit exceeded and maximum retries reached: ${error.message}`); - } else if (error.$metadata?.httpStatusCode >= 500) { - core.setFailed(`Server error (${error.$metadata?.httpStatusCode}): ${error.message}. All retry attempts failed.`); - } else if (error.name === 'AccessDeniedException') { - core.setFailed(`Action failed with error: Permissions error: ${error.message}. Check IAM roles.`); - } else { - core.setFailed(`Action failed with error: ${error.message}`); - } - if (error.stack) { - core.debug(error.stack); - } - } - } -}; - -describe('Error Handling Tests', () => { - beforeEach(() => { - jest.clearAllMocks(); - - process.cwd = jest.fn().mockReturnValue('/mock/cwd'); - - path.join.mockImplementation((...parts) => parts.join('/')); - path.resolve.mockImplementation((...parts) => parts.join('/')); - path.isAbsolute.mockImplementation((p) => p && p.startsWith('/')); - path.relative.mockImplementation((from, to) => { - if (from === to) return ''; - if (to.startsWith(from)) return to.substring(from.length).replace(/^\/+/, ''); - return '../' + to; - }); - - core.getInput.mockImplementation((name) => { - const inputs = { - 'function-name': 'test-function', - 'region': 'us-east-1', - 'code-artifacts-dir': '/mock/src', - 'role': 'arn:aws:iam::123456789012:role/lambda-role', - 'runtime': 'nodejs18.x', - 'handler': 'index.handler', - 'memory-size': '256', - 'timeout': '15' - }; - return inputs[name] || ''; - }); - core.getBooleanInput.mockImplementation((name) => { - if (name === 'dry-run') return false; - if (name === 'publish') return true; - return false; - }); - core.info = jest.fn(); - core.warning = jest.fn(); - core.setFailed = jest.fn(); - core.debug = jest.fn(); - core.error = jest.fn(); - core.setOutput = jest.fn(); - - jest.spyOn(validations, 'validateAllInputs').mockReturnValue({ - valid: true, - functionName: 'test-function', - region: 'us-east-1', - codeArtifactsDir: '/mock/src', - role: 'arn:aws:iam::123456789012:role/lambda-role', - runtime: 'nodejs18.x', - handler: 'index.handler', - parsedMemorySize: 256, - timeout: 15, - ephemeralStorage: 512, - packageType: 'Zip', - dryRun: false, - publish: true - }); - - fs.readFile.mockResolvedValue(Buffer.from('mock zip content')); - - jest.spyOn(mainModule, 'waitForFunctionUpdated').mockResolvedValue(undefined); - }); - - describe('Basic Input Validation', () => { - test('should stop execution when inputs are invalid', async () => { - jest.spyOn(validations, 'validateAllInputs').mockReturnValueOnce({ valid: false }); - - await simplifiedIndex.run(); - - expect(LambdaClient.prototype.send).not.toHaveBeenCalled(); - expect(core.setFailed).not.toHaveBeenCalled(); - }); - }); - - describe('AWS Error Classification and Handling', () => { - test('should handle ThrottlingException', async () => { - const throttlingError = new Error('Rate exceeded'); - throttlingError.name = 'ThrottlingException'; - throttlingError.$metadata = { - httpStatusCode: 429, - attempts: 3 - }; - - LambdaClient.prototype.send = jest.fn().mockRejectedValue(throttlingError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Rate limit exceeded and maximum retries reached:') - ); - }); - - test('should handle TooManyRequestsException', async () => { - const tooManyRequestsError = new Error('Too many requests'); - tooManyRequestsError.name = 'TooManyRequestsException'; - tooManyRequestsError.$metadata = { - httpStatusCode: 429, - attempts: 3 - }; - - LambdaClient.prototype.send = jest.fn().mockRejectedValue(tooManyRequestsError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Rate limit exceeded and maximum retries reached:') - ); - }); - - test('should handle server errors (HTTP 5xx)', async () => { - const serverError = new Error('Internal server error'); - serverError.name = 'InternalFailure'; - serverError.$metadata = { - httpStatusCode: 500, - attempts: 3 - }; - - LambdaClient.prototype.send = jest.fn().mockRejectedValue(serverError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Server error (500): Internal server error. All retry attempts failed.') - ); - }); - - test('should handle AccessDeniedException', async () => { - const accessError = new Error('User is not authorized to perform: lambda:GetFunction'); - accessError.name = 'AccessDeniedException'; - accessError.$metadata = { - httpStatusCode: 403, - attempts: 1 - }; - - LambdaClient.prototype.send = jest.fn().mockRejectedValue(accessError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringMatching(/^Action failed with error: Permissions error: User is not authorized/) - ); - }); - - test('should handle generic errors', async () => { - const genericError = new Error('Some unexpected error'); - genericError.name = 'InternalFailure'; - genericError.stack = 'Error stack trace'; - - LambdaClient.prototype.send = jest.fn().mockRejectedValue(genericError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Action failed with error: Some unexpected error' - ); - expect(core.debug).toHaveBeenCalledWith('Error stack trace'); - }); - }); - - describe('Function Creation Error Handling', () => { - test('should handle errors during function creation', async () => { - - const notFoundError = new Error('Function not found'); - notFoundError.name = 'ResourceNotFoundException'; - const creationError = new Error('Error during function creation'); - creationError.stack = 'Creation error stack trace'; - - LambdaClient.prototype.send = jest.fn() - .mockImplementationOnce(() => { throw notFoundError; }) - .mockImplementationOnce(() => { throw creationError; }); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Failed to create function: Error during function creation' - ); - expect(core.debug).toHaveBeenCalledWith('Creation error stack trace'); - }); - test('should handle ThrottlingException during function creation', async () => { - - - const throttlingError = { - name: 'ThrottlingException', - message: 'Rate exceeded', - $metadata: { httpStatusCode: 429 } - }; - - core.setFailed(`Rate limit exceeded and maximum retries reached: ${throttlingError.message}`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Rate limit exceeded and maximum retries reached') - ); - }); - test('should handle AccessDeniedException during function creation', async () => { - - const accessError = { - name: 'AccessDeniedException', - message: 'User not authorized', - $metadata: { httpStatusCode: 403 } - }; - - core.setFailed(`Action failed with error: Permissions error: ${accessError.message}. Check IAM roles.`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Permissions error') - ); - }); - test('should handle ServerErrors during function creation', async () => { - - const serverError = { - name: 'InternalServerError', - message: 'Server error occurred', - $metadata: { httpStatusCode: 500 } - }; - - core.setFailed(`Server error (${serverError.$metadata.httpStatusCode}): ${serverError.message}. All retry attempts failed.`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Server error (500)') - ); - }); - test('should handle general error during function creation', async () => { - - const validationError = { - name: 'ValidationError', - message: 'Bad request parameters', - stack: 'Mock error stack trace' - }; - - core.setFailed(`Failed to create function: ${validationError.message}`); - core.debug(validationError.stack); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Failed to create function') - ); - expect(core.debug).toHaveBeenCalledWith('Mock error stack trace'); - }); - }); - - describe('Configuration Update Error Handling', () => { - test('should handle errors during function configuration update', async () => { - - const configUpdateError = new Error('Error updating function configuration'); - configUpdateError.stack = 'Config update error stack trace'; - - LambdaClient.prototype.send = jest.fn() - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => { throw configUpdateError; }); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Failed to update function configuration: Error updating function configuration' - ); - expect(core.debug).toHaveBeenCalledWith('Config update error stack trace'); - }); - test('should handle ThrottlingException during config update', async () => { - - const throttlingError = { - name: 'ThrottlingException', - message: 'Rate exceeded', - $metadata: { httpStatusCode: 429 }, - stack: 'Mock error stack trace' - }; - - core.setFailed(`Rate limit exceeded and maximum retries reached: ${throttlingError.message}`); - core.debug(throttlingError.stack); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Rate limit exceeded and maximum retries reached') - ); - expect(core.debug).toHaveBeenCalledWith('Mock error stack trace'); - }); - test('should handle AccessDeniedException during config update', async () => { - - const accessError = { - name: 'AccessDeniedException', - message: 'User not authorized', - stack: 'Mock error stack trace' - }; - - core.setFailed(`Action failed with error: Permissions error: ${accessError.message}. Check IAM roles.`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Permissions error') - ); - }); - test('should handle server errors during config update', async () => { - - const serverError = { - name: 'InternalError', - message: 'Server error', - $metadata: { httpStatusCode: 500 }, - stack: 'Mock error stack trace' - }; - - core.setFailed(`Server error (${serverError.$metadata.httpStatusCode}): ${serverError.message}. All retry attempts failed.`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Server error (500)') - ); - }); - }); - - describe('Function Code Update Error Handling', () => { - test('should handle errors during function code update', async () => { - - const codeUpdateError = new Error('Error updating function code'); - codeUpdateError.stack = 'Code update error stack trace'; - - LambdaClient.prototype.send = jest.fn() - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => { throw codeUpdateError; }); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Failed to update function code: Error updating function code' - ); - expect(core.debug).toHaveBeenCalledWith('Code update error stack trace'); - }); - test('should handle file read errors when updating function code', async () => { - - const fileReadError = new Error('No such file or directory'); - fileReadError.code = 'ENOENT'; - fileReadError.stack = 'File error stack trace'; - - LambdaClient.prototype.send = jest.fn() - .mockImplementationOnce(() => ({})) - .mockImplementationOnce(() => ({})); - - fs.readFile.mockRejectedValueOnce(fileReadError); - - await simplifiedIndex.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Failed to read Lambda deployment package') - ); - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining('File not found.') - ); - expect(core.debug).toHaveBeenCalledWith('File error stack trace'); - }); - test('should handle file read errors during zip file preparation', async () => { - - const fileReadError = { - code: 'ENOENT', - message: 'File not found', - stack: 'Mock error stack trace' - }; - - core.setFailed(`Failed to read Lambda deployment package at /path/to/file.zip: ${fileReadError.message}`); - core.error('File not found. Ensure the code artifacts directory is correct.'); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Failed to read Lambda deployment package') - ); - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining('File not found') - ); - }); - test('should handle permission errors when reading zip file', async () => { - - const permissionError = { - code: 'EACCES', - message: 'Permission denied', - stack: 'Mock error stack trace' - }; - - core.setFailed(`Failed to read Lambda deployment package at /path/to/file.zip: ${permissionError.message}`); - core.error('Permission denied. Check file access permissions.'); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Failed to read Lambda deployment package') - ); - expect(core.error).toHaveBeenCalledWith( - expect.stringContaining('Permission denied') - ); - }); - test('should handle AWS errors during code update', async () => { - - const codeUpdateError = { - name: 'ServiceException', - message: 'Code size too large', - stack: 'Mock error stack trace' - }; - - core.setFailed(`Failed to update function code: ${codeUpdateError.message}`); - - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('Failed to update function code') - ); - }); - }); - - describe('waitForFunctionUpdated error handling', () => { - test('should handle timeout during function update wait', async () => { - - mainModule.waitForFunctionUpdated.mockRestore(); - - waitUntilFunctionUpdated.mockRejectedValue({ - name: 'TimeoutError', - message: 'Timed out waiting for function update' - }); - - LambdaClient.prototype.send = jest.fn().mockImplementation((command) => { - if (command instanceof GetFunctionConfigurationCommand) { - return Promise.resolve({ - FunctionName: 'test-function', - Runtime: 'nodejs14.x' - }); - } else if (command instanceof UpdateFunctionConfigurationCommand) { - return Promise.resolve({ - FunctionName: 'test-function', - Runtime: 'nodejs18.x' - }); - } - return Promise.resolve({}); - }); - jest.spyOn(mainModule, 'hasConfigurationChanged').mockResolvedValue(true); - await expect(mainModule.waitForFunctionUpdated(new LambdaClient(), 'test-function')).rejects.toThrow( - 'Timed out waiting for function test-function update' - ); - }); - test('should handle resource not found error during function update wait', async () => { - - mainModule.waitForFunctionUpdated.mockRestore(); - - waitUntilFunctionUpdated.mockRejectedValue({ - name: 'ResourceNotFoundException', - message: 'Function not found' - }); - await expect(mainModule.waitForFunctionUpdated(new LambdaClient(), 'nonexistent-function')).rejects.toThrow( - 'Function nonexistent-function not found' - ); - }); - test('should handle permission error during function update wait', async () => { - - mainModule.waitForFunctionUpdated.mockRestore(); - - waitUntilFunctionUpdated.mockRejectedValue({ - $metadata: { httpStatusCode: 403 }, - message: 'Permission denied' - }); - await expect(mainModule.waitForFunctionUpdated(new LambdaClient(), 'test-function')).rejects.toThrow( - 'Permission denied while checking function test-function status' - ); - }); - test('should handle general errors during function update wait', async () => { - - mainModule.waitForFunctionUpdated.mockRestore(); - - waitUntilFunctionUpdated.mockRejectedValue({ - name: 'GenericError', - message: 'Something went wrong' - }); - await expect(mainModule.waitForFunctionUpdated(new LambdaClient(), 'test-function')).rejects.toThrow( - 'Error waiting for function test-function update: Something went wrong' - ); - }); - }); - - describe('packageCodeArtifacts error handling', () => { - test('should handle empty directory error', async () => { - - jest.spyOn(mainModule, 'packageCodeArtifacts').mockImplementation(() => { - return Promise.reject(new Error('Code artifacts directory \'/empty/dir\' is empty, no files to package')); - }); - await expect(mainModule.packageCodeArtifacts('/empty/dir')).rejects.toThrow( - 'Code artifacts directory \'/empty/dir\' is empty, no files to package' - ); - - mainModule.packageCodeArtifacts.mockRestore(); - }); - test('should handle directory access errors', async () => { - - jest.spyOn(mainModule, 'packageCodeArtifacts').mockImplementation(() => { - return Promise.reject(new Error('Code artifacts directory \'/invalid/dir\' does not exist or is not accessible: Directory does not exist')); - }); - await expect(mainModule.packageCodeArtifacts('/invalid/dir')).rejects.toThrow( - 'Code artifacts directory \'/invalid/dir\' does not exist or is not accessible' - ); - - mainModule.packageCodeArtifacts.mockRestore(); - }); - test('should handle ZIP validation failures', async () => { - - - jest.spyOn(mainModule, 'packageCodeArtifacts').mockImplementation(() => { - return Promise.reject(new Error('ZIP validation failed: ZIP file corrupt')); - }); - await expect(mainModule.packageCodeArtifacts('/mock/src')).rejects.toThrow( - 'ZIP validation failed: ZIP file corrupt' - ); - - mainModule.packageCodeArtifacts.mockRestore(); - }); - }); - - describe('deepEqual function', () => { - test('should correctly compare null values', () => { - expect(mainModule.deepEqual(null, null)).toBe(true); - expect(mainModule.deepEqual(null, {})).toBe(false); - expect(mainModule.deepEqual({}, null)).toBe(false); - }); - test('should correctly compare arrays of different lengths', () => { - expect(mainModule.deepEqual([1, 2, 3], [1, 2])).toBe(false); - expect(mainModule.deepEqual([1, 2], [1, 2, 3])).toBe(false); - }); - test('should correctly identify array vs non-array differences', () => { - expect(mainModule.deepEqual([1, 2], { '0': 1, '1': 2 })).toBe(false); - expect(mainModule.deepEqual({ '0': 1, '1': 2 }, [1, 2])).toBe(false); - }); - test('should correctly compare objects with different keys', () => { - expect(mainModule.deepEqual({ a: 1, b: 2 }, { a: 1, c: 3 })).toBe(false); - expect(mainModule.deepEqual({ a: 1, b: 2 }, { a: 1 })).toBe(false); - }); - }); - - describe('DryRun mode tests', () => { - test('should handle dry run for new function error', async () => { - - jest.spyOn(validations, 'validateAllInputs').mockReturnValue({ - valid: true, - functionName: 'test-function', - region: 'us-east-1', - codeArtifactsDir: '/mock/src', - dryRun: true - }); - - LambdaClient.prototype.send = jest.fn().mockImplementation((command) => { - if (command instanceof GetFunctionConfigurationCommand) { - return Promise.reject({ - name: 'ResourceNotFoundException', - message: 'Function not found' - }); - } - return Promise.resolve({}); - }); - await mainModule.run(); - expect(core.setFailed).toHaveBeenCalledWith( - expect.stringContaining('DRY RUN MODE can only be used for updating function code of existing functions') - ); - }); - test('should handle dry run mode for existing functions', async () => { - - jest.spyOn(validations, 'validateAllInputs').mockReturnValue({ - valid: true, - functionName: 'test-function', - region: 'us-east-1', - codeArtifactsDir: '/mock/src', - dryRun: true, - runtime: 'nodejs18.x', - handler: 'index.handler' - }); - - const mockUpdateCodeResponse = { - FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', - Version: '$LATEST' - }; - - LambdaClient.prototype.send = jest.fn().mockImplementation((command) => { - if (command instanceof GetFunctionConfigurationCommand) { - return Promise.resolve({ - FunctionName: 'test-function', - Runtime: 'nodejs18.x' - }); - } else if (command instanceof UpdateFunctionCodeCommand) { - - expect(command.input).toHaveProperty('DryRun', true); - return Promise.resolve(mockUpdateCodeResponse); - } - return Promise.resolve({}); - }); - - jest.spyOn(mainModule, 'hasConfigurationChanged').mockResolvedValue(false); - - fs.readFile.mockResolvedValue(Buffer.from('mock file content')); - - await mainModule.run(); - - expect(core.info).toHaveBeenCalledWith('DRY RUN MODE: No AWS resources will be created or modified'); - }); - }); - - describe('S3 function creation', () => { - - test('should handle S3 upload error', async () => { - - const errorMessage = 'Failed to upload package to S3: Access denied to S3 bucket'; - - core.setFailed(errorMessage); - core.debug('S3 error stack trace'); - - expect(core.setFailed).toHaveBeenCalledWith(errorMessage); - expect(core.debug).toHaveBeenCalledWith('S3 error stack trace'); - }); - - test('should handle nonexistent S3 bucket error', async () => { - - const errorMessage = 'Failed to create bucket my-lambda-bucket: BucketAlreadyExists'; - - core.error(errorMessage); - core.debug('Bucket error stack trace'); - - expect(core.error).toHaveBeenCalledWith(errorMessage); - expect(core.debug).toHaveBeenCalledWith('Bucket error stack trace'); - }); - - test('should handle S3 file read errors', async () => { - - const failedMessage = 'Failed to read Lambda deployment package: Permission denied'; - const errorMessage = 'Permission denied. Check file access permissions.'; - - core.setFailed(failedMessage); - core.error(errorMessage); - core.debug('File error stack trace'); - - expect(core.setFailed).toHaveBeenCalledWith(failedMessage); - expect(core.error).toHaveBeenCalledWith(errorMessage); - expect(core.debug).toHaveBeenCalledWith('File error stack trace'); - }); - - test('should successfully create function with S3 method', async () => { - - const createResponse = { - FunctionName: 'test-function', - FunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', - Version: '$LATEST' - }; - - core.setOutput('function-arn', createResponse.FunctionArn); - core.setOutput('version', createResponse.Version); - - expect(core.setOutput).toHaveBeenCalledWith('function-arn', createResponse.FunctionArn); - expect(core.setOutput).toHaveBeenCalledWith('version', createResponse.Version); - - core.info('Lambda function created successfully'); - expect(core.info).toHaveBeenCalledWith('Lambda function created successfully'); - }); - }); -}); diff --git a/deploy-lambda.yml b/deploy-lambda.yml new file mode 100644 index 00000000..32c056ce --- /dev/null +++ b/deploy-lambda.yml @@ -0,0 +1,82 @@ +# This workflow deploys code to an AWS Lambda function using the Lambda GitHub Action. +# It uses OpenID Connect (OIDC) to authenticate with AWS instead of long-term access keys. +# +# To use this workflow, you will need to complete the following set-up steps: +# +# 1. Configure AWS IAM OIDC identity provider for GitHub Actions: +# - Go to the IAM console, navigate to "Identity providers", and create a new provider +# - Use https://token.actions.githubusercontent.com as the provider URL +# - Use sts.amazonaws.com as the audience +# - Complete the wizard to create the provider +# +# 2. Create an IAM role for GitHub Actions: +# - Create a new role with Web Identity as the trusted entity +# - Select the OIDC provider you created above +# - For "Audience", enter "sts.amazonaws.com" +# - Add a condition to limit the role to your repository: +# token.actions.githubusercontent.com:sub: repo:your-org/your-repo:* +# - Attach policies for Lambda and S3 permissions (Can be found on the README.md) +# +# 3. Replace the value of the required parameters +# - AWS_REGION +# - AWS_ROLE_TO_ASSUME +# - LAMBDA_FUNCTION_NAME +# - LAMBDA_CODE_ARTIFACTS_DIR +# - LAMBDA_HANDLER +# - LAMBDA_RUNTIME +# +# 4. Add any additional parameters under the environment variable section and Deploy Lambda Function step. +# +# 5. Install dependencies and build your source code locally or add a step to do so in the workflow. +# name: Install dependencies and build Lambda function +# run: | +# npm ci +# npm run build + + + +name: Deploy to AWS Lambda + +on: + push: + branches: [ "main" ] + +env: + AWS_REGION: MY_AWS_REGION # set this to your AWS region + AWS_ROLE_TO_ASSUME: MY_ROLE_TO_ASSUME # set this to your IAM role ARN + LAMBDA_FUNCTION_NAME: MY_FUNCTION_NAME # set this to your Lambda function name + LAMBDA_CODE_ARTIFACTS_DIR: MY_CODE_ARTIFACTS_DIR # set this to the directory containing your Lambda code + LAMBDA_HANDLER: MY_LAMBDA_HANDLER # set this to your Lambda handler + LAMBDA_RUNTIME: MY_LAMBDA_RUNTIME # set this to your Lambda runtime + # Include additional parameters as needed (Format at LAMBDA_PARAMETER) + +permissions: + id-token: write # This is required for OIDC authentication + contents: read # This is required to checkout the repository + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v3 + with: + role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws-region: ${{ env.AWS_REGION }} + # The role-to-assume should be the ARN of the IAM role you created for GitHub Actions OIDC + + - name: Deploy Lambda Function + id: aws-actions/amazon-lambda-deploy@v1 + uses: ./ # Uses the action in the root directory of your repository + with: + function-name: ${{ env.LAMBDA_FUNCTION_NAME }} + code-artifacts-dir: ${{ env.CODE_ARTIFACTS_DIR }} + handler: ${{ env.LAMBDA_HANDLER }} + runtime: ${{ env.LAMBDA_RUNTIME }} + # Add any additional inputs your action supports \ No newline at end of file From b311cfd13ed9b37abddda5d18950180893f0ac47 Mon Sep 17 00:00:00 2001 From: "wuychloe@amazon.com chloe1818" Date: Wed, 30 Jul 2025 16:01:07 -0700 Subject: [PATCH 2/5] Edited workflow file to use GitHub action and edited unit tests. --- {__tests__ => __tests__}/code_artifacts.test.js | 0 {__tests__ => __tests__}/deep_equal.test.js | 0 {__tests__ => __tests__}/dry_run_mode.test.js | 0 {__tests__ => __tests__}/error_handling.test.js | 0 {__tests__ => __tests__}/file_permission_error.test.js | 0 {__tests__ => __tests__}/function_create.test.js | 0 {__tests__ => __tests__}/has_configuration_changed.test.js | 0 {__tests__ => __tests__}/package_artifacts.test.js | 0 {__tests__ => __tests__}/s3_bucket_operations.test.js | 0 {__tests__ => __tests__}/s3_key_generation.test.js | 0 {__tests__ => __tests__}/update_function_code.test.js | 0 {__tests__ => __tests__}/update_function_config.test.js | 0 {__tests__ => __tests__}/validations.test.js | 0 {__tests__ => __tests__}/wait_for_function_active.test.js | 0 {__tests__ => __tests__}/wait_for_function_updated.test.js | 0 deploy-lambda.yml => deploy-lambda-example.yml | 6 ++---- 16 files changed, 2 insertions(+), 4 deletions(-) rename {__tests__ => __tests__}/code_artifacts.test.js (100%) rename {__tests__ => __tests__}/deep_equal.test.js (100%) rename {__tests__ => __tests__}/dry_run_mode.test.js (100%) rename {__tests__ => __tests__}/error_handling.test.js (100%) rename {__tests__ => __tests__}/file_permission_error.test.js (100%) rename {__tests__ => __tests__}/function_create.test.js (100%) rename {__tests__ => __tests__}/has_configuration_changed.test.js (100%) rename {__tests__ => __tests__}/package_artifacts.test.js (100%) rename {__tests__ => __tests__}/s3_bucket_operations.test.js (100%) rename {__tests__ => __tests__}/s3_key_generation.test.js (100%) rename {__tests__ => __tests__}/update_function_code.test.js (100%) rename {__tests__ => __tests__}/update_function_config.test.js (100%) rename {__tests__ => __tests__}/validations.test.js (100%) rename {__tests__ => __tests__}/wait_for_function_active.test.js (100%) rename {__tests__ => __tests__}/wait_for_function_updated.test.js (100%) rename deploy-lambda.yml => deploy-lambda-example.yml (95%) diff --git a/__tests__ /code_artifacts.test.js b/__tests__/code_artifacts.test.js similarity index 100% rename from __tests__ /code_artifacts.test.js rename to __tests__/code_artifacts.test.js diff --git a/__tests__ /deep_equal.test.js b/__tests__/deep_equal.test.js similarity index 100% rename from __tests__ /deep_equal.test.js rename to __tests__/deep_equal.test.js diff --git a/__tests__ /dry_run_mode.test.js b/__tests__/dry_run_mode.test.js similarity index 100% rename from __tests__ /dry_run_mode.test.js rename to __tests__/dry_run_mode.test.js diff --git a/__tests__ /error_handling.test.js b/__tests__/error_handling.test.js similarity index 100% rename from __tests__ /error_handling.test.js rename to __tests__/error_handling.test.js diff --git a/__tests__ /file_permission_error.test.js b/__tests__/file_permission_error.test.js similarity index 100% rename from __tests__ /file_permission_error.test.js rename to __tests__/file_permission_error.test.js diff --git a/__tests__ /function_create.test.js b/__tests__/function_create.test.js similarity index 100% rename from __tests__ /function_create.test.js rename to __tests__/function_create.test.js diff --git a/__tests__ /has_configuration_changed.test.js b/__tests__/has_configuration_changed.test.js similarity index 100% rename from __tests__ /has_configuration_changed.test.js rename to __tests__/has_configuration_changed.test.js diff --git a/__tests__ /package_artifacts.test.js b/__tests__/package_artifacts.test.js similarity index 100% rename from __tests__ /package_artifacts.test.js rename to __tests__/package_artifacts.test.js diff --git a/__tests__ /s3_bucket_operations.test.js b/__tests__/s3_bucket_operations.test.js similarity index 100% rename from __tests__ /s3_bucket_operations.test.js rename to __tests__/s3_bucket_operations.test.js diff --git a/__tests__ /s3_key_generation.test.js b/__tests__/s3_key_generation.test.js similarity index 100% rename from __tests__ /s3_key_generation.test.js rename to __tests__/s3_key_generation.test.js diff --git a/__tests__ /update_function_code.test.js b/__tests__/update_function_code.test.js similarity index 100% rename from __tests__ /update_function_code.test.js rename to __tests__/update_function_code.test.js diff --git a/__tests__ /update_function_config.test.js b/__tests__/update_function_config.test.js similarity index 100% rename from __tests__ /update_function_config.test.js rename to __tests__/update_function_config.test.js diff --git a/__tests__ /validations.test.js b/__tests__/validations.test.js similarity index 100% rename from __tests__ /validations.test.js rename to __tests__/validations.test.js diff --git a/__tests__ /wait_for_function_active.test.js b/__tests__/wait_for_function_active.test.js similarity index 100% rename from __tests__ /wait_for_function_active.test.js rename to __tests__/wait_for_function_active.test.js diff --git a/__tests__ /wait_for_function_updated.test.js b/__tests__/wait_for_function_updated.test.js similarity index 100% rename from __tests__ /wait_for_function_updated.test.js rename to __tests__/wait_for_function_updated.test.js diff --git a/deploy-lambda.yml b/deploy-lambda-example.yml similarity index 95% rename from deploy-lambda.yml rename to deploy-lambda-example.yml index 32c056ce..80a7e8cf 100644 --- a/deploy-lambda.yml +++ b/deploy-lambda-example.yml @@ -27,14 +27,13 @@ # # 4. Add any additional parameters under the environment variable section and Deploy Lambda Function step. # -# 5. Install dependencies and build your source code locally or add a step to do so in the workflow. +# 5. Install dependencies and build your source code locally or add a step to do so in the workflow. Below is an example in javascript. # name: Install dependencies and build Lambda function # run: | # npm ci # npm run build - name: Deploy to AWS Lambda on: @@ -72,8 +71,7 @@ jobs: # The role-to-assume should be the ARN of the IAM role you created for GitHub Actions OIDC - name: Deploy Lambda Function - id: aws-actions/amazon-lambda-deploy@v1 - uses: ./ # Uses the action in the root directory of your repository + uses: aws-actions/aws-lambda-deploy@v1 with: function-name: ${{ env.LAMBDA_FUNCTION_NAME }} code-artifacts-dir: ${{ env.CODE_ARTIFACTS_DIR }} From 33176a7c281f090d41840db7fcc18d63f9cc1a57 Mon Sep 17 00:00:00 2001 From: "wuychloe@amazon.com chloe1818" Date: Thu, 31 Jul 2025 10:21:10 -0700 Subject: [PATCH 3/5] Increased unit test coverage --- __tests__/code_artifacts.test.js | 40 ++++++++++++++++++++++++++ __tests__/dry_run_mode.test.js | 2 +- __tests__/s3_bucket_operations.test.js | 21 +++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/__tests__/code_artifacts.test.js b/__tests__/code_artifacts.test.js index b78df1f2..8acd5598 100644 --- a/__tests__/code_artifacts.test.js +++ b/__tests__/code_artifacts.test.js @@ -1,9 +1,16 @@ const { packageCodeArtifacts } = require('../index'); +const fs = require('fs/promises'); +const AdmZip = require('adm-zip'); +const path = require('path'); +const os = require('os'); +const validations = require('../validations'); jest.mock('@actions/core'); jest.mock('fs/promises'); jest.mock('adm-zip'); jest.mock('path'); +jest.mock('os'); +jest.mock('../validations'); describe('Code Artifacts Tests', () => { test('should throw error when artifactsDir is not provided', async () => { @@ -17,4 +24,37 @@ describe('Code Artifacts Tests', () => { test('should throw error when artifactsDir is empty string', async () => { await expect(packageCodeArtifacts('')).rejects.toThrow('Code artifacts directory path must be provided'); }); + + test('should throw ZIP validation error when stat fails', async () => { + const mockTimestamp = 1234567890; + global.Date.now = jest.fn().mockReturnValue(mockTimestamp); + + os.tmpdir = jest.fn().mockReturnValue('/mock/tmp'); + path.join.mockImplementation((...parts) => parts.join('/')); + + fs.rm.mockResolvedValue(undefined); + fs.mkdir.mockResolvedValue(undefined); + fs.access.mockResolvedValue(undefined); + fs.readdir.mockResolvedValue(['file1.js']); + fs.cp.mockResolvedValue(undefined); + + const mockZipInstance = { + addLocalFile: jest.fn(), + writeZip: jest.fn() + }; + AdmZip.mockImplementation(() => mockZipInstance); + + fs.readdir.mockImplementation((dir, options) => { + if (options && options.withFileTypes) { + return Promise.resolve([{ name: 'file1.js', isDirectory: () => false }]); + } + return Promise.resolve(['file1.js']); + }); + + fs.stat.mockRejectedValue(new Error('Stat failed')); + + validations.validateAndResolvePath = jest.fn().mockReturnValue('/resolved/path'); + + await expect(packageCodeArtifacts('/mock/artifacts')).rejects.toThrow('ZIP validation failed: Stat failed'); + }); }); \ No newline at end of file diff --git a/__tests__/dry_run_mode.test.js b/__tests__/dry_run_mode.test.js index 5a4e5303..5d72b197 100644 --- a/__tests__/dry_run_mode.test.js +++ b/__tests__/dry_run_mode.test.js @@ -14,7 +14,7 @@ jest.mock('path'); describe('Dry Run Mode Tests', () => { beforeEach(() => { jest.clearAllMocks(); - process.env.AWS_REGION = 'us-east-1'; + process.env.WS_REGION = 'us-east-1'; }); test('should skip configuration updates in dry run mode when config changed', async () => { diff --git a/__tests__/s3_bucket_operations.test.js b/__tests__/s3_bucket_operations.test.js index e5f0c08f..3d8868e7 100644 --- a/__tests__/s3_bucket_operations.test.js +++ b/__tests__/s3_bucket_operations.test.js @@ -504,6 +504,25 @@ describe('S3 Bucket Operations Tests', () => { expect(core.error).toHaveBeenCalledWith('Invalid bucket name: Invalid_Bucket_Name. Bucket names must follow S3 naming rules.'); expect(core.error).toHaveBeenCalledWith('See s3-troubleshooting.md for S3 bucket naming rules.'); }); + + test('should handle S3 upload failure with stack trace in createFunction', async () => { + const uploadError = new Error('S3 upload failed'); + uploadError.stack = 'Error: S3 upload failed\n at uploadToS3'; + + jest.spyOn(mainModule, 'uploadToS3').mockRejectedValue(uploadError); + + const mockClient = { send: jest.fn() }; + const inputs = { + functionName: 'test-function', + finalZipPath: '/path/to/file.zip', + s3Bucket: 'test-bucket', + s3Key: 'test-key.zip', + region: 'us-east-1', + role: 'arn:aws:iam::123456789012:role/test-role' + }; + + await expect(mainModule.createFunction(mockClient, inputs, false)).rejects.toThrow('Cannot read properties of undefined'); + }); }); describe('End-to-End S3 Deployment Flow', () => { @@ -633,7 +652,7 @@ describe('S3 Bucket Operations Tests', () => { const functionName = core.getInput('function-name'); const s3Bucket = core.getInput('s3-bucket'); if (s3Bucket) { - await mainModule.uploadToS3('file.zip', s3Bucket, 'key.zip', 'region', '123456789012'); // Adding expected bucket owner + await mainModule.uploadToS3('file.zip', s3Bucket, 'key.zip', 'region'); } expect(mainModule.uploadToS3).not.toHaveBeenCalled(); }); From bb7e0925de8b1ea57d5f54839d9bf5ae55c4aaf6 Mon Sep 17 00:00:00 2001 From: "wuychloe@amazon.com chloe1818" Date: Thu, 31 Jul 2025 10:23:54 -0700 Subject: [PATCH 4/5] Updated build from source in README.md. --- README.md | 39 +-------------------------------------- 1 file changed, 1 insertion(+), 38 deletions(-) diff --git a/README.md b/README.md index d881c66e..146ba9fe 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The required parameters to deploy are function name, code artifacts directory, h ``` ## Build from Source -To automate building your source code, add the step that corresponds to your runtime: +To automate building your source code, add a build step based on your runtime and build process. Below are two commonly used examples for Node.js and Python: ### Node.js @@ -124,43 +124,6 @@ To automate building your source code, add the step that corresponds to your run python -m build ``` -### Ruby - -```yaml - - name: Build source code using Rake - run: | - # Install dependencies - bundle install - - # Build - bundle exec rake [task_name] -``` - -### Java - -```yaml - - name: Build source code using Maven - run: | - # Install dependencies - mvn dependency:resolve clean install -DskipTests - - # Build - mvn clean package - -``` -### .NET - -```yaml - - name: Build source code - run: | - # Install dependencies - dotnet restore - - # Build - dotnet build - -``` - ## Inputs | Name | Description | Required | Default | From 319493d8d1d4c15bbc683b505eeffca953d2da23 Mon Sep 17 00:00:00 2001 From: "wuychloe@amazon.com chloe1818" Date: Thu, 31 Jul 2025 10:55:28 -0700 Subject: [PATCH 5/5] Add chore prefix to Dependabot's commits and PRs. --- .github/dependabot.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 38303034..057ebace 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,4 +5,6 @@ updates: schedule: interval: weekly day: tuesday - open-pull-requests-limit: 10 \ No newline at end of file + open-pull-requests-limit: 10 + commit-message: + prefix: "chore:" \ No newline at end of file