mirror of
https://github.com/aws-actions/configure-aws-credentials.git
synced 2026-09-03 06:05:04 +09:00
feat: Support usage of AWS Profiles (#1696)
* Support usage of AWS Profiles
* squash merge main updates w feature branch
Squashed commit of the following:
commit ef2df4679f908ff30d5a711258ace2fa906c4bf3
Author: Michael Lehmann <lehmanmj@amazon.com>
Date: Tue Mar 17 11:24:04 2026 -0700
dist update
commit db3779a0e9
Author: Jan Feddern <jf@novatec-gmbh.de>
Date: Sun Dec 21 11:28:36 2025 +0100
Support usage of AWS Profiles
* chore: Update dist
* consistent outputEnvCredentials
* take out tests temporarily
* chore: Update dist
* debug changes for static creds
* remove debug and only cleanup profile if it was set
* formatting fixes + remove profile from cleanup test
* feat: Support usage of AWS Profiles
Adds a config option to support writing to profile files instead of
exporting environment variables.
Closes #1594. Closes #1586. Closes #112.
* chore: fix failing test case and windows path
* chore: lint project markdown files
* chore: update scripts in package.json and tsconfig update
* make env vars consistent, readme linting
* debug for profile path env vars
* remove debug
* remove profile backups
* error if we try to overwrite
* add option to overwrite existing profiles
* tests for overwrite option
* default to no env vars
* remove default from action file
* add static credential env var support
* validation fix for static creds multi profile
* debug sleep for static creds validation
* wait syntax
* undo sleep for creds validate
* test coverage, readme/action yml updates, validate creds later on self-hosted runner
* security dependency updates
* chore(deps-dev): bump @biomejs/biome from 2.4.8 to 2.4.10 (#1709)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.8 to 2.4.10.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.10/packages/@biomejs/biome)
---
updated-dependencies:
- dependency-name: "@biomejs/biome"
dependency-version: 2.4.10
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump @aws-sdk/credential-provider-env (#1713)
Bumps [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) from 3.972.22 to 3.972.24.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-env/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-env)
---
updated-dependencies:
- dependency-name: "@aws-sdk/credential-provider-env"
dependency-version: 3.972.24
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore: Update dist
* chore(deps): bump @aws-sdk/client-sts from 3.1015.0 to 3.1020.0 (#1710)
Bumps [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) from 3.1015.0 to 3.1020.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1020.0/clients/client-sts)
---
updated-dependencies:
- dependency-name: "@aws-sdk/client-sts"
dependency-version: 3.1020.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore: Update dist
* fix: do not write empty profile files
Also cleanup fix, additional test, README typo cleanup
* linting fix
* chore: linting fix
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Jan Feddern <jf@novatec-gmbh.de>
Co-authored-by: Michael Lehmann <lehmanmj@amazon.com>
Co-authored-by: GitHub Actions <github-aws-sdk-osds-automation@amazon.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -38,6 +38,26 @@ describe('Configure AWS Credentials cleanup', {}, () => {
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', '');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', '');
|
||||
});
|
||||
it('also clears AWS_PROFILE when aws-profile was set', {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
||||
if (name === 'aws-profile') return 'my-profile';
|
||||
if (name === 'output-env-credentials') return 'true';
|
||||
return '';
|
||||
});
|
||||
cleanup();
|
||||
expect(core.setFailed).toHaveBeenCalledTimes(0);
|
||||
expect(core.exportVariable).toHaveBeenCalledTimes(6);
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', '');
|
||||
});
|
||||
it('skips env cleanup when aws-profile is set without output-env-credentials', {}, () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation((name: string) => {
|
||||
if (name === 'aws-profile') return 'my-profile';
|
||||
return '';
|
||||
});
|
||||
cleanup();
|
||||
expect(core.setFailed).toHaveBeenCalledTimes(0);
|
||||
expect(core.exportVariable).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it('handles errors', {}, () => {
|
||||
vi.spyOn(core, 'exportVariable').mockImplementationOnce(() => {
|
||||
throw new Error('Test error');
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { beforeEach } from 'node:test';
|
||||
import * as core from '@actions/core';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as helpers from '../src/helpers';
|
||||
|
||||
describe('Configure AWS Credentials helpers', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
});
|
||||
it('removes brackets from GitHub Actor', {}, () => {
|
||||
const actor = 'actor[bot]';
|
||||
@@ -91,6 +91,7 @@ describe('Configure AWS Credentials helpers', {}, () => {
|
||||
});
|
||||
|
||||
it('clears session token when not provided', {}, () => {
|
||||
vi.spyOn(core, 'setSecret').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'exportVariable').mockImplementation(() => {});
|
||||
process.env.AWS_SESSION_TOKEN = 'old-token';
|
||||
helpers.exportCredentials({ AccessKeyId: 'test', SecretAccessKey: 'test' }, false, true);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fs, vol } from 'memfs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CredentialsClient } from '../src/CredentialsClient';
|
||||
import { run } from '../src/index';
|
||||
import * as profileManager from '../src/profileManager';
|
||||
import mocks from './mockinputs.test';
|
||||
|
||||
const mockedSTSClient = mockClient(STSClient);
|
||||
@@ -822,4 +823,202 @@ describe('Configure AWS Credentials', {}, () => {
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AWS Profile Support', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedSTSClient.reset();
|
||||
vi.mock('node:fs');
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('writes profile files with OIDC authentication', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.GH_OIDC_INPUTS,
|
||||
'aws-profile': 'dev',
|
||||
}),
|
||||
);
|
||||
vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
|
||||
await run();
|
||||
|
||||
// Verify credentials were NOT exported to environment variables
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
||||
|
||||
// Verify profile files were written
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes profile files with IAM user credentials', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.IAM_USER_INPUTS,
|
||||
'aws-profile': 'production',
|
||||
}),
|
||||
);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
||||
accessKeyId: 'MYAWSACCESSKEYID',
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
// Verify credentials were NOT exported to environment variables
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
||||
|
||||
// Verify profile files were written
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: production');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: production');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: production');
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes profile files with IAM user role assumption', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.IAM_ASSUMEROLE_INPUTS,
|
||||
'aws-profile': 'assumed-role',
|
||||
}),
|
||||
);
|
||||
mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
||||
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
||||
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
||||
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
||||
|
||||
vi.spyOn(profileManager, 'writeProfileFiles');
|
||||
await run();
|
||||
|
||||
// Verify credentials were NOT exported to environment variables
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
||||
|
||||
// Verify profile files were written
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: assumed-role');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: assumed-role');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: assumed-role');
|
||||
|
||||
// Verify profile files were written twice (first to write access key id and access key, second to write
|
||||
// actual session token after role assumption
|
||||
expect(profileManager.writeProfileFiles).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects output-env-credentials=true with profiles', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.GH_OIDC_INPUTS,
|
||||
'aws-profile': 'dev',
|
||||
'output-env-credentials': 'true',
|
||||
}),
|
||||
);
|
||||
vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
|
||||
await run();
|
||||
|
||||
// verify that env vars were exported
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'STSAWSSECRETACCESSKEY');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'STSAWSSESSIONTOKEN');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'dev');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
||||
|
||||
// Verify profile files were still written
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maintains backward compatibility when aws-profile is not specified', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
||||
vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
|
||||
await run();
|
||||
|
||||
// Verify credentials WERE exported to environment variables (backward compatibility)
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'STSAWSSECRETACCESSKEY');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'STSAWSSESSIONTOKEN');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
||||
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
||||
|
||||
// Verify AWS_PROFILE was NOT exported
|
||||
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
||||
|
||||
// Verify profile files were NOT written
|
||||
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Writing credentials to profile'));
|
||||
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('✓ Successfully configured AWS profile:'));
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles default profile correctly', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.GH_OIDC_INPUTS,
|
||||
'aws-profile': 'default',
|
||||
}),
|
||||
);
|
||||
vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
|
||||
await run();
|
||||
|
||||
// Verify profile files were written for 'default' profile
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: default');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: default');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: default');
|
||||
expect(core.setFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid profile names with whitespace', async () => {
|
||||
vi.spyOn(core, 'getInput').mockImplementation(
|
||||
mocks.getInput({
|
||||
...mocks.GH_OIDC_INPUTS,
|
||||
'aws-profile': 'invalid profile',
|
||||
}),
|
||||
);
|
||||
vi.spyOn(core, 'getIDToken').mockResolvedValue('testoidctoken');
|
||||
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
||||
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
||||
|
||||
await run();
|
||||
|
||||
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('whitespace'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
import * as core from '@actions/core';
|
||||
import { fs, vol } from 'memfs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ensureAwsDirectoryExists,
|
||||
getProfileFilePaths,
|
||||
mergeProfileSection,
|
||||
parseIni,
|
||||
stringifyIni,
|
||||
validateProfileName,
|
||||
writeProfileFiles,
|
||||
} from '../src/profileManager';
|
||||
|
||||
describe('Profile Manager', {}, () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mock('node:fs');
|
||||
vol.reset();
|
||||
vi.spyOn(core, 'debug').mockImplementation(() => {});
|
||||
vi.spyOn(core, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('parseIni', {}, () => {
|
||||
it('parses a single section', {}, () => {
|
||||
const result = parseIni('[dev]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n');
|
||||
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' });
|
||||
});
|
||||
|
||||
it('parses multiple sections', {}, () => {
|
||||
const result = parseIni('[dev]\nkey=dev_val\n\n[prod]\nkey=prod_val\n');
|
||||
expect(result.dev.key).toBe('dev_val');
|
||||
expect(result.prod.key).toBe('prod_val');
|
||||
});
|
||||
|
||||
it('skips comments and empty lines', {}, () => {
|
||||
const result = parseIni('# comment\n; another comment\n\n[dev]\nkey=val\n');
|
||||
expect(result.dev).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
it('trims whitespace around keys and values', {}, () => {
|
||||
const result = parseIni('[dev]\n key = val \n');
|
||||
expect(result.dev.key).toBe('val');
|
||||
});
|
||||
|
||||
it('preserves section names with spaces (e.g. profile prefix)', {}, () => {
|
||||
const result = parseIni('[profile dev]\nregion=us-east-1\n');
|
||||
expect(result['profile dev']).toEqual({ region: 'us-east-1' });
|
||||
});
|
||||
|
||||
it('guards against __proto__ section pollution', {}, () => {
|
||||
const result = parseIni('[__proto__]\npolluted=true\n[safe]\nkey=val\n');
|
||||
expect(result.__proto__).not.toHaveProperty('polluted');
|
||||
expect(result.safe).toEqual({ key: 'val' });
|
||||
});
|
||||
|
||||
it('guards against __proto__ key pollution', {}, () => {
|
||||
const result = parseIni('[dev]\n__proto__=evil\naws_access_key_id=AKIA\n');
|
||||
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA' });
|
||||
expect(result.dev).not.toHaveProperty('__proto__', 'evil');
|
||||
});
|
||||
|
||||
it('handles values containing equals signs', {}, () => {
|
||||
const result = parseIni('[dev]\naws_session_token=FwoGZXIvYXdzEBYa/base64==\n');
|
||||
expect(result.dev.aws_session_token).toBe('FwoGZXIvYXdzEBYa/base64==');
|
||||
});
|
||||
|
||||
it('handles empty values', {}, () => {
|
||||
const result = parseIni('[dev]\ncli_pager=\n');
|
||||
expect(result.dev.cli_pager).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty object for empty input', {}, () => {
|
||||
expect(parseIni('')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object for whitespace-only input', {}, () => {
|
||||
expect(parseIni(' \n\n \n')).toEqual({});
|
||||
});
|
||||
|
||||
it('handles Windows line endings (CRLF)', {}, () => {
|
||||
const result = parseIni('[dev]\r\naws_access_key_id=AKIA\r\naws_secret_access_key=secret\r\n');
|
||||
expect(result.dev).toEqual({ aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringifyIni', {}, () => {
|
||||
it('serializes a single section', {}, () => {
|
||||
const result = stringifyIni({ dev: { key: 'val' } });
|
||||
expect(result).toBe('[dev]\nkey = val\n');
|
||||
});
|
||||
|
||||
it('serializes multiple sections with blank line separator', {}, () => {
|
||||
const result = stringifyIni({ dev: { a: '1' }, prod: { b: '2' } });
|
||||
expect(result).toBe('[dev]\na = 1\n\n[prod]\nb = 2\n');
|
||||
});
|
||||
|
||||
it('round-trips through parseIni', {}, () => {
|
||||
const data = { dev: { aws_access_key_id: 'AKIA', aws_secret_access_key: 'secret' }, 'profile prod': { region: 'us-west-2' } };
|
||||
const roundTripped = parseIni(stringifyIni(data));
|
||||
expect(roundTripped).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles empty data object', {}, () => {
|
||||
const result = stringifyIni({});
|
||||
expect(result).toBe('\n');
|
||||
expect(parseIni(result)).toEqual({});
|
||||
});
|
||||
|
||||
it('handles section with no keys', {}, () => {
|
||||
const result = stringifyIni({ dev: {} });
|
||||
expect(result).toBe('[dev]\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProfileName', {}, () => {
|
||||
it('accepts valid profile names', {}, () => {
|
||||
expect(() => validateProfileName('dev')).not.toThrow();
|
||||
expect(() => validateProfileName('production')).not.toThrow();
|
||||
expect(() => validateProfileName('my-profile-123')).not.toThrow();
|
||||
expect(() => validateProfileName('default')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty profile names', {}, () => {
|
||||
expect(() => validateProfileName('')).toThrow('aws-profile must not be empty');
|
||||
expect(() => validateProfileName(' ')).toThrow('aws-profile must not be empty');
|
||||
});
|
||||
|
||||
it('rejects profile names with whitespace', {}, () => {
|
||||
expect(() => validateProfileName('my profile')).toThrow('aws-profile must not contain whitespace');
|
||||
expect(() => validateProfileName('dev\ntest')).toThrow('aws-profile must not contain whitespace');
|
||||
expect(() => validateProfileName('prod\tenv')).toThrow('aws-profile must not contain whitespace');
|
||||
});
|
||||
|
||||
it('rejects profile names with brackets', {}, () => {
|
||||
expect(() => validateProfileName('dev[test]')).toThrow('aws-profile must not contain brackets');
|
||||
expect(() => validateProfileName('[profile]')).toThrow('aws-profile must not contain brackets');
|
||||
});
|
||||
|
||||
it('rejects profile names with path separators', {}, () => {
|
||||
expect(() => validateProfileName('dev/test')).toThrow('aws-profile must not contain path separators');
|
||||
expect(() => validateProfileName('dev\\test')).toThrow('aws-profile must not contain path separators');
|
||||
expect(() => validateProfileName('../etc/passwd')).toThrow('aws-profile must not contain path separators');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProfileFilePaths', {}, () => {
|
||||
it('returns default paths when env vars not set', {}, () => {
|
||||
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
delete process.env.AWS_CONFIG_FILE;
|
||||
|
||||
const paths = getProfileFilePaths();
|
||||
|
||||
expect(paths.credentials).toMatch(/\.aws[/\\]credentials$/);
|
||||
expect(paths.config).toMatch(/\.aws[/\\]config$/);
|
||||
});
|
||||
|
||||
it('respects AWS_SHARED_CREDENTIALS_FILE env var', {}, () => {
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/path/credentials';
|
||||
process.env.AWS_CONFIG_FILE = '/custom/path/config';
|
||||
|
||||
const paths = getProfileFilePaths();
|
||||
|
||||
expect(paths.credentials).toBe('/custom/path/credentials');
|
||||
expect(paths.config).toBe('/custom/path/config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureAwsDirectoryExists', {}, () => {
|
||||
it('creates directory if it does not exist', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
|
||||
ensureAwsDirectoryExists(filePath);
|
||||
|
||||
expect(fs.existsSync('/home/runner/.aws')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not error if directory already exists', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
expect(() => ensureAwsDirectoryExists(filePath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('creates nested directories', {}, () => {
|
||||
const filePath = '/home/runner/custom/path/.aws/credentials';
|
||||
|
||||
ensureAwsDirectoryExists(filePath);
|
||||
|
||||
expect(fs.existsSync('/home/runner/custom/path/.aws')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeProfileSection', {}, () => {
|
||||
it('creates new file with profile section', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}, false);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev).toBeDefined();
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
||||
expect(parsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
||||
});
|
||||
|
||||
it('merges with existing profiles', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIAIOSFODNN7EXAMPLE',
|
||||
aws_secret_access_key: 'devSecretKey',
|
||||
}, false);
|
||||
|
||||
// Add second profile
|
||||
mergeProfileSection(filePath, 'prod', {
|
||||
aws_access_key_id: 'AKIAPRODEXAMPLE',
|
||||
aws_secret_access_key: 'prodSecretKey',
|
||||
}, false);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev).toBeDefined();
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
||||
expect(parsed.prod).toBeDefined();
|
||||
expect(parsed.prod.aws_access_key_id).toBe('AKIAPRODEXAMPLE');
|
||||
});
|
||||
|
||||
it('overwrites existing profile with same name', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
aws_session_token: 'oldSessionToken'
|
||||
}, false);
|
||||
|
||||
// Overwrite with new credentials
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'newSessionToken',
|
||||
}, true);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev.aws_access_key_id).toBe('NEW_KEY');
|
||||
expect(parsed.dev.aws_secret_access_key).toBe('newSecretKey');
|
||||
expect(parsed.dev.aws_session_token).toBe('newSessionToken');
|
||||
});
|
||||
|
||||
it('overwriting a profile removes stale keys', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create profile with session token
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
aws_session_token: 'old-token',
|
||||
}, false);
|
||||
|
||||
// Overwrite without session token
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA2',
|
||||
aws_secret_access_key: 'secret2',
|
||||
}, true);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIA2');
|
||||
expect(parsed.dev.aws_secret_access_key).toBe('secret2');
|
||||
expect(parsed.dev.aws_session_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty existing file', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
fs.writeFileSync(filePath, '', { mode: 0o600 });
|
||||
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'AKIA',
|
||||
aws_secret_access_key: 'secret',
|
||||
}, false);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIA');
|
||||
expect(parsed.dev.aws_secret_access_key).toBe('secret');
|
||||
});
|
||||
|
||||
it('errors if profile name already exists but overwrite flag is false', {}, () => {
|
||||
const filePath = '/home/runner/.aws/credentials';
|
||||
fs.mkdirSync('/home/runner/.aws', { recursive: true });
|
||||
|
||||
// Create initial profile
|
||||
mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'OLD_KEY',
|
||||
aws_secret_access_key: 'oldSecretKey',
|
||||
}, false);
|
||||
|
||||
// Overwrite with new credentials
|
||||
expect(() => mergeProfileSection(filePath, 'dev', {
|
||||
aws_access_key_id: 'NEW_KEY',
|
||||
aws_secret_access_key: 'newSecretKey',
|
||||
aws_session_token: 'sessionToken',
|
||||
}, false)).toThrow(`Profile with name "dev" already exists. Please use the overwrite-aws-profile input if you want to overwrite existing profiles.`);
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.dev.aws_access_key_id).toBe('OLD_KEY');
|
||||
expect(parsed.dev.aws_secret_access_key).toBe('oldSecretKey');
|
||||
expect(parsed.dev.aws_session_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeProfileFiles', {}, () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.AWS_SHARED_CREDENTIALS_FILE;
|
||||
delete process.env.AWS_CONFIG_FILE;
|
||||
});
|
||||
|
||||
it('writes credentials and config for new profile', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
||||
},
|
||||
'us-east-1',
|
||||
false,
|
||||
);
|
||||
|
||||
// Check credentials file
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
const credParsed = parseIni(credContent);
|
||||
|
||||
expect(credParsed.dev).toBeDefined();
|
||||
expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
||||
expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
||||
expect(credParsed.dev.aws_session_token).toBe('FwoGZXIvYXdzEBYaDEXAMPLE');
|
||||
|
||||
// Check config file
|
||||
const configPath = getProfileFilePaths().config;
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
const configParsed = parseIni(configContent);
|
||||
|
||||
expect(configParsed['profile dev']).toBeDefined();
|
||||
expect(configParsed['profile dev'].region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('uses correct section naming for default profile', {}, () => {
|
||||
writeProfileFiles(
|
||||
'default',
|
||||
{
|
||||
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
},
|
||||
'us-west-2',
|
||||
false,
|
||||
);
|
||||
|
||||
// Check credentials file uses [default]
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
const credParsed = parseIni(credContent);
|
||||
|
||||
expect(credParsed.default).toBeDefined();
|
||||
expect(credParsed['profile default']).toBeUndefined();
|
||||
|
||||
// Check config file uses [default] (not [profile default])
|
||||
const configPath = getProfileFilePaths().config;
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
const configParsed = parseIni(configContent);
|
||||
|
||||
expect(configParsed.default).toBeDefined();
|
||||
expect(configParsed['profile default']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports multiple profiles', {}, () => {
|
||||
// Write first profile
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIADEV',
|
||||
SecretAccessKey: 'devSecret',
|
||||
},
|
||||
'us-east-1',
|
||||
false,
|
||||
);
|
||||
|
||||
// Write second profile
|
||||
writeProfileFiles(
|
||||
'prod',
|
||||
{
|
||||
AccessKeyId: 'AKIAPROD',
|
||||
SecretAccessKey: 'prodSecret',
|
||||
SessionToken: 'prodToken',
|
||||
},
|
||||
'us-west-2',
|
||||
false,
|
||||
);
|
||||
|
||||
// Verify both profiles exist
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
const credParsed = parseIni(credContent);
|
||||
|
||||
expect(credParsed.dev).toBeDefined();
|
||||
expect(credParsed.prod).toBeDefined();
|
||||
expect(credParsed.dev.aws_access_key_id).toBe('AKIADEV');
|
||||
expect(credParsed.prod.aws_access_key_id).toBe('AKIAPROD');
|
||||
});
|
||||
|
||||
it('handles credentials without session token', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
},
|
||||
'us-east-1',
|
||||
false,
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
const credParsed = parseIni(credContent);
|
||||
|
||||
expect(credParsed.dev.aws_access_key_id).toBe('AKIAIOSFODNN7EXAMPLE');
|
||||
expect(credParsed.dev.aws_secret_access_key).toBe('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY');
|
||||
expect(credParsed.dev.aws_session_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws error for invalid profile name', {}, () => {
|
||||
expect(() =>
|
||||
writeProfileFiles(
|
||||
'invalid profile',
|
||||
{
|
||||
AccessKeyId: 'AKIA',
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false,
|
||||
),
|
||||
).toThrow('Failed to write AWS profile');
|
||||
expect(() =>
|
||||
writeProfileFiles(
|
||||
'invalid profile',
|
||||
{
|
||||
AccessKeyId: 'AKIA',
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
),
|
||||
).toThrow('whitespace');
|
||||
});
|
||||
|
||||
it('respects custom file paths from env vars', {}, () => {
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom/credentials';
|
||||
process.env.AWS_CONFIG_FILE = '/custom/config';
|
||||
|
||||
fs.mkdirSync('/custom', { recursive: true });
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIA',
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(fs.existsSync('/custom/credentials')).toBe(true);
|
||||
expect(fs.existsSync('/custom/config')).toBe(true);
|
||||
});
|
||||
|
||||
it('logs info messages', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIA',
|
||||
SecretAccessKey: 'secret',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
||||
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
||||
});
|
||||
|
||||
it('preserves pre-existing unrelated profiles in credentials file', {}, () => {
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credsPath,
|
||||
'[personal]\naws_access_key_id=AKIAPERSONAL\naws_secret_access_key=personalSecret\naws_session_token=personalToken\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
// Pre-existing profile must be fully intact
|
||||
expect(parsed.personal).toEqual({
|
||||
aws_access_key_id: 'AKIAPERSONAL',
|
||||
aws_secret_access_key: 'personalSecret',
|
||||
aws_session_token: 'personalToken',
|
||||
});
|
||||
// New profile also present
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
||||
});
|
||||
|
||||
it('preserves pre-existing config with extra keys', {}, () => {
|
||||
const configPath = getProfileFilePaths().config;
|
||||
fs.mkdirSync(require('node:path').dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
'[profile personal]\nregion=eu-west-1\noutput=json\ncli_pager=\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIA', SecretAccessKey: 'secret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed['profile personal']).toEqual({
|
||||
region: 'eu-west-1',
|
||||
output: 'json',
|
||||
cli_pager: '',
|
||||
});
|
||||
expect(parsed['profile dev'].region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('preserves pre-existing default profile when writing a named profile', {}, () => {
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credsPath,
|
||||
'[default]\naws_access_key_id=AKIADEFAULT\naws_secret_access_key=defaultSecret\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-west-2',
|
||||
false
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8');
|
||||
const parsed = parseIni(content);
|
||||
|
||||
expect(parsed.default).toEqual({
|
||||
aws_access_key_id: 'AKIADEFAULT',
|
||||
aws_secret_access_key: 'defaultSecret',
|
||||
});
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
||||
});
|
||||
|
||||
it('comments in pre-existing files are stripped on round-trip', {}, () => {
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
fs.mkdirSync(require('node:path').dirname(credsPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
credsPath,
|
||||
'# My important comment\n[personal]\naws_access_key_id=AKIA\naws_secret_access_key=secret\n',
|
||||
);
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(credsPath, 'utf-8') as string;
|
||||
|
||||
// Comment is lost (known trade-off), but profile data is preserved
|
||||
expect(content).not.toContain('# My important comment');
|
||||
const parsed = parseIni(content);
|
||||
expect(parsed.personal.aws_access_key_id).toBe('AKIA');
|
||||
expect(parsed.dev.aws_access_key_id).toBe('AKIADEV');
|
||||
});
|
||||
|
||||
it('writes empty section when credentials object has no keys', {}, () => {
|
||||
writeProfileFiles('dev', {}, 'us-east-1', false);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
const credParsed = parseIni(credContent);
|
||||
|
||||
// Section exists but has no credential keys
|
||||
expect(credParsed.dev).toEqual({});
|
||||
|
||||
// Config still gets region
|
||||
const configPath = getProfileFilePaths().config;
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
const configParsed = parseIni(configContent);
|
||||
expect(configParsed['profile dev'].region).toBe('us-east-1');
|
||||
});
|
||||
|
||||
it('resolves credentials and config paths independently from env vars', {}, () => {
|
||||
process.env.AWS_SHARED_CREDENTIALS_FILE = '/custom-creds/credentials';
|
||||
// AWS_CONFIG_FILE is NOT set — should use default path
|
||||
|
||||
fs.mkdirSync('/custom-creds', { recursive: true });
|
||||
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIA', SecretAccessKey: 'secret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(fs.existsSync('/custom-creds/credentials')).toBe(true);
|
||||
// Config file should be at the default path (under homedir)
|
||||
const defaultConfigPath = require('node:path').join(require('node:os').homedir(), '.aws', 'config');
|
||||
expect(fs.existsSync(defaultConfigPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces AWS CLI-compatible INI output (golden file)', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{
|
||||
AccessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
||||
SecretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
SessionToken: 'FwoGZXIvYXdzEBYaDEXAMPLE',
|
||||
},
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
|
||||
const configPath = getProfileFilePaths().config;
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
// Verify exact byte-for-byte format matching AWS CLI style:
|
||||
// - [section] header on its own line
|
||||
// - key = value with spaces around =
|
||||
// - LF line endings, trailing newline
|
||||
expect(credContent).toBe(
|
||||
'[dev]\n' +
|
||||
'aws_access_key_id = AKIAIOSFODNN7EXAMPLE\n' +
|
||||
'aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n' +
|
||||
'aws_session_token = FwoGZXIvYXdzEBYaDEXAMPLE\n',
|
||||
);
|
||||
expect(configContent).toBe(
|
||||
'[profile dev]\n' +
|
||||
'region = us-east-1\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('golden file for multi-profile output', {}, () => {
|
||||
writeProfileFiles(
|
||||
'dev',
|
||||
{ AccessKeyId: 'AKIADEV', SecretAccessKey: 'devSecret' },
|
||||
'us-east-1',
|
||||
false
|
||||
);
|
||||
writeProfileFiles(
|
||||
'prod',
|
||||
{ AccessKeyId: 'AKIAPROD', SecretAccessKey: 'prodSecret', SessionToken: 'prodToken' },
|
||||
'us-west-2',
|
||||
false
|
||||
);
|
||||
|
||||
const credsPath = getProfileFilePaths().credentials;
|
||||
const credContent = fs.readFileSync(credsPath, 'utf-8');
|
||||
|
||||
const configPath = getProfileFilePaths().config;
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
expect(credContent).toBe(
|
||||
'[dev]\n' +
|
||||
'aws_access_key_id = AKIADEV\n' +
|
||||
'aws_secret_access_key = devSecret\n' +
|
||||
'\n' +
|
||||
'[prod]\n' +
|
||||
'aws_access_key_id = AKIAPROD\n' +
|
||||
'aws_secret_access_key = prodSecret\n' +
|
||||
'aws_session_token = prodToken\n',
|
||||
);
|
||||
expect(configContent).toBe(
|
||||
'[profile dev]\n' +
|
||||
'region = us-east-1\n' +
|
||||
'\n' +
|
||||
'[profile prod]\n' +
|
||||
'region = us-west-2\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user