Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ This enables managed platform updates and configures Elastic Beanstalk to automa
| `create-s3-bucket-if-not-exists` | Create S3 bucket if it doesn't exist | `true` |
| `s3-bucket-name` | Custom S3 bucket name for deployment packages | `elasticbeanstalk-{region}-{accountId}` |
| `exclude-patterns` | Comma-separated glob patterns to exclude from auto-created packages | None |
| `verbose-logging` | Opt in to include infrastructure identifiers (region, account ID, environment ID, version label, URL) in logs and outputs. When false, general deployment progress is still visible. | `false` |

## Outputs

Expand Down
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ inputs:
option-settings:
description: 'Elastic Beanstalk option settings as JSON array. Required when creating a new environment - MUST include IAM settings: [{"Namespace":"aws:autoscaling:launchconfiguration","OptionName":"IamInstanceProfile","Value":"your-instance-profile"},{"Namespace":"aws:elasticbeanstalk:environment","OptionName":"ServiceRole","Value":"your-service-role"}]'
required: false
verbose-logging:
description: 'Enable verbose logging of deployment details such as region, account ID, environment ID, version label, and environment URL. When set to false, operational progress is still logged but identifying infrastructure details are omitted from logs and outputs.'
required: false
default: 'false'

outputs:
environment-url:
Expand Down
230 changes: 200 additions & 30 deletions src/__tests__/main.test.ts

Large diffs are not rendered by default.

61 changes: 44 additions & 17 deletions src/__tests__/s3_operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,12 @@ jest.mock('@aws-sdk/client-sts', () => ({
import * as fs from 'fs';
import { uploadToS3, createS3Bucket } from '../aws-operations';
import { AWSClients } from '../aws-clients';
import { DeploymentContext } from '../logging';

const mockedFs = fs as jest.Mocked<typeof fs>;

const defaultCtx: DeploymentContext = { verboseLogging: true, maxRetries: 3, retryDelay: 1 };

describe('S3 Operations', () => {
let mockClients: AWSClients;

Expand All @@ -80,7 +83,7 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockResolvedValueOnce({}); // PutObject

const result = await uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', 3, 1, false);
const result = await uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', false, defaultCtx);

expect(result).toEqual({
bucket: 'elasticbeanstalk-us-east-1-123456789012',
Expand All @@ -95,7 +98,7 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockResolvedValueOnce({}); // PutObject

const result = await uploadToS3(mockClients, 'us-west-2', '987654321098', 'app', 'abc123', 'deploy.jar', 3, 1, false);
const result = await uploadToS3(mockClients, 'us-west-2', '987654321098', 'app', 'abc123', 'deploy.jar', false, defaultCtx);

expect(result).toEqual({
bucket: 'elasticbeanstalk-us-west-2-987654321098',
Expand All @@ -109,7 +112,7 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockResolvedValueOnce({}); // PutObject

const result = await uploadToS3(mockClients, 'eu-west-1', '111222333444', 'test-app', 'v2.0.0', 'package.zip', 3, 1, false);
const result = await uploadToS3(mockClients, 'eu-west-1', '111222333444', 'test-app', 'v2.0.0', 'package.zip', false, defaultCtx);

expect(result.bucket).toBe('elasticbeanstalk-eu-west-1-111222333444');
});
Expand All @@ -119,7 +122,7 @@ describe('S3 Operations', () => {
it('should not create bucket if it already exists', async () => {
mockSend.mockResolvedValueOnce({}); // HeadBucket with ExpectedBucketOwner succeeds

await createS3Bucket(mockClients, 'us-east-1', 'existing-bucket', '123456789012', 3, 1);
await createS3Bucket(mockClients, 'us-east-1', 'existing-bucket', '123456789012', defaultCtx);

expect(mockSend).toHaveBeenCalledTimes(1); // HeadBucket only (ownership verified via ExpectedBucketOwner)
});
Expand All @@ -129,7 +132,7 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucket throws (bucket doesn't exist)
.mockResolvedValueOnce({}); // CreateBucket succeeds

await createS3Bucket(mockClients, 'us-east-1', 'new-bucket', '123456789012', 3, 1);
await createS3Bucket(mockClients, 'us-east-1', 'new-bucket', '123456789012', defaultCtx);

expect(mockSend).toHaveBeenCalledTimes(2); // HeadBucket + CreateBucket
});
Expand All @@ -139,7 +142,7 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucket throws (bucket doesn't exist)
.mockResolvedValueOnce({}); // CreateBucket with LocationConstraint succeeds

await createS3Bucket(mockClients, 'eu-central-1', 'euro-bucket', '123456789012', 3, 1);
await createS3Bucket(mockClients, 'eu-central-1', 'euro-bucket', '123456789012', defaultCtx);

expect(mockSend).toHaveBeenCalledTimes(2); // HeadBucket + CreateBucket
});
Expand All @@ -150,7 +153,7 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(new Error('NetworkError')) // CreateBucket attempt 1 fails
.mockResolvedValueOnce({}); // CreateBucket attempt 2 succeeds

await createS3Bucket(mockClients, 'us-west-2', 'retry-bucket', '123456789012', 3, 1);
await createS3Bucket(mockClients, 'us-west-2', 'retry-bucket', '123456789012', defaultCtx);

expect(mockSend).toHaveBeenCalledTimes(3); // HeadBucket + 2 CreateBucket attempts
});
Expand All @@ -161,12 +164,33 @@ describe('S3 Operations', () => {

mockSend.mockRejectedValueOnce(forbiddenError); // HeadBucket returns 403

await expect(createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', 3, 1))
await expect(createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', defaultCtx))
.rejects.toThrow("S3 bucket 'someone-elses-bucket' exists but is not owned by this AWS account");

expect(mockSend).toHaveBeenCalledTimes(1); // HeadBucket only, no create attempt
});

it('should redact bucket name and account ID in 403 error when verbose-logging is false', async () => {
const forbiddenError = new Error('Forbidden');
(forbiddenError as any).$metadata = { httpStatusCode: 403 };

mockSend.mockRejectedValueOnce(forbiddenError); // HeadBucket returns 403

const quietCtx: DeploymentContext = { verboseLogging: false, maxRetries: 3, retryDelay: 1 };
try {
await createS3Bucket(mockClients, 'us-east-1', 'someone-elses-bucket', '123456789012', quietCtx);
fail('Expected error to be thrown');
} catch (error) {
const message = (error as Error).message;
// Should NOT contain bucket name or account ID
expect(message).not.toContain('someone-elses-bucket');
expect(message).not.toContain('123456789012');
// Should still contain actionable guidance
expect(message).toContain('S3 bucket exists but is not owned by this AWS account');
expect(message).toContain('s3-bucket-name');
}
});

it('should bubble up AccessDenied permissions error without retrying', async () => {
const accessDeniedError = new Error('Access Denied');
accessDeniedError.name = 'AccessDenied';
Expand All @@ -175,7 +199,7 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(new Error('NoSuchBucket')) // HeadBucketCommand throws error (bucket doesn't exist)
.mockRejectedValueOnce(accessDeniedError); // First CreateBucketCommand fails with AccessDenied

await expect(createS3Bucket(mockClients, 'us-east-1', 'permission-denied-bucket', '123456789012', 3, 1))
await expect(createS3Bucket(mockClients, 'us-east-1', 'permission-denied-bucket', '123456789012', defaultCtx))
.rejects.toThrow('Access Denied');

expect(mockSend).toHaveBeenCalledTimes(2); // 1 HeadBucket + 1 CreateBucket (no retries on AccessDenied)
Expand All @@ -191,7 +215,8 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(bucketExistsError) // CreateBucketCommand attempt 2 fails (retry 1)
.mockRejectedValueOnce(bucketExistsError); // CreateBucketCommand attempt 3 fails (retry 2)

await expect(createS3Bucket(mockClients, 'eu-west-1', 'taken-bucket-name', '123456789012', 2, 1))
const ctx2Retries: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 1 };
await expect(createS3Bucket(mockClients, 'eu-west-1', 'taken-bucket-name', '123456789012', ctx2Retries))
.rejects.toThrow('Create S3 bucket failed after 3 attempts (2 retries): The requested bucket name is not available');

expect(mockSend).toHaveBeenCalledTimes(4); // 1 HeadBucket + 3 CreateBucket attempts
Expand All @@ -206,7 +231,8 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(invalidNameError) // CreateBucketCommand attempt 1 fails
.mockRejectedValueOnce(invalidNameError); // CreateBucketCommand attempt 2 fails (retry 1)

await expect(createS3Bucket(mockClients, 'us-west-2', 'Invalid_Bucket_Name', '123456789012', 1, 1))
const ctx1Retry: DeploymentContext = { verboseLogging: true, maxRetries: 1, retryDelay: 1 };
await expect(createS3Bucket(mockClients, 'us-west-2', 'Invalid_Bucket_Name', '123456789012', ctx1Retry))
.rejects.toThrow('Create S3 bucket failed after 2 attempts (1 retry): The specified bucket is not valid');

expect(mockSend).toHaveBeenCalledTimes(3); // 1 HeadBucket + 2 CreateBucket attempts
Expand All @@ -223,7 +249,8 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockRejectedValueOnce(uploadError); // PutObject fails with non-retryable AccessDenied

await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', 2, 1, false))
const ctx2Retries: DeploymentContext = { verboseLogging: true, maxRetries: 2, retryDelay: 1 };
await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'app.zip', false, ctx2Retries))
.rejects.toThrow('Access Denied');

expect(mockSend).toHaveBeenCalledTimes(2); // 1 HeadBucket + 1 PutObject
Expand All @@ -241,7 +268,7 @@ describe('S3 Operations', () => {
.mockRejectedValueOnce(noSuchBucketError) // PutObject attempt 3 fails (retry 2)
.mockRejectedValueOnce(noSuchBucketError); // PutObject attempt 4 fails (retry 3)

await expect(uploadToS3(mockClients, 'eu-central-1', '987654321098', 'test-app', 'v2.0.0', 'deploy.jar', 3, 1, false))
await expect(uploadToS3(mockClients, 'eu-central-1', '987654321098', 'test-app', 'v2.0.0', 'deploy.jar', false, defaultCtx))
.rejects.toThrow('Upload to S3 failed after 4 attempts (3 retries): The specified bucket does not exist');

expect(mockSend).toHaveBeenCalledTimes(5); // 1 HeadBucket + 4 PutObject attempts
Expand All @@ -253,7 +280,7 @@ describe('S3 Operations', () => {
const oversizedPackage = 600 * 1024 * 1024; // 600 MB
mockedFs.statSync.mockReturnValue({ size: oversizedPackage } as any);

await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'large-app.zip', 3, 1, false))
await expect(uploadToS3(mockClients, 'us-east-1', '123456789012', 'my-app', 'v1.0.0', 'large-app.zip', false, defaultCtx))
.rejects.toThrow('exceeds the maximum allowed size of 500 MB');

// Should fail before any AWS API calls
Expand All @@ -267,7 +294,7 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockResolvedValueOnce({}); // PutObject

const result = await uploadToS3(mockClients, 'us-west-2', '123456789012', 'my-app', 'v2.0.0', 'valid-app.zip', 3, 1, false);
const result = await uploadToS3(mockClients, 'us-west-2', '123456789012', 'my-app', 'v2.0.0', 'valid-app.zip', false, defaultCtx);

expect(result).toEqual({
bucket: 'elasticbeanstalk-us-west-2-123456789012',
Expand All @@ -283,7 +310,7 @@ describe('S3 Operations', () => {
.mockResolvedValueOnce({}) // HeadBucket (ownership check)
.mockResolvedValueOnce({}); // PutObject

const result = await uploadToS3(mockClients, 'eu-west-1', '987654321098', 'test-app', 'v1.0.0', 'exact-app.zip', 3, 1, false);
const result = await uploadToS3(mockClients, 'eu-west-1', '987654321098', 'test-app', 'v1.0.0', 'exact-app.zip', false, defaultCtx);

expect(result).toEqual({
bucket: 'elasticbeanstalk-eu-west-1-987654321098',
Expand All @@ -296,7 +323,7 @@ describe('S3 Operations', () => {
const justOverLimit = (500 * 1024 * 1024) + 1; // 500 MB + 1 byte
mockedFs.statSync.mockReturnValue({ size: justOverLimit } as any);

await expect(uploadToS3(mockClients, 'ap-southeast-1', '111222333444', 'app', 'v3.0.0', 'over-limit.zip', 3, 1, false))
await expect(uploadToS3(mockClients, 'ap-southeast-1', '111222333444', 'app', 'v3.0.0', 'over-limit.zip', false, defaultCtx))
.rejects.toThrow('exceeds the maximum allowed size of 500 MB');

expect(mockSend).not.toHaveBeenCalled();
Expand Down
18 changes: 18 additions & 0 deletions src/__tests__/validations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,31 @@ describe('Validation Functions', () => {
]);
return '';
});
mockedCore.getBooleanInput.mockReturnValue(true);

const result = validateAllInputs();

expect(result.valid).toBe(false);
expect(mockedCore.setFailed).toHaveBeenCalledWith('Invalid AWS region format: invalid-region. Expected format like \'us-east-1\' or \'us-gov-east-1\'');
});

it('should suppress region in error when verbose-logging is false', () => {
mockedCore.getInput.mockImplementation((name: string) => {
if (name === 'aws-region') return 'invalid-region';
if (name === 'application-name') return 'test-app';
if (name === 'environment-name') return 'test-env';
return '';
});
mockedCore.getBooleanInput.mockReturnValue(false);

const result = validateAllInputs();

expect(result.valid).toBe(false);
const failedMessage = mockedCore.setFailed.mock.calls[0][0] as string;
expect(failedMessage).not.toContain('invalid-region');
expect(failedMessage).toContain("Expected format like 'us-east-1' or 'us-gov-east-1'");
});

it('should validate successfully for GovCloud regions', () => {
mockedCore.getInput.mockImplementation((name: string) => {
const inputs: Record<string, string> = {
Expand Down
Loading
Loading