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 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..146ba9fe 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,105 @@ 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 - -on: - push: - branches: [main, master] - -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 + - name: Deploy on dry run mode + uses: aws-actions/aws-lambda-deploy@v1 with: - function-name: my-lambda-function - code-artifacts-dir: ./dist - memory-size: 512 - timeout: 60 - environment: '{"ENV":"production","DEBUG":"true"}' + function-name: my-function-name + code-artifacts-dir: my-code-artifacts-dir + dry-run: true ``` +## Build from Source -### Dry Run Mode +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 ```yaml -name: Validate Lambda Deployment + - name: Build source code + run: | + # Install dependencies + npm ci -on: - pull_request: - branches: [main, master] + # Build + npm run build +``` +### Python -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 +```yaml + - name: Build source code using setup tools + run: | + # Install dependencies + pip install -r requirement.txt + + # Build + python -m build ``` ## Inputs @@ -187,32 +168,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 index 2dd0bef7..8acd5598 100644 --- a/__tests__/code_artifacts.test.js +++ b/__tests__/code_artifacts.test.js @@ -1,192 +1,60 @@ -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 { packageCodeArtifacts } = require('../index'); const fs = require('fs/promises'); -const path = require('path'); -const { glob } = require('glob'); const AdmZip = require('adm-zip'); -const mainModule = require('../index'); +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', () => { - 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 throw error when artifactsDir is not provided', async () => { + await expect(packageCodeArtifacts()).rejects.toThrow('Code artifacts directory path must be provided'); }); - - 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 throw error when artifactsDir is null', async () => { + await expect(packageCodeArtifacts(null)).rejects.toThrow('Code artifacts directory path must be provided'); }); - - 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 throw error when artifactsDir is empty string', async () => { + await expect(packageCodeArtifacts('')).rejects.toThrow('Code artifacts directory path must be provided'); }); - - 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'); - }); + + test('should throw ZIP validation error when stat fails', async () => { + const mockTimestamp = 1234567890; + global.Date.now = jest.fn().mockReturnValue(mockTimestamp); - await mainModule.run(); + os.tmpdir = jest.fn().mockReturnValue('/mock/tmp'); + path.join.mockImplementation((...parts) => parts.join('/')); - expect(fs.mkdir).not.toHaveBeenCalled(); - expect(glob).not.toHaveBeenCalled(); - expect(core.setFailed).not.toHaveBeenCalled(); - }); - - test('should fail when code-artifacts-dir is missing', async () => { + fs.rm.mockResolvedValue(undefined); + fs.mkdir.mockResolvedValue(undefined); + fs.access.mockResolvedValue(undefined); + fs.readdir.mockResolvedValue(['file1.js']); + fs.cp.mockResolvedValue(undefined); - 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] || ''; - }); + const mockZipInstance = { + addLocalFile: jest.fn(), + writeZip: jest.fn() + }; + AdmZip.mockImplementation(() => mockZipInstance); - mainModule.run.mockImplementationOnce(async () => { - const codeArtifactsDir = core.getInput('code-artifacts-dir'); - - if (!codeArtifactsDir) { - core.setFailed('Code-artifacts-dir must be provided'); - return; + fs.readdir.mockImplementation((dir, options) => { + if (options && options.withFileTypes) { + return Promise.resolve([{ name: 'file1.js', isDirectory: () => false }]); } - - await fs.mkdir('/mock/cwd/lambda-package', { recursive: true }); + return Promise.resolve(['file1.js']); }); - - await mainModule.run(); - - expect(core.setFailed).toHaveBeenCalledWith( - 'Code-artifacts-dir must be provided' - ); - expect(fs.mkdir).not.toHaveBeenCalled(); - expect(glob).not.toHaveBeenCalled(); + 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 6f604fc0..5d72b197 100644 --- a/__tests__/dry_run_mode.test.js +++ b/__tests__/dry_run_mode.test.js @@ -1,111 +1,41 @@ -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'); +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', () => { - 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' - ); + process.env.WS_REGION = 'us-east-1'; }); - - 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'); + + 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 index 36f5be28..cbe6bbe3 100644 --- a/__tests__/error_handling.test.js +++ b/__tests__/error_handling.test.js @@ -1,797 +1,150 @@ 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 { 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', () => { - 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('@aws-sdk/client-s3'); +jest.mock('@aws-sdk/client-sts'); +jest.mock('fs/promises'); +jest.mock('adm-zip'); 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', () => { +describe('Deployment Completion 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; - }); + process.env.AWS_REGION = 'us-east-1'; - core.getInput.mockImplementation((name) => { + core.getInput = jest.fn().mockImplementation((name) => { const inputs = { 'function-name': 'test-function', 'region': 'us-east-1', - 'code-artifacts-dir': '/mock/src', + 'code-artifacts-dir': '/mock/artifacts', 'role': 'arn:aws:iam::123456789012:role/lambda-role', 'runtime': 'nodejs18.x', - 'handler': 'index.handler', - 'memory-size': '256', - 'timeout': '15' + 'handler': 'index.handler' }; 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({ + // Mock validations + validations.validateAllInputs = jest.fn().mockReturnValue({ valid: true, functionName: 'test-function', - region: 'us-east-1', - codeArtifactsDir: '/mock/src', + codeArtifactsDir: '/mock/artifacts', role: 'arn:aws:iam::123456789012:role/lambda-role', runtime: 'nodejs18.x', handler: 'index.handler', - parsedMemorySize: 256, - timeout: 15, - ephemeralStorage: 512, - packageType: 'Zip', + 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); - fs.readFile.mockResolvedValue(Buffer.from('mock zip content')); + 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(); - jest.spyOn(mainModule, 'waitForFunctionUpdated').mockResolvedValue(undefined); + await mainModule.run(); }); - - 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 ThrottlingException error', async () => { + const error = new Error('Rate limit exceeded'); + error.name = 'ThrottlingException'; - 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.') - ); + validations.validateAllInputs.mockImplementation(() => { + throw error; }); - 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/) - ); - }); + await mainModule.run(); - 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'); - }); + expect(core.setFailed).toHaveBeenCalledWith('Rate limit exceeded and maximum retries reached: Rate limit exceeded'); }); - - 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' - ); + + 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'); }); - - 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(); + + 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'); }); - - 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); + + 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.'); }); - - 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'); + + 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.'); }); - - describe('S3 function creation', () => { + + test('should handle generic error', async () => { + const error = new Error('Generic error'); - 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'); + validations.validateAllInputs.mockImplementation(() => { + throw error; }); - 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'); - }); + await mainModule.run(); - 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'); - }); + 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'; - 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'); + 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__/package_artifacts.test.js b/__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 index 19215cc5..3d8868e7 100644 --- a/__tests__/s3_bucket_operations.test.js +++ b/__tests__/s3_bucket_operations.test.js @@ -387,6 +387,144 @@ 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.'); + }); + + 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', () => { let originalRun; beforeAll(() => { @@ -514,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(); }); 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__/validations.test.js b/__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/deploy-lambda-example.yml b/deploy-lambda-example.yml new file mode 100644 index 00000000..80a7e8cf --- /dev/null +++ b/deploy-lambda-example.yml @@ -0,0 +1,80 @@ +# 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. 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: + 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 + uses: aws-actions/aws-lambda-deploy@v1 + 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