Support usage of AWS Profiles

This commit is contained in:
Jan Feddern
2025-12-21 11:28:36 +01:00
parent b547701ef9
commit db3779a0e9
12 changed files with 1308 additions and 19 deletions
+2 -1
View File
@@ -31,12 +31,13 @@ describe('Configure AWS Credentials cleanup', {}, () => {
it('replaces AWS credential and region environment variables with empty strings', {}, () => {
cleanup();
expect(core.setFailed).toHaveBeenCalledTimes(0);
expect(core.exportVariable).toHaveBeenCalledTimes(5);
expect(core.exportVariable).toHaveBeenCalledTimes(6);
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', '');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', '');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', '');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', '');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', '');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', '');
});
it('handles errors', {}, () => {
vi.spyOn(core, 'exportVariable').mockImplementationOnce(() => {
+217
View File
@@ -822,4 +822,221 @@ 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());
// Verify AWS_PROFILE was exported
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'dev');
// Verify region was still exported
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
// 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 env vars
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
// Verify AWS_PROFILE was exported
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'production');
// Verify region was exported
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
// Verify profile files were written
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: production');
expect(core.setFailed).not.toHaveBeenCalled();
});
it('writes profile files with 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' });
await run();
// Verify assumed role credentials were NOT exported to env vars
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID');
// Verify AWS_PROFILE was exported
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'assumed-role');
// Verify profile files were written
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: assumed-role');
expect(core.setFailed).not.toHaveBeenCalled();
});
it('respects output-env-credentials=false for AWS_PROFILE', async () => {
vi.spyOn(core, 'getInput').mockImplementation(
mocks.getInput({
...mocks.GH_OIDC_INPUTS,
'aws-profile': 'dev',
'output-env-credentials': 'false',
}),
);
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 AWS_PROFILE was NOT exported when output-env-credentials is false
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
// Verify region is still exported (always exported in profile mode)
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('✓ 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.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'));
});
it('always exports region in profile mode even with output-env-credentials=false', async () => {
vi.spyOn(core, 'getInput').mockImplementation(
mocks.getInput({
...mocks.GH_OIDC_INPUTS,
'aws-profile': 'dev',
'output-env-credentials': 'false',
}),
);
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 region was exported even when output-env-credentials is false
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
// Verify credential env vars were NOT exported
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());
// Verify AWS_PROFILE was NOT exported (output-env-credentials=false)
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
expect(core.setFailed).not.toHaveBeenCalled();
});
});
});
+342
View File
@@ -0,0 +1,342 @@
import * as core from '@actions/core';
import { fs, vol } from 'memfs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as ini from 'ini';
import {
ensureAwsDirectoryExists,
getProfileFilePaths,
mergeProfileSection,
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('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',
});
const content = fs.readFileSync(filePath, 'utf-8');
const parsed = ini.parse(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',
});
// Add second profile
mergeProfileSection(filePath, 'prod', {
aws_access_key_id: 'AKIAPRODEXAMPLE',
aws_secret_access_key: 'prodSecretKey',
});
const content = fs.readFileSync(filePath, 'utf-8');
const parsed = ini.parse(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',
});
// Overwrite with new credentials
mergeProfileSection(filePath, 'dev', {
aws_access_key_id: 'NEW_KEY',
aws_secret_access_key: 'newSecretKey',
aws_session_token: 'sessionToken',
});
const content = fs.readFileSync(filePath, 'utf-8');
const parsed = ini.parse(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('sessionToken');
});
});
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',
);
// Check credentials file
const credsPath = getProfileFilePaths().credentials;
const credContent = fs.readFileSync(credsPath, 'utf-8');
const credParsed = ini.parse(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 = ini.parse(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',
);
// Check credentials file uses [default]
const credsPath = getProfileFilePaths().credentials;
const credContent = fs.readFileSync(credsPath, 'utf-8');
const credParsed = ini.parse(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 = ini.parse(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',
);
// Write second profile
writeProfileFiles(
'prod',
{
AccessKeyId: 'AKIAPROD',
SecretAccessKey: 'prodSecret',
SessionToken: 'prodToken',
},
'us-west-2',
);
// Verify both profiles exist
const credsPath = getProfileFilePaths().credentials;
const credContent = fs.readFileSync(credsPath, 'utf-8');
const credParsed = ini.parse(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',
);
const credsPath = getProfileFilePaths().credentials;
const credContent = fs.readFileSync(credsPath, 'utf-8');
const credParsed = ini.parse(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',
),
).toThrow('Failed to write AWS profile');
expect(() =>
writeProfileFiles(
'invalid profile',
{
AccessKeyId: 'AKIA',
SecretAccessKey: 'secret',
},
'us-east-1',
),
).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',
);
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',
);
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');
});
});
});