diff --git a/package.json b/package.json index 66aa93e..f09d014 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,8 @@ "dependencies": { "@aws-sdk/client-cloudformation": "^3.1034.0", "@aws-sdk/client-s3": "^3.1034.0", - "@aws-sdk/client-sts": "^3.1034.0", - "@aws-sdk/credential-provider-env": "^3.972.29", - "@aws-sdk/credential-provider-imds": "^3.370.0", - "@aws-sdk/credential-provider-ini": "^3.972.33", - "@aws-sdk/credential-provider-process": "^3.972.29", - "@aws-sdk/credential-provider-sso": "^3.972.33", - "@aws-sdk/credential-provider-web-identity": "^3.972.33", - "@aws-sdk/property-provider": "^3.366.0", + "@aws-sdk/credential-providers": "^3.975.0", + "@smithy/node-http-handler": "^4.6.1", "@dagrejs/graphlib": "^3.0.4", "ajv": "^8.11.0", "cli-cursor": "^5.0.0", @@ -42,6 +36,7 @@ "ext": "^1.7.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.4", + "https-proxy-agent": "^9.0.0", "js-yaml": "^4.1.0", "log": "^6.3.1", "log-node": "^8.0.3", diff --git a/src/index.js b/src/index.js index 18fa68f..4effba0 100644 --- a/src/index.js +++ b/src/index.js @@ -48,7 +48,7 @@ const runComponents = async (argv = process.argv.slice(2)) => { } method = args._; - if (!method) { + if (!method.length) { await renderHelp(); return; } @@ -65,6 +65,8 @@ const runComponents = async (argv = process.argv.slice(2)) => { } delete options._; // remove the method name if any + validateOptions(options, method); + const configurationPath = await resolveConfigurationPath(process.cwd()); const configuration = await readConfiguration(configurationPath); validateConfiguration(configuration, configurationPath); @@ -82,8 +84,6 @@ const runComponents = async (argv = process.argv.slice(2)) => { context = new Context(contextConfig); await context.init(); - validateOptions(options, method); - try { const componentsService = new ComponentsService(context, configuration, options); await componentsService.init(); diff --git a/src/state/S3StateStorage.js b/src/state/S3StateStorage.js index ee83eef..a1cab17 100644 --- a/src/state/S3StateStorage.js +++ b/src/state/S3StateStorage.js @@ -7,6 +7,8 @@ const ServerlessError = require('../serverless-error'); const BaseStateStorage = require('./BaseStateStorage'); const normalizeState = require('./normalize-state'); +const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); + class S3StateStorage extends BaseStateStorage { constructor(config = {}) { super(); @@ -17,7 +19,9 @@ class S3StateStorage extends BaseStateStorage { this.bucketName = config.bucketName; this.stateKey = config.stateKey; - this.s3Client = new S3({ region: this.region, credentials: config.credentials }); + this.s3Client = new S3( + config.clientConfig || { region: this.region, credentials: config.credentials } + ); this.writeRequestQueue = pLimit(1); } @@ -36,7 +40,7 @@ class S3StateStorage extends BaseStateStorage { const readState = await streamToString(stateObjectFromS3.Body); this.state = normalizeState(JSON.parse(readState)); } catch (e) { - if (e.Code === 'NoSuchKey') { + if (getAwsErrorCode(e) === 'NoSuchKey') { this.state = normalizeState({}); } else { throw new ServerlessError( diff --git a/src/state/get-s3-state-storage-from-config.js b/src/state/get-s3-state-storage-from-config.js index 0ba7897..72e4e19 100644 --- a/src/state/get-s3-state-storage-from-config.js +++ b/src/state/get-s3-state-storage-from-config.js @@ -20,19 +20,20 @@ const getS3StateStorageFromConfig = async (stateConfiguration, context) => { const configuredBucketName = getConfiguredStateBucketName(stateConfiguration); const region = configuredBucketName - ? await getStateBucketRegion(bucketName, stateConfiguration) + ? await getStateBucketRegion(bucketName, stateConfiguration, context) : 'us-east-1'; const awsClientConfig = getAwsClientConfig({ profile: stateConfiguration.profile, region, + stage: context.stage, }); return new S3StateStorage({ bucketName, stateKey, region, - credentials: awsClientConfig.credentials, + clientConfig: awsClientConfig, }); }; diff --git a/src/state/utils/get-state-bucket-name.js b/src/state/utils/get-state-bucket-name.js index a84c6f6..c6b469e 100644 --- a/src/state/utils/get-state-bucket-name.js +++ b/src/state/utils/get-state-bucket-name.js @@ -9,20 +9,22 @@ const remoteStateCloudFormationTemplate = require('./remote-state-cloudformation const ServerlessError = require('../../serverless-error'); const COMPOSE_REMOTE_STATE_STACK_NAME = 'serverless-compose-state'; +const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); -const getCloudFormationClient = (stateConfiguration = {}) => { +const getCloudFormationClient = (stateConfiguration = {}, context = {}) => { // We are enforcing us-east-1 as the intention (that might change in the future if we find a good reason) // is to only create one such bucket across all regions in a single AWS Account return new CloudFormation( getAwsClientConfig({ profile: stateConfiguration.profile, region: 'us-east-1', + stage: context.stage, }) ); }; const monitorStackCreation = async (stackName, context, stateConfiguration) => { - const client = getCloudFormationClient(stateConfiguration); + const client = getCloudFormationClient(stateConfiguration, context); const describeStacksResponse = await client.describeStacks({ StackName: stackName }); const status = describeStacksResponse.Stacks[0].StackStatus; @@ -49,7 +51,7 @@ const monitorStackCreation = async (stackName, context, stateConfiguration) => { * @param {import('../../Context')} context */ const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) => { - const client = getCloudFormationClient(stateConfiguration); + const client = getCloudFormationClient(stateConfiguration, context); const templateBody = JSON.stringify(remoteStateCloudFormationTemplate); // TODO: REPLACE WITH PROGRESS @@ -72,8 +74,8 @@ const ensureRemoteStateBucketStackExists = async (context, stateConfiguration) = return bucketName; }; -const getStateBucketNameFromCF = async (stateConfiguration) => { - const client = getCloudFormationClient(stateConfiguration); +const getStateBucketNameFromCF = async (stateConfiguration, context) => { + const client = getCloudFormationClient(stateConfiguration, context); const logicalResourceId = 'ServerlessComposeRemoteStateBucket'; const result = await client.describeStackResource({ StackName: COMPOSE_REMOTE_STATE_STACK_NAME, @@ -104,10 +106,10 @@ const getStateBucketName = async (stateConfiguration, context) => { // 2. Check from remote try { - return await getStateBucketNameFromCF(stateConfiguration); + return await getStateBucketNameFromCF(stateConfiguration, context); } catch (e) { // If message includes 'does not exist', we need to move forward and create the stack first - if (!(e.Code === 'ValidationError' && e.message.includes('does not exist'))) { + if (!(getAwsErrorCode(e) === 'ValidationError' && e.message.includes('does not exist'))) { throw new ServerlessError( `Could not retrieve S3 state bucket: ${e.message}`, 'CANNOT_RETRIEVE_REMOTE_STATE_S3_BUCKET' diff --git a/src/state/utils/get-state-bucket-region.js b/src/state/utils/get-state-bucket-region.js index edc15b7..c12c343 100644 --- a/src/state/utils/get-state-bucket-region.js +++ b/src/state/utils/get-state-bucket-region.js @@ -4,11 +4,14 @@ const { S3 } = require('@aws-sdk/client-s3'); const { getAwsClientConfig } = require('../../utils/aws'); const ServerlessError = require('../../serverless-error'); -const getStateBucketRegion = async (bucketName, stateConfiguration = {}) => { +const getAwsErrorCode = (error) => error && (error.Code || error.code || error.name); + +const getStateBucketRegion = async (bucketName, stateConfiguration = {}, context = {}) => { const client = new S3( getAwsClientConfig({ profile: stateConfiguration.profile, region: 'us-east-1', + stage: context.stage, }) ); @@ -16,14 +19,16 @@ const getStateBucketRegion = async (bucketName, stateConfiguration = {}) => { try { result = await client.getBucketLocation({ Bucket: bucketName }); } catch (e) { - if (e.Code === 'NoSuchBucket') { + const code = getAwsErrorCode(e); + + if (code === 'NoSuchBucket') { throw new ServerlessError( `Provided bucket: "${bucketName}" could not be found.`, 'CANNOT_FIND_PROVIDED_REMOTE_STATE_BUCKET' ); } - if (e.Code === 'AccessDenied') { + if (code === 'AccessDenied') { throw new ServerlessError( `Access to provided bucket: "${bucketName}" has been denied.`, 'CANNOT_ACCESS_PROVIDED_REMOTE_STATE_BUCKET' diff --git a/src/utils/aws/config.js b/src/utils/aws/config.js new file mode 100644 index 0000000..fc984dc --- /dev/null +++ b/src/utils/aws/config.js @@ -0,0 +1,142 @@ +'use strict'; + +const { HttpsProxyAgent } = require('https-proxy-agent'); +const https = require('https'); +const fs = require('fs'); +const { NodeHttpHandler } = require('@smithy/node-http-handler'); + +/** + * Build AWS SDK v3 client configuration from environment and options + * @param {Object} options - Configuration options + * @param {string} options.region - AWS region + * @param {Object|Function} options.credentials - AWS credentials or SDK v3 credential provider + * @param {number} options.maxAttempts - Maximum retry attempts + * @param {string} options.retryMode - Retry mode ('legacy', 'standard', 'adaptive') + * @returns {Object} AWS SDK v3 client configuration + */ +function buildClientConfig(options = {}) { + const { credentials, maxAttempts, region, requestHandler, retryMode, ...clientOptions } = options; + const config = { + ...clientOptions, + region: + region === undefined + ? process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1' + : region, + maxAttempts: maxAttempts === undefined ? getMaxAttempts() : maxAttempts, + retryMode: retryMode || 'standard', + }; + + // Add credentials if provided + if (credentials) { + config.credentials = credentials; + } + + if (requestHandler) { + config.requestHandler = requestHandler; + } else { + // Configure HTTP options (proxy, timeout, certificates) + const httpOptions = buildHttpOptions(); + if (httpOptions) { + config.requestHandler = new NodeHttpHandler(httpOptions); + } + } + + return config; +} + +/** + * Get maximum retry attempts from environment + * @returns {number} Maximum retry attempts + */ +function getMaxRetries() { + const userValue = Number(process.env.SLS_AWS_REQUEST_MAX_RETRIES); + return userValue >= 0 ? userValue : 4; +} + +function getMaxAttempts() { + return getMaxRetries() + 1; +} + +/** + * Build HTTP options for AWS SDK v3 clients + * @returns {Object|null} HTTP configuration or null if no special config needed + */ +function buildHttpOptions() { + const httpOptions = {}; + + // Configure timeout + const timeout = process.env.AWS_CLIENT_TIMEOUT || process.env.aws_client_timeout; + if (timeout) { + httpOptions.requestTimeout = parseInt(timeout, 10); + } + + // Configure proxy + const proxy = getProxyUrl(); + + // Configure custom CA certificates + const caCerts = getCACertificates(); + const agentOptions = {}; + if (caCerts.length > 0) { + Object.assign(agentOptions, { + rejectUnauthorized: true, + ca: caCerts, + }); + } + + if (proxy) { + httpOptions.httpsAgent = new HttpsProxyAgent(proxy, agentOptions); + } else if (caCerts.length > 0) { + httpOptions.httpsAgent = new https.Agent(agentOptions); + } + + return httpOptions.httpsAgent || 'requestTimeout' in httpOptions ? httpOptions : null; +} + +/** + * Get proxy URL from environment variables + * @returns {string|null} Proxy URL or null if not configured + */ +function getProxyUrl() { + return ( + process.env.proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.HTTPS_PROXY || + process.env.https_proxy || + null + ); +} + +/** + * Get CA certificates from environment variables and files + * @returns {Array} Array of CA certificates + */ +function getCACertificates() { + let caCerts = []; + + // Get certificates from environment variable + const ca = process.env.ca || process.env.HTTPS_CA || process.env.https_ca; + if (ca) { + // Can be a single certificate or multiple, comma separated. + const caArr = ca.split(','); + // Replace the newline -- https://stackoverflow.com/questions/30400341 + caCerts = caCerts.concat(caArr.map((cert) => cert.replace(/\\n/g, '\n'))); + } + + // Get certificates from files + const cafile = process.env.cafile || process.env.HTTPS_CAFILE || process.env.https_cafile; + if (cafile) { + // Can be a single certificate file path or multiple paths, comma separated. + const caPathArr = cafile.split(','); + caCerts = caCerts.concat(caPathArr.map((cafilePath) => fs.readFileSync(cafilePath.trim()))); + } + + return caCerts; +} + +module.exports = { + buildClientConfig, + buildHttpOptions, + getMaxAttempts, + getMaxRetries, +}; diff --git a/src/utils/aws/credentials.js b/src/utils/aws/credentials.js new file mode 100644 index 0000000..64c8aa1 --- /dev/null +++ b/src/utils/aws/credentials.js @@ -0,0 +1,145 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const readline = require('readline'); +const { fromIni, fromNodeProviderChain } = require('@aws-sdk/credential-providers'); + +const defaultConfigProfileSectionRegex = /^profile\s+(?:default|"default"|'default')$/; + +function hasEnvironmentCredentials(prefix) { + return Boolean( + process.env[`${prefix}_ACCESS_KEY_ID`] && process.env[`${prefix}_SECRET_ACCESS_KEY`] + ); +} + +function fromPrefixedEnv(prefix) { + return async () => { + const accessKeyId = process.env[`${prefix}_ACCESS_KEY_ID`]; + const secretAccessKey = process.env[`${prefix}_SECRET_ACCESS_KEY`]; + const sessionToken = process.env[`${prefix}_SESSION_TOKEN`]; + + if (!accessKeyId || !secretAccessKey) { + throw Object.assign(new Error(`Could not load credentials from ${prefix} environment`), { + name: 'CredentialsProviderError', + }); + } + + return { + accessKeyId, + secretAccessKey, + sessionToken, + }; + }; +} + +function promptMfaCode(mfaSerial) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + + return new Promise((resolve) => { + rl.question(`Enter MFA code for ${mfaSerial}: `, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +function expandHomeDirPath(filePath) { + return filePath.startsWith('~/') ? path.join(os.homedir(), filePath.slice(2)) : filePath; +} + +function getSharedCredentialsFilepath() { + return expandHomeDirPath( + process.env.AWS_SHARED_CREDENTIALS_FILE || path.join(os.homedir(), '.aws', 'credentials') + ); +} + +function getSharedConfigFilepath() { + return expandHomeDirPath( + process.env.AWS_CONFIG_FILE || path.join(os.homedir(), '.aws', 'config') + ); +} + +function fromProfile(profile) { + return fromIni({ + profile, + filepath: getSharedCredentialsFilepath(), + configFilepath: getSharedConfigFilepath(), + mfaCodeProvider: promptMfaCode, + }); +} + +function getIniSectionNames(filePath) { + try { + const contents = fs.readFileSync(filePath, 'utf8'); + const sectionNames = new Set(); + + for (const line of contents.split(/\r?\n/)) { + const trimmedLine = line.split(/(^|\s)[;#]/)[0].trim(); + if (trimmedLine[0] === '[' && trimmedLine[trimmedLine.length - 1] === ']') { + sectionNames.add(trimmedLine.slice(1, -1)); + } + } + + return sectionNames; + } catch (error) { + if (error && error.code === 'ENOENT') return new Set(); + throw error; + } +} + +function isDefaultConfigProfileSection(sectionName) { + return sectionName === 'default' || defaultConfigProfileSectionRegex.test(sectionName); +} + +function doesImplicitDefaultProfileExist() { + const credentialsProfiles = getIniSectionNames(getSharedCredentialsFilepath()); + if (credentialsProfiles.has('default')) return true; + + const configSectionNames = getIniSectionNames(getSharedConfigFilepath()); + for (const sectionName of configSectionNames) { + if (isDefaultConfigProfileSection(sectionName)) return true; + } + + return false; +} + +function fromImplicitDefaultProfileWithFallback() { + const profileProvider = fromProfile('default'); + let fallbackProvider; + + return async (providerOptions) => { + try { + return await profileProvider(providerOptions); + } catch (error) { + if (doesImplicitDefaultProfileExist()) throw error; + if (!fallbackProvider) fallbackProvider = fromNodeProviderChain(); + return fallbackProvider(providerOptions); + } + }; +} + +function getCredentialProvider({ profile, stage } = {}) { + const stageUpper = stage ? stage.toUpperCase() : null; + + if (profile) return fromProfile(profile); + if (stageUpper && process.env[`AWS_${stageUpper}_PROFILE`]) { + return fromProfile(process.env[`AWS_${stageUpper}_PROFILE`]); + } + if (stageUpper && hasEnvironmentCredentials(`AWS_${stageUpper}`)) { + return fromPrefixedEnv(`AWS_${stageUpper}`); + } + if (process.env.AWS_PROFILE) return fromProfile(process.env.AWS_PROFILE); + if (hasEnvironmentCredentials('AWS')) return fromPrefixedEnv('AWS'); + if (process.env.AWS_DEFAULT_PROFILE) return fromProfile(process.env.AWS_DEFAULT_PROFILE); + + return fromImplicitDefaultProfileWithFallback(); +} + +module.exports = { + doesImplicitDefaultProfileExist, + fromPrefixedEnv, + getCredentialProvider, + hasEnvironmentCredentials, +}; diff --git a/src/utils/aws/get-client-config.js b/src/utils/aws/get-client-config.js index b3ea4e9..9aba285 100644 --- a/src/utils/aws/get-client-config.js +++ b/src/utils/aws/get-client-config.js @@ -1,8 +1,11 @@ 'use strict'; +const { buildClientConfig } = require('./config'); const getCredentialProvider = require('./get-credential-provider'); -module.exports = ({ profile, region } = {}) => ({ - region, - credentials: getCredentialProvider({ profile, region }), -}); +module.exports = ({ credentials, profile, region, stage, ...clientOptions } = {}) => + buildClientConfig({ + ...clientOptions, + region, + credentials: credentials || getCredentialProvider({ profile, stage }), + }); diff --git a/src/utils/aws/get-credential-provider.js b/src/utils/aws/get-credential-provider.js index 814d218..314a6c5 100644 --- a/src/utils/aws/get-credential-provider.js +++ b/src/utils/aws/get-credential-provider.js @@ -1,14 +1,5 @@ 'use strict'; -const fromNodeProviderChain = require('./provider-chain/from-node-provider-chain'); +const { getCredentialProvider } = require('./credentials'); -module.exports = ({ profile, region } = {}) => { - if (process.env.AWS_DEFAULT_PROFILE && !process.env.AWS_PROFILE) { - process.env.AWS_PROFILE = process.env.AWS_DEFAULT_PROFILE; - } - - return fromNodeProviderChain({ - profile, - clientConfig: { region }, - }); -}; +module.exports = ({ profile, stage } = {}) => getCredentialProvider({ profile, stage }); diff --git a/src/utils/aws/provider-chain/default-provider.js b/src/utils/aws/provider-chain/default-provider.js deleted file mode 100644 index bac984e..0000000 --- a/src/utils/aws/provider-chain/default-provider.js +++ /dev/null @@ -1,36 +0,0 @@ -'use strict'; - -// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 -// credential-provider internals. Kept local so compose owns only the small -// provider chain it actually uses. - -const { fromEnv } = require('@aws-sdk/credential-provider-env'); -const { fromIni } = require('@aws-sdk/credential-provider-ini'); -const { fromProcess } = require('@aws-sdk/credential-provider-process'); -const { fromSSO } = require('@aws-sdk/credential-provider-sso'); -const { fromTokenFile } = require('@aws-sdk/credential-provider-web-identity'); -const { chain, CredentialsProviderError, memoize } = require('@aws-sdk/property-provider'); - -const remoteProvider = require('./remote-provider'); - -function defaultProvider(init) { - return memoize( - chain( - ...(init.profile ? [] : [fromEnv()]), - fromSSO(init), - fromIni(init), - fromProcess(init), - fromTokenFile(init), - remoteProvider(init), - async () => { - throw new CredentialsProviderError('Could not load credentials from any providers', false); - } - ), - (credentials) => - credentials.expiration !== undefined && - credentials.expiration.getTime() - Date.now() < 300000, - (credentials) => credentials.expiration !== undefined - ); -} - -module.exports = defaultProvider; diff --git a/src/utils/aws/provider-chain/from-node-provider-chain.js b/src/utils/aws/provider-chain/from-node-provider-chain.js deleted file mode 100644 index 7f140ea..0000000 --- a/src/utils/aws/provider-chain/from-node-provider-chain.js +++ /dev/null @@ -1,23 +0,0 @@ -'use strict'; - -// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 -// credential-provider internals. Kept local so compose owns only the small -// provider chain it actually uses. - -const { - getDefaultRoleAssumer, - getDefaultRoleAssumerWithWebIdentity, -} = require('@aws-sdk/client-sts'); - -const defaultProvider = require('./default-provider'); - -function fromNodeProviderChain(init) { - return defaultProvider({ - ...init, - roleAssumer: init.roleAssumer || getDefaultRoleAssumer(init.clientConfig), - roleAssumerWithWebIdentity: - init.roleAssumerWithWebIdentity || getDefaultRoleAssumerWithWebIdentity(init.clientConfig), - }); -} - -module.exports = fromNodeProviderChain; diff --git a/src/utils/aws/provider-chain/remote-provider.js b/src/utils/aws/provider-chain/remote-provider.js deleted file mode 100644 index 89ddc04..0000000 --- a/src/utils/aws/provider-chain/remote-provider.js +++ /dev/null @@ -1,32 +0,0 @@ -'use strict'; - -// Adapted from @serverless-components/utils-aws@0.1.0 and the AWS SDK v3 -// credential-provider internals. Kept local so compose owns only the small -// provider chain it actually uses. - -const { CredentialsProviderError } = require('@aws-sdk/property-provider'); - -const { - ENV_CMDS_FULL_URI, - ENV_CMDS_RELATIVE_URI, - fromContainerMetadata, - fromInstanceMetadata, -} = require('@aws-sdk/credential-provider-imds'); - -const ENV_IMDS_DISABLED = 'AWS_EC2_METADATA_DISABLED'; - -function remoteProvider(init) { - if (process.env[ENV_CMDS_RELATIVE_URI] || process.env[ENV_CMDS_FULL_URI]) { - return fromContainerMetadata(init); - } - - if (process.env[ENV_IMDS_DISABLED]) { - return async () => { - throw new CredentialsProviderError('EC2 Instance Metadata Service access disabled'); - }; - } - - return fromInstanceMetadata(init); -} - -module.exports = remoteProvider; diff --git a/test/unit/src/index.test.js b/test/unit/src/index.test.js index d8ee416..9899dd3 100644 --- a/test/unit/src/index.test.js +++ b/test/unit/src/index.test.js @@ -31,6 +31,8 @@ describe('test/unit/src/index.test.js', () => { }); const loadRunComponents = (componentsServiceInstances, validateOptions = sinon.stub()) => { + const contextInit = sinon.stub().resolves(); + const contextInstances = []; class FakeContext { constructor(config) { this.root = config.root; @@ -39,10 +41,11 @@ describe('test/unit/src/index.test.js', () => { log: sinon.stub(), }; this.componentCommandsOutcomes = {}; + contextInstances.push(this); } async init() { - return undefined; + return contextInit(); } shutdown() { @@ -54,35 +57,131 @@ describe('test/unit/src/index.test.js', () => { componentsServiceInstances.forEach((instance, index) => { ComponentsService.onCall(index).returns(instance); }); + const renderHelp = sinon.stub().resolves(); + const resolveConfigurationVariables = sinon.stub().resolves(); + const resolveConfigurationPath = sinon.stub().resolves('serverless-compose.yml'); + const readConfiguration = sinon.stub().resolves({ + services: { + api: { + path: 'api', + }, + }, + }); + const validateConfiguration = sinon.stub(); + const handleError = sinon.stub(); + const initializeNodeLogging = sinon.stub(); const listenerSnapshot = snapshotListeners(); listenerSnapshots.push(listenerSnapshot); delete require.cache[require.resolve('../../../src/index.js')]; const { runComponents } = proxyquire.noCallThru().load('../../../src', { 'signal-exit/signals': { signals: moduleSignals }, - './render-help': sinon.stub().resolves(), + './render-help': renderHelp, './Context': FakeContext, './ComponentsService': ComponentsService, - './handle-error': sinon.stub(), - './configuration/resolve-variables': sinon.stub().resolves(), - './configuration/resolve-path': sinon.stub().resolves('serverless-compose.yml'), - './configuration/read': sinon.stub().resolves({ - services: { - api: { - path: 'api', - }, - }, - }), + './handle-error': handleError, + './configuration/resolve-variables': resolveConfigurationVariables, + './configuration/resolve-path': resolveConfigurationPath, + './configuration/read': readConfiguration, './configuration/validate': { - validateConfiguration: sinon.stub(), + validateConfiguration, }, './validate-options': validateOptions, - './utils/serverless-utils/log-reporters/node': sinon.stub(), + './utils/serverless-utils/log-reporters/node': initializeNodeLogging, }); - return { runComponents, validateOptions }; + return { + runComponents, + validateOptions, + ComponentsService, + contextInit, + contextInstances, + renderHelp, + resolveConfigurationVariables, + resolveConfigurationPath, + readConfiguration, + validateConfiguration, + handleError, + initializeNodeLogging, + }; }; + it('renders help for empty argv without resolving configuration', async () => { + const { runComponents, validateOptions, renderHelp, resolveConfigurationPath } = + loadRunComponents([]); + + await runComponents([]); + + expect(renderHelp).to.have.been.calledOnceWithExactly(); + expect(validateOptions).to.not.have.been.called; + expect(resolveConfigurationPath).to.not.have.been.called; + }); + + it('renders help option without validating options or resolving configuration', async () => { + const { runComponents, validateOptions, renderHelp, resolveConfigurationPath } = + loadRunComponents([]); + + await runComponents(['--help']); + + expect(renderHelp).to.have.been.calledOnceWithExactly(); + expect(validateOptions).to.not.have.been.called; + expect(resolveConfigurationPath).to.not.have.been.called; + }); + + it('rejects unsupported native options before configuration or state initialization', async () => { + const validateOptions = sinon.spy(require('../../../src/validate-options')); + const { + runComponents, + contextInit, + contextInstances, + resolveConfigurationPath, + readConfiguration, + resolveConfigurationVariables, + ComponentsService, + } = loadRunComponents([], validateOptions); + + let caughtError; + try { + await runComponents(['deploy', '--aws-profile', 'prod']); + } catch (error) { + caughtError = error; + } + + expect(caughtError).to.have.property('code', 'UNRECOGNIZED_CLI_OPTIONS'); + expect(validateOptions).to.have.been.calledOnceWithExactly( + sinon.match({ 'aws-profile': 'prod' }), + 'deploy' + ); + expect(resolveConfigurationPath).to.not.have.been.called; + expect(readConfiguration).to.not.have.been.called; + expect(resolveConfigurationVariables).to.not.have.been.called; + expect(contextInstances).to.have.length(0); + expect(contextInit).to.not.have.been.called; + expect(ComponentsService).to.not.have.been.called; + }); + + it('rejects unsupported region option before configuration or state initialization', async () => { + const validateOptions = sinon.spy(require('../../../src/validate-options')); + const { runComponents, contextInit, contextInstances, resolveConfigurationPath } = + loadRunComponents([], validateOptions); + + let caughtError; + try { + await runComponents(['deploy', '--region', 'eu-west-1']); + } catch (error) { + caughtError = error; + } + + expect(caughtError).to.have.property('code', 'UNRECOGNIZED_CLI_OPTIONS'); + expect(validateOptions).to.have.been.calledOnceWithExactly( + sinon.match({ region: 'eu-west-1' }), + 'deploy' + ); + expect(resolveConfigurationPath).to.not.have.been.called; + expect(contextInstances).to.have.length(0); + expect(contextInit).to.not.have.been.called; + }); + it('preserves nested shortcut service commands', async () => { const componentsServiceInstance = { init: sinon.stub().resolves(), @@ -136,4 +235,32 @@ describe('test/unit/src/index.test.js', () => { expect(componentsServiceInstance.invokeGlobalCommand.called).to.equal(false); expect(processExit).to.have.been.calledOnceWithExactly(0); }); + + it('allows Framework options for nested passthrough commands after normalization', async () => { + const componentsServiceInstance = { + init: sinon.stub().resolves(), + invokeComponentCommand: sinon.stub().resolves(), + invokeGlobalCommand: sinon.stub().resolves(), + allComponents: {}, + }; + const validateOptions = sinon.spy(require('../../../src/validate-options')); + const { runComponents } = loadRunComponents([componentsServiceInstance], validateOptions); + const processExit = sinon.stub(process, 'exit'); + sinon.stub(process, 'getMaxListeners').returns(10); + sinon.stub(process, 'setMaxListeners'); + + await runComponents(['api:deploy:function', '--region', 'eu-west-1', '--aws-profile', 'dev']); + + expect(validateOptions).to.have.been.calledOnceWithExactly( + sinon.match({ 'region': 'eu-west-1', 'aws-profile': 'dev' }), + 'deploy:function' + ); + expect(componentsServiceInstance.invokeComponentCommand).to.have.been.calledOnceWithExactly( + 'api', + 'deploy:function', + sinon.match({ 'region': 'eu-west-1', 'aws-profile': 'dev' }) + ); + expect(componentsServiceInstance.invokeGlobalCommand.called).to.equal(false); + expect(processExit).to.have.been.calledOnceWithExactly(0); + }); }); diff --git a/test/unit/src/state/S3StateStorage.test.js b/test/unit/src/state/S3StateStorage.test.js index 61286f2..933f213 100644 --- a/test/unit/src/state/S3StateStorage.test.js +++ b/test/unit/src/state/S3StateStorage.test.js @@ -95,6 +95,18 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => { }); }); + it('gracefully handles SDK v3 NoSuchKey errors when state file in S3 is not present', async () => { + const s3StateStorage = new S3StateStorage({ bucketName, stateKey }); + const getError = new Error(); + getError.name = 'NoSuchKey'; + const mockedS3Client = { + getObject: sinon.stub().rejects(getError), + }; + s3StateStorage.s3Client = mockedS3Client; + const result = await s3StateStorage.readState(); + expect(result).to.deep.equal({}); + }); + it('rejects if error other than NoSuchKey has been reported when reading state from S3', async () => { const s3StateStorage = new S3StateStorage({ bucketName, stateKey }); const mockedS3Client = { @@ -135,7 +147,7 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => { }); }); - it('passes region and credentials into the S3 client constructor', () => { + it('passes full client config into the S3 client constructor', () => { const S3 = sinon.stub().returns({}); const S3StateStorageWithStubbedClient = proxyquire .noCallThru() @@ -147,13 +159,18 @@ describe('test/unit/src/state/S3StateStorage.test.js', () => { bucketName, stateKey, region: 'eu-central-1', - credentials: 'creds', + clientConfig: { + region: 'eu-central-1', + credentials: 'creds', + retryMode: 'standard', + }, }); expect(stateStorage).to.be.instanceOf(S3StateStorageWithStubbedClient); expect(S3).to.have.been.calledOnceWithExactly({ region: 'eu-central-1', credentials: 'creds', + retryMode: 'standard', }); }); diff --git a/test/unit/src/state/get-s3-state-storage-from-config.test.js b/test/unit/src/state/get-s3-state-storage-from-config.test.js index 400e725..13c0ec4 100644 --- a/test/unit/src/state/get-s3-state-storage-from-config.test.js +++ b/test/unit/src/state/get-s3-state-storage-from-config.test.js @@ -15,7 +15,8 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { const getStateBucketName = sinon.stub().resolves('managed-bucket'); const getConfiguredStateBucketName = sinon.stub().returns(null); const getStateBucketRegion = sinon.stub(); - const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + const awsClientConfig = { region: 'us-east-1', credentials: 'creds', retryMode: 'standard' }; + const getAwsClientConfig = sinon.stub().returns(awsClientConfig); class S3StateStorage { constructor(config) { @@ -44,12 +45,13 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ profile: 'team', region: 'us-east-1', + stage: 'prod', }); expect(stateStorage.config).to.deep.equal({ bucketName: 'managed-bucket', stateKey: 'custom/prod/state.json', region: 'us-east-1', - credentials: 'creds', + clientConfig: awsClientConfig, }); }); @@ -62,7 +64,8 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { const getStateBucketName = sinon.stub().resolves('provided-bucket'); const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket'); const getStateBucketRegion = sinon.stub().resolves('eu-central-1'); - const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + const awsClientConfig = { region: 'eu-central-1', credentials: 'creds', retryMode: 'standard' }; + const getAwsClientConfig = sinon.stub().returns(awsClientConfig); class S3StateStorage { constructor(config) { @@ -84,17 +87,19 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { expect(getStateBucketRegion).to.have.been.calledOnceWithExactly( 'provided-bucket', - stateConfiguration + stateConfiguration, + { stage: 'dev' } ); expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ profile: 'team', region: 'eu-central-1', + stage: 'dev', }); expect(stateStorage.config).to.deep.equal({ bucketName: 'provided-bucket', stateKey: 'dev/state.json', region: 'eu-central-1', - credentials: 'creds', + clientConfig: awsClientConfig, }); }); @@ -107,7 +112,8 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { const getStateBucketName = sinon.stub().resolves('provided-bucket'); const getConfiguredStateBucketName = sinon.stub().returns('provided-bucket'); const getStateBucketRegion = sinon.stub().resolves('eu-central-1'); - const getAwsClientConfig = sinon.stub().returns({ credentials: 'creds' }); + const awsClientConfig = { region: 'eu-central-1', credentials: 'creds', retryMode: 'standard' }; + const getAwsClientConfig = sinon.stub().returns(awsClientConfig); class S3StateStorage { constructor(config) { @@ -129,17 +135,19 @@ describe('test/unit/src/state/get-s3-state-storage-from-config.test.js', () => { expect(getStateBucketRegion).to.have.been.calledOnceWithExactly( 'provided-bucket', - stateConfiguration + stateConfiguration, + { stage: 'dev' } ); expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ profile: 'team', region: 'eu-central-1', + stage: 'dev', }); expect(stateStorage.config).to.deep.equal({ bucketName: 'provided-bucket', stateKey: 'dev/state.json', region: 'eu-central-1', - credentials: 'creds', + clientConfig: awsClientConfig, }); }); }); diff --git a/test/unit/src/state/utils/get-state-bucket-name.test.js b/test/unit/src/state/utils/get-state-bucket-name.test.js index 185dad5..d6a7015 100644 --- a/test/unit/src/state/utils/get-state-bucket-name.test.js +++ b/test/unit/src/state/utils/get-state-bucket-name.test.js @@ -76,6 +76,23 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => { ).to.be.true; }); + it('handles SDK v3 ValidationError names when bucket stack has to be created', async () => { + const configuration = { backend: 's3' }; + const stackDoesNotExistError = new Error('Stack "test" does not exist'); + stackDoesNotExistError.name = 'ValidationError'; + cfMock + .on(DescribeStackResourceCommand) + .rejectsOnce(stackDoesNotExistError) + .on(CreateStackCommand) + .resolves() + .on(DescribeStacksCommand) + .resolves({ Stacks: [{ StackStatus: 'CREATE_COMPLETE' }] }); + + expect( + (await getStateBucketName(configuration, context)).startsWith('serverless-compose-state-') + ).to.be.true; + }); + it('handles unexpected error when resolving bucket from s3', async () => { const configuration = { backend: 's3' }; const unknownError = new Error('unknown error'); @@ -113,6 +130,7 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => { const getAwsClientConfig = sinon.stub().returns({ region: 'us-east-1', credentials: 'creds', + retryMode: 'standard', }); const getStateBucketNameWithStubs = proxyquire @@ -128,10 +146,12 @@ describe('test/unit/src/state/utils/get-state-bucket-name.test.js', () => { expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ profile: 'team', region: 'us-east-1', + stage: 'dev', }); expect(CloudFormation).to.have.been.calledOnceWithExactly({ region: 'us-east-1', credentials: 'creds', + retryMode: 'standard', }); }); }); diff --git a/test/unit/src/state/utils/get-state-bucket-region.test.js b/test/unit/src/state/utils/get-state-bucket-region.test.js index 44f2de4..68d5370 100644 --- a/test/unit/src/state/utils/get-state-bucket-region.test.js +++ b/test/unit/src/state/utils/get-state-bucket-region.test.js @@ -48,6 +48,17 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { ); }); + it('rejects when SDK v3 reports bucket cannot be found by error name', async () => { + const bucketDoesNotExistError = new Error('No such bucket'); + bucketDoesNotExistError.name = 'NoSuchBucket'; + + s3Mock.on(GetBucketLocationCommand).rejects(bucketDoesNotExistError); + await expect(getStateBucketRegion(bucketName)).to.be.eventually.rejected.and.have.property( + 'code', + 'CANNOT_FIND_PROVIDED_REMOTE_STATE_BUCKET' + ); + }); + it('rejects when access to bucket is denied', async () => { const bucketCannotBeAccessedError = new Error('No such bucket'); bucketCannotBeAccessedError.Code = 'AccessDenied'; @@ -59,6 +70,17 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { ); }); + it('rejects when SDK v3 reports bucket access denial by error name', async () => { + const bucketCannotBeAccessedError = new Error('No such bucket'); + bucketCannotBeAccessedError.name = 'AccessDenied'; + + s3Mock.on(GetBucketLocationCommand).rejects(bucketCannotBeAccessedError); + await expect(getStateBucketRegion(bucketName)).to.be.eventually.rejected.and.have.property( + 'code', + 'CANNOT_ACCESS_PROVIDED_REMOTE_STATE_BUCKET' + ); + }); + it('rejects on generic error', async () => { s3Mock.on(GetBucketLocationCommand).rejects(new Error('failure')); await expect(getStateBucketRegion(bucketName)).to.be.eventually.rejected.and.have.property( @@ -73,6 +95,7 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { const getAwsClientConfig = sinon.stub().returns({ region: 'us-east-1', credentials: 'creds', + retryMode: 'standard', }); const getStateBucketRegionWithStubs = proxyquire @@ -82,13 +105,18 @@ describe('test/unit/src/state/utils/get-state-bucket-region.test.js', () => { '../../utils/aws': { getAwsClientConfig }, }); - expect(await getStateBucketRegionWithStubs(bucketName, { profile: 'team' })).to.equal( - 'eu-central-1' - ); + expect( + await getStateBucketRegionWithStubs(bucketName, { profile: 'team' }, { stage: 'prod' }) + ).to.equal('eu-central-1'); expect(getAwsClientConfig).to.have.been.calledOnceWithExactly({ profile: 'team', region: 'us-east-1', + stage: 'prod', + }); + expect(S3).to.have.been.calledOnceWithExactly({ + region: 'us-east-1', + credentials: 'creds', + retryMode: 'standard', }); - expect(S3).to.have.been.calledOnceWithExactly({ region: 'us-east-1', credentials: 'creds' }); }); }); diff --git a/test/unit/src/utils/aws/config.test.js b/test/unit/src/utils/aws/config.test.js new file mode 100644 index 0000000..9e51396 --- /dev/null +++ b/test/unit/src/utils/aws/config.test.js @@ -0,0 +1,184 @@ +'use strict'; + +const chai = require('chai'); +const proxyquire = require('proxyquire'); + +const { expect } = chai; + +describe('test/unit/src/utils/aws/config.test.js', () => { + const envKeys = [ + 'AWS_REGION', + 'AWS_DEFAULT_REGION', + 'SLS_AWS_REQUEST_MAX_RETRIES', + 'AWS_CLIENT_TIMEOUT', + 'aws_client_timeout', + 'proxy', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'ca', + 'HTTPS_CA', + 'https_ca', + 'cafile', + 'HTTPS_CAFILE', + 'https_cafile', + ]; + + async function withEnv(callback) { + const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); + + for (const key of envKeys) delete process.env[key]; + + try { + return await callback(); + } finally { + for (const key of envKeys) { + const value = originalEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + function loadConfig() { + function FakeNodeHttpHandler(options) { + this.options = options; + } + + function FakeHttpsProxyAgent(proxy, options) { + this.proxy = proxy; + this.options = options; + } + + class FakeHttpsAgent { + constructor(options) { + this.options = options; + } + } + + return proxyquire('../../../../../src/utils/aws/config', { + '@smithy/node-http-handler': { NodeHttpHandler: FakeNodeHttpHandler }, + 'https-proxy-agent': { HttpsProxyAgent: FakeHttpsProxyAgent }, + 'https': { Agent: FakeHttpsAgent }, + }); + } + + it('preserves explicit maxAttempts values including zero', async () => { + await withEnv(async () => { + const { buildClientConfig } = loadConfig(); + + expect(buildClientConfig({ maxAttempts: 0 }).maxAttempts).to.equal(0); + expect(buildClientConfig({ maxAttempts: 1 }).maxAttempts).to.equal(1); + }); + }); + + it('maps Serverless retry count to SDK v3 maxAttempts', async () => { + await withEnv(async () => { + const { buildClientConfig } = loadConfig(); + + expect(buildClientConfig().maxAttempts).to.equal(5); + + process.env.SLS_AWS_REQUEST_MAX_RETRIES = '0'; + expect(buildClientConfig().maxAttempts).to.equal(1); + + process.env.SLS_AWS_REQUEST_MAX_RETRIES = '2'; + expect(buildClientConfig().maxAttempts).to.equal(3); + }); + }); + + it('falls back to environment region only when region is undefined', async () => { + await withEnv(async () => { + process.env.AWS_REGION = 'eu-west-1'; + const { buildClientConfig } = loadConfig(); + + expect(buildClientConfig().region).to.equal('eu-west-1'); + expect(buildClientConfig({ region: undefined }).region).to.equal('eu-west-1'); + expect(buildClientConfig({ region: '' }).region).to.equal(''); + expect(buildClientConfig({ region: null }).region).to.equal(null); + }); + }); + + it('uses AWS_DEFAULT_REGION before hardcoded us-east-1 fallback', async () => { + await withEnv(async () => { + const { buildClientConfig } = loadConfig(); + + expect(buildClientConfig().region).to.equal('us-east-1'); + + process.env.AWS_DEFAULT_REGION = 'ap-south-1'; + expect(buildClientConfig().region).to.equal('ap-south-1'); + + process.env.AWS_REGION = 'eu-west-1'; + expect(buildClientConfig().region).to.equal('eu-west-1'); + }); + }); + + it('uses NodeHttpHandler for timeout config', async () => { + await withEnv(async () => { + process.env.AWS_CLIENT_TIMEOUT = '1234'; + const { buildClientConfig } = loadConfig(); + + const config = buildClientConfig(); + + expect(config.requestHandler.options).to.deep.equal({ requestTimeout: 1234 }); + }); + }); + + it('preserves explicit zero timeout config', async () => { + await withEnv(async () => { + process.env.AWS_CLIENT_TIMEOUT = '0'; + const { buildClientConfig } = loadConfig(); + + const config = buildClientConfig(); + + expect(config.requestHandler.options).to.deep.equal({ requestTimeout: 0 }); + }); + }); + + it('passes proxy and CA options when constructing the proxy agent', async () => { + await withEnv(async () => { + process.env.HTTPS_PROXY = 'https://proxy.example.com:1234'; + process.env.HTTPS_CA = 'certificate'; + const { buildClientConfig } = loadConfig(); + + const config = buildClientConfig(); + + expect(config.requestHandler.options.httpsAgent.proxy).to.equal( + 'https://proxy.example.com:1234' + ); + expect(config.requestHandler.options.httpsAgent.options).to.include({ + rejectUnauthorized: true, + }); + expect(config.requestHandler.options.httpsAgent.options.ca).to.deep.equal(['certificate']); + }); + }); + + it('passes custom user agent config through', async () => { + await withEnv(async () => { + const { buildClientConfig } = loadConfig(); + + expect(buildClientConfig({ customUserAgent: 'custom-agent' }).customUserAgent).to.equal( + 'custom-agent' + ); + }); + }); + + it('passes SDK v3 client options through', async () => { + await withEnv(async () => { + const { buildClientConfig } = loadConfig(); + const requestHandler = {}; + + expect( + buildClientConfig({ + endpoint: 'http://localhost:4566', + forcePathStyle: true, + requestHandler, + }) + ).to.include({ + endpoint: 'http://localhost:4566', + forcePathStyle: true, + requestHandler, + }); + }); + }); +}); diff --git a/test/unit/src/utils/aws/credentials.test.js b/test/unit/src/utils/aws/credentials.test.js new file mode 100644 index 0000000..38aecdf --- /dev/null +++ b/test/unit/src/utils/aws/credentials.test.js @@ -0,0 +1,527 @@ +'use strict'; + +const chai = require('chai'); +const path = require('path'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +const { expect } = chai; + +describe('test/unit/src/utils/aws/credentials.test.js', () => { + const homeDir = path.resolve('/home/test'); + const credentialsFilePath = path.join(homeDir, '.aws', 'credentials'); + const configFilePath = path.join(homeDir, '.aws', 'config'); + const envKeys = [ + 'AWS_PROFILE', + 'AWS_DEFAULT_PROFILE', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_DEV_PROFILE', + 'AWS_DEV_ACCESS_KEY_ID', + 'AWS_DEV_SECRET_ACCESS_KEY', + 'AWS_DEV_SESSION_TOKEN', + 'AWS_SHARED_CREDENTIALS_FILE', + 'AWS_CONFIG_FILE', + ]; + + async function withEnv(callback) { + const originalEnv = new Map(envKeys.map((key) => [key, process.env[key]])); + + for (const key of envKeys) delete process.env[key]; + + try { + return await callback(); + } finally { + for (const key of envKeys) { + const value = originalEnv.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + } + + function createMissingFileError() { + return Object.assign(new Error('missing'), { code: 'ENOENT' }); + } + + function createUnresolvedProfileError(profile) { + return Object.assign( + new Error( + `Could not resolve credentials using profile: [${profile}] in configuration/credentials file(s).` + ), + { name: 'CredentialsProviderError' } + ); + } + + function loadCredentials({ files = {}, fromIni, fromNodeProviderChain } = {}) { + const readFileSync = sinon.stub().callsFake((filePath) => { + if (Object.prototype.hasOwnProperty.call(files, filePath)) { + const result = files[filePath]; + if (result instanceof Error) throw result; + return result; + } + throw createMissingFileError(); + }); + + return proxyquire('../../../../../src/utils/aws/credentials', { + '@aws-sdk/credential-providers': { + fromIni, + fromNodeProviderChain, + }, + 'fs': { readFileSync }, + 'os': { homedir: () => homeDir }, + }); + } + + afterEach(() => { + sinon.restore(); + }); + + it('does not mutate AWS_PROFILE when AWS_DEFAULT_PROFILE is set', async () => { + await withEnv(async () => { + process.env.AWS_DEFAULT_PROFILE = 'custom-default'; + const profileProvider = sinon.stub().resolves({ + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + }); + const fromIni = sinon.stub().returns(profileProvider); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + getCredentialProvider(); + + expect(process.env.AWS_PROFILE).to.equal(undefined); + expect(fromIni.firstCall.args[0]).to.include({ profile: 'custom-default' }); + expect(fromNodeProviderChain).to.not.have.been.called; + }); + }); + + it('uses explicit state profile before stage profile', async () => { + await withEnv(async () => { + process.env.AWS_DEV_PROFILE = 'stage-profile'; + const fromIni = sinon.stub().callsFake(({ profile }) => `${profile}-provider`); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + expect(getCredentialProvider({ profile: 'state-profile', stage: 'dev' })).to.equal( + 'state-profile-provider' + ); + expect(fromIni.firstCall.args[0]).to.include({ + profile: 'state-profile', + filepath: credentialsFilePath, + configFilepath: configFilePath, + }); + expect(fromIni.firstCall.args[0].mfaCodeProvider).to.be.a('function'); + }); + }); + + it('uses stage profile before stage environment credentials', async () => { + await withEnv(async () => { + process.env.AWS_DEV_PROFILE = 'stage-profile'; + process.env.AWS_DEV_ACCESS_KEY_ID = 'stageAccessKeyId'; + process.env.AWS_DEV_SECRET_ACCESS_KEY = 'stageSecretAccessKey'; + const fromIni = sinon.stub().callsFake(({ profile }) => `${profile}-provider`); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + expect(getCredentialProvider({ stage: 'dev' })).to.equal('stage-profile-provider'); + }); + }); + + it('uses stage environment credentials before AWS_PROFILE', async () => { + await withEnv(async () => { + process.env.AWS_DEV_ACCESS_KEY_ID = 'stageAccessKeyId'; + process.env.AWS_DEV_SECRET_ACCESS_KEY = 'stageSecretAccessKey'; + process.env.AWS_DEV_SESSION_TOKEN = 'stageSessionToken'; + process.env.AWS_PROFILE = 'aws-profile'; + const fromIni = sinon.stub(); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider({ stage: 'dev' })()).to.eventually.deep.equal({ + accessKeyId: 'stageAccessKeyId', + secretAccessKey: 'stageSecretAccessKey', + sessionToken: 'stageSessionToken', + }); + expect(fromIni).to.not.have.been.called; + }); + }); + + it('uses AWS_PROFILE before standard environment credentials', async () => { + await withEnv(async () => { + process.env.AWS_PROFILE = 'aws-profile'; + process.env.AWS_ACCESS_KEY_ID = 'accessKeyId'; + process.env.AWS_SECRET_ACCESS_KEY = 'secretAccessKey'; + const fromIni = sinon.stub().callsFake(({ profile }) => `${profile}-provider`); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + expect(getCredentialProvider()).to.equal('aws-profile-provider'); + }); + }); + + it('uses standard environment credentials before AWS_DEFAULT_PROFILE', async () => { + await withEnv(async () => { + process.env.AWS_ACCESS_KEY_ID = 'accessKeyId'; + process.env.AWS_SECRET_ACCESS_KEY = 'secretAccessKey'; + process.env.AWS_DEFAULT_PROFILE = 'custom-default'; + const fromIni = sinon.stub(); + const fromNodeProviderChain = sinon.stub(); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider()()).to.eventually.deep.equal({ + accessKeyId: 'accessKeyId', + secretAccessKey: 'secretAccessKey', + sessionToken: undefined, + }); + expect(fromIni).to.not.have.been.called; + }); + }); + + it('falls back from the implicit default profile only when the profile is absent', async () => { + await withEnv(async () => { + const fallbackCredentials = { + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }; + const fallbackProvider = sinon.stub().resolves(fallbackCredentials); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider()()).to.eventually.deep.equal(fallbackCredentials); + expect(fromNodeProviderChain).to.have.been.calledOnce; + expect(fallbackProvider).to.have.been.calledOnce; + }); + }); + + it('forwards provider invocation options when using default fallback', async () => { + await withEnv(async () => { + const providerOptions = { callerClientConfig: { region: 'eu-west-1' } }; + const fallbackCredentials = { + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }; + const profileProvider = sinon.stub().rejects(createUnresolvedProfileError('default')); + const fallbackProvider = sinon.stub().resolves(fallbackCredentials); + const fromIni = sinon.stub().returns(profileProvider); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider()(providerOptions)).to.eventually.deep.equal( + fallbackCredentials + ); + expect(profileProvider).to.have.been.calledOnceWithExactly(providerOptions); + expect(fallbackProvider).to.have.been.calledOnceWithExactly(providerOptions); + }); + }); + + it('does not fallback when the implicit default profile exists but is malformed', async () => { + await withEnv(async () => { + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [credentialsFilePath]: ['[default]', 'aws_access_key_id = accessKeyId'].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when a malformed default profile is loaded from a tilde credentials path', async () => { + await withEnv(async () => { + process.env.AWS_SHARED_CREDENTIALS_FILE = '~/.aws/credentials'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [credentialsFilePath]: ['[default]', 'aws_access_key_id = accessKeyId'].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromIni.firstCall.args[0]).to.include({ + filepath: credentialsFilePath, + configFilepath: configFilePath, + }); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when a malformed default profile is loaded from a tilde config path', async () => { + await withEnv(async () => { + process.env.AWS_CONFIG_FILE = '~/.aws/config'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [configFilePath]: ['[profile default]', 'custom_field = value'].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromIni.firstCall.args[0]).to.include({ + filepath: credentialsFilePath, + configFilepath: configFilePath, + }); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when AWS_DEFAULT_PROFILE is explicitly set but absent', async () => { + await withEnv(async () => { + process.env.AWS_DEFAULT_PROFILE = 'missing-default'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('missing-default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when AWS_DEFAULT_PROFILE exists but is malformed', async () => { + await withEnv(async () => { + process.env.AWS_DEFAULT_PROFILE = 'custom-default'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('custom-default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [credentialsFilePath]: ['[custom-default]', 'aws_access_key_id = accessKeyId'].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromIni.firstCall.args[0]).to.include({ profile: 'custom-default' }); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when AWS_DEFAULT_PROFILE exists as a quoted config profile', async () => { + await withEnv(async () => { + process.env.AWS_DEFAULT_PROFILE = 'custom-default'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('custom-default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [configFilePath]: ['[profile "custom-default"]', 'custom_field = value'].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromIni.firstCall.args[0]).to.include({ profile: 'custom-default' }); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback when AWS_DEFAULT_PROFILE exists as an SSO config profile', async () => { + await withEnv(async () => { + process.env.AWS_DEFAULT_PROFILE = 'custom-default'; + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const originalError = Object.assign(new Error('SSO session has expired'), { + name: 'CredentialsProviderError', + }); + const fromIni = sinon.stub().returns(sinon.stub().rejects(originalError)); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { + [configFilePath]: [ + '[profile custom-default]', + 'sso_session = my-sso', + 'sso_account_id = 123456789012', + 'sso_role_name = Admin', + '[sso-session my-sso]', + 'sso_region = us-east-1', + 'sso_start_url = https://example.awsapps.com/start', + 'sso_registration_scopes = sso:account:access', + ].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith('SSO session has expired'); + expect(fromIni.firstCall.args[0]).to.include({ profile: 'custom-default' }); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('does not fallback for explicit state profiles', async () => { + await withEnv(async () => { + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('custom'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ fromIni, fromNodeProviderChain }); + + await expect(getCredentialProvider({ profile: 'custom' })()).to.be.rejectedWith( + 'Could not resolve credentials using profile' + ); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); + + it('detects implicit default profiles from credentials and config files', async () => { + await withEnv(async () => { + const fromIni = sinon.stub(); + const fromNodeProviderChain = sinon.stub(); + for (const [description, files] of [ + [ + 'credentials default', + { + [credentialsFilePath]: ['[default]', 'aws_access_key_id = accessKeyId'].join('\n'), + }, + ], + ['config default', { [configFilePath]: ['[default]', 'region = us-east-1'].join('\n') }], + [ + 'config profile default', + { [configFilePath]: ['[profile default]', 'region = us-east-1'].join('\n') }, + ], + [ + 'config double-quoted profile default', + { [configFilePath]: ['[profile "default"]', 'region = us-east-1'].join('\n') }, + ], + [ + 'config single-quoted profile default', + { [configFilePath]: ["[profile 'default']", 'region = us-east-1'].join('\n') }, + ], + ]) { + const { doesImplicitDefaultProfileExist } = loadCredentials({ + files, + fromIni, + fromNodeProviderChain, + }); + + expect(doesImplicitDefaultProfileExist(), description).to.equal(true); + } + }); + }); + + it('does not detect non-default profiles as implicit default profiles', async () => { + await withEnv(async () => { + const fromIni = sinon.stub(); + const fromNodeProviderChain = sinon.stub(); + const { doesImplicitDefaultProfileExist } = loadCredentials({ + files: { + [credentialsFilePath]: ['[credentials-profile]', 'aws_access_key_id = accessKeyId'].join( + '\n' + ), + [configFilePath]: [ + '[profile custom]', + 'region = us-east-1', + '[profile "quoted"]', + 'region = us-east-1', + '[raw-config]', + 'region = us-east-1', + ].join('\n'), + }, + fromIni, + fromNodeProviderChain, + }); + + expect(doesImplicitDefaultProfileExist()).to.equal(false); + }); + }); + + it('does not fallback when implicit default profile detection cannot read shared files', async () => { + await withEnv(async () => { + const readError = Object.assign(new Error('permission denied'), { code: 'EACCES' }); + const fallbackProvider = sinon.stub().resolves({ + accessKeyId: 'fallbackAccessKeyId', + secretAccessKey: 'fallbackSecretAccessKey', + }); + const fromIni = sinon + .stub() + .returns(sinon.stub().rejects(createUnresolvedProfileError('default'))); + const fromNodeProviderChain = sinon.stub().returns(fallbackProvider); + const { getCredentialProvider } = loadCredentials({ + files: { [credentialsFilePath]: readError }, + fromIni, + fromNodeProviderChain, + }); + + await expect(getCredentialProvider()()).to.be.rejectedWith('permission denied'); + expect(fromNodeProviderChain).to.not.have.been.called; + expect(fallbackProvider).to.not.have.been.called; + }); + }); +}); diff --git a/test/unit/src/utils/aws/default-provider.test.js b/test/unit/src/utils/aws/default-provider.test.js deleted file mode 100644 index 013698d..0000000 --- a/test/unit/src/utils/aws/default-provider.test.js +++ /dev/null @@ -1,95 +0,0 @@ -'use strict'; - -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); - -const expect = chai.expect; - -describe('test/unit/src/utils/aws/default-provider.test.js', () => { - const getStubs = () => { - const envProvider = sinon.stub().returns('env-provider'); - const ssoProvider = sinon.stub().returns('sso-provider'); - const iniProvider = sinon.stub().returns('ini-provider'); - const processProvider = sinon.stub().returns('process-provider'); - const tokenProvider = sinon.stub().returns('token-provider'); - const remoteProvider = sinon.stub().returns('remote-provider'); - const chain = sinon.stub().callsFake((...providers) => providers); - const memoize = sinon.stub().callsFake((provider) => provider); - - const defaultProvider = proxyquire - .noCallThru() - .load('../../../../../src/utils/aws/provider-chain/default-provider', { - '@aws-sdk/credential-provider-env': { fromEnv: envProvider }, - '@aws-sdk/credential-provider-ini': { fromIni: iniProvider }, - '@aws-sdk/credential-provider-process': { fromProcess: processProvider }, - '@aws-sdk/credential-provider-sso': { fromSSO: ssoProvider }, - '@aws-sdk/credential-provider-web-identity': { fromTokenFile: tokenProvider }, - '@aws-sdk/property-provider': { - chain, - memoize, - CredentialsProviderError: class CredentialsProviderError extends Error {}, - }, - './remote-provider': remoteProvider, - }); - - return { - defaultProvider, - envProvider, - ssoProvider, - iniProvider, - processProvider, - tokenProvider, - remoteProvider, - chain, - memoize, - }; - }; - - afterEach(() => { - sinon.restore(); - }); - - it('keeps env credentials first when no explicit profile is provided', () => { - const { - defaultProvider, - envProvider, - ssoProvider, - iniProvider, - processProvider, - tokenProvider, - remoteProvider, - chain, - memoize, - } = getStubs(); - const init = { region: 'us-east-1' }; - - const provider = defaultProvider(init); - - expect(provider).to.deep.equal(chain.firstCall.args); - expect(envProvider).to.have.been.calledOnceWithExactly(); - expect(ssoProvider).to.have.been.calledOnceWithExactly(init); - expect(iniProvider).to.have.been.calledOnceWithExactly(init); - expect(processProvider).to.have.been.calledOnceWithExactly(init); - expect(tokenProvider).to.have.been.calledOnceWithExactly(init); - expect(remoteProvider).to.have.been.calledOnceWithExactly(init); - expect(chain.firstCall.args.slice(0, 6)).to.deep.equal([ - 'env-provider', - 'sso-provider', - 'ini-provider', - 'process-provider', - 'token-provider', - 'remote-provider', - ]); - expect(memoize).to.have.been.calledOnce; - }); - - it('skips env credentials when an explicit profile is provided', () => { - const { defaultProvider, envProvider, chain } = getStubs(); - - defaultProvider({ profile: 'team' }); - - expect(envProvider.called).to.equal(false); - expect(chain.firstCall.args[0]).to.equal('sso-provider'); - }); -}); diff --git a/test/unit/src/utils/aws/from-node-provider-chain.test.js b/test/unit/src/utils/aws/from-node-provider-chain.test.js deleted file mode 100644 index 8e37528..0000000 --- a/test/unit/src/utils/aws/from-node-provider-chain.test.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); - -const expect = chai.expect; - -describe('test/unit/src/utils/aws/from-node-provider-chain.test.js', () => { - afterEach(() => { - sinon.restore(); - }); - - it('injects default role assumers derived from clientConfig', () => { - const getDefaultRoleAssumer = sinon.stub().returns('roleAssumer'); - const getDefaultRoleAssumerWithWebIdentity = sinon.stub().returns('webIdentityAssumer'); - const defaultProvider = sinon.stub().returns('provider'); - - const fromNodeProviderChain = proxyquire - .noCallThru() - .load('../../../../../src/utils/aws/provider-chain/from-node-provider-chain', { - '@aws-sdk/client-sts': { - getDefaultRoleAssumer, - getDefaultRoleAssumerWithWebIdentity, - }, - './default-provider': defaultProvider, - }); - - expect( - fromNodeProviderChain({ profile: 'team', clientConfig: { region: 'us-east-1' } }) - ).to.equal('provider'); - expect(getDefaultRoleAssumer).to.have.been.calledOnceWithExactly({ region: 'us-east-1' }); - expect(getDefaultRoleAssumerWithWebIdentity).to.have.been.calledOnceWithExactly({ - region: 'us-east-1', - }); - expect(defaultProvider).to.have.been.calledOnceWithExactly({ - profile: 'team', - clientConfig: { region: 'us-east-1' }, - roleAssumer: 'roleAssumer', - roleAssumerWithWebIdentity: 'webIdentityAssumer', - }); - }); -}); diff --git a/test/unit/src/utils/aws/get-client-config.test.js b/test/unit/src/utils/aws/get-client-config.test.js index c5839d9..883cfb7 100644 --- a/test/unit/src/utils/aws/get-client-config.test.js +++ b/test/unit/src/utils/aws/get-client-config.test.js @@ -11,21 +11,52 @@ describe('test/unit/src/utils/aws/get-client-config.test.js', () => { sinon.restore(); }); - it('returns the region and credentials together', () => { + it('builds client config with resolved credentials', () => { const getCredentialProvider = sinon.stub().returns('creds'); + const buildClientConfig = sinon.stub().returns('client-config'); const getClientConfig = proxyquire .noCallThru() .load('../../../../../src/utils/aws/get-client-config', { './get-credential-provider': getCredentialProvider, + './config': { buildClientConfig }, }); - expect(getClientConfig({ profile: 'team', region: 'eu-central-1' })).to.deep.equal({ - region: 'eu-central-1', - credentials: 'creds', - }); + expect( + getClientConfig({ + profile: 'team', + region: 'eu-central-1', + stage: 'prod', + endpoint: 'http://localhost:4566', + }) + ).to.equal('client-config'); expect(getCredentialProvider).to.have.been.calledOnceWithExactly({ profile: 'team', + stage: 'prod', + }); + expect(buildClientConfig).to.have.been.calledOnceWithExactly({ + endpoint: 'http://localhost:4566', region: 'eu-central-1', + credentials: 'creds', + }); + }); + + it('preserves explicit credentials without resolving a provider', () => { + const getCredentialProvider = sinon.stub(); + const buildClientConfig = sinon.stub().returns('client-config'); + const getClientConfig = proxyquire + .noCallThru() + .load('../../../../../src/utils/aws/get-client-config', { + './get-credential-provider': getCredentialProvider, + './config': { buildClientConfig }, + }); + + expect(getClientConfig({ credentials: 'explicit-creds', region: 'us-east-1' })).to.equal( + 'client-config' + ); + expect(getCredentialProvider).to.not.have.been.called; + expect(buildClientConfig).to.have.been.calledOnceWithExactly({ + region: 'us-east-1', + credentials: 'explicit-creds', }); }); }); diff --git a/test/unit/src/utils/aws/get-credential-provider.test.js b/test/unit/src/utils/aws/get-credential-provider.test.js index 1430685..6431b7f 100644 --- a/test/unit/src/utils/aws/get-credential-provider.test.js +++ b/test/unit/src/utils/aws/get-credential-provider.test.js @@ -7,60 +7,25 @@ const sinon = require('sinon'); const expect = chai.expect; describe('test/unit/src/utils/aws/get-credential-provider.test.js', () => { - let originalAwsProfile; - let originalAwsDefaultProfile; - - beforeEach(() => { - originalAwsProfile = process.env.AWS_PROFILE; - originalAwsDefaultProfile = process.env.AWS_DEFAULT_PROFILE; - }); - afterEach(() => { - if (originalAwsProfile == null) delete process.env.AWS_PROFILE; - else process.env.AWS_PROFILE = originalAwsProfile; - if (originalAwsDefaultProfile == null) delete process.env.AWS_DEFAULT_PROFILE; - else process.env.AWS_DEFAULT_PROFILE = originalAwsDefaultProfile; sinon.restore(); }); - it('falls back to AWS_DEFAULT_PROFILE and forwards the region to the provider chain', () => { + it('forwards profile and stage to the aligned credential resolver', () => { const credentialProvider = sinon.stub().returns('provider'); - delete process.env.AWS_PROFILE; - process.env.AWS_DEFAULT_PROFILE = 'default-profile'; const getCredentialProvider = proxyquire .noCallThru() .load('../../../../../src/utils/aws/get-credential-provider', { - './provider-chain/from-node-provider-chain': credentialProvider, + './credentials': { getCredentialProvider: credentialProvider }, }); - expect(getCredentialProvider({ profile: 'custom-profile', region: 'eu-central-1' })).to.equal( + expect(getCredentialProvider({ profile: 'custom-profile', stage: 'prod' })).to.equal( 'provider' ); - expect(process.env.AWS_PROFILE).to.equal('default-profile'); expect(credentialProvider).to.have.been.calledOnceWithExactly({ profile: 'custom-profile', - clientConfig: { region: 'eu-central-1' }, - }); - }); - - it('does not override an existing AWS_PROFILE value', () => { - const credentialProvider = sinon.stub().returns('provider'); - process.env.AWS_PROFILE = 'already-set'; - process.env.AWS_DEFAULT_PROFILE = 'default-profile'; - - const getCredentialProvider = proxyquire - .noCallThru() - .load('../../../../../src/utils/aws/get-credential-provider', { - './provider-chain/from-node-provider-chain': credentialProvider, - }); - - getCredentialProvider({ region: 'us-east-1' }); - - expect(process.env.AWS_PROFILE).to.equal('already-set'); - expect(credentialProvider).to.have.been.calledOnceWithExactly({ - profile: undefined, - clientConfig: { region: 'us-east-1' }, + stage: 'prod', }); }); }); diff --git a/test/unit/src/utils/aws/remote-provider.test.js b/test/unit/src/utils/aws/remote-provider.test.js deleted file mode 100644 index 3f02053..0000000 --- a/test/unit/src/utils/aws/remote-provider.test.js +++ /dev/null @@ -1,107 +0,0 @@ -'use strict'; - -const chai = require('chai'); -const proxyquire = require('proxyquire'); -const sinon = require('sinon'); - -const expect = chai.expect; - -describe('test/unit/src/utils/aws/remote-provider.test.js', () => { - let originalRelativeUri; - let originalFullUri; - let originalImdsDisabled; - - beforeEach(() => { - originalRelativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; - originalFullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; - originalImdsDisabled = process.env.AWS_EC2_METADATA_DISABLED; - }); - - afterEach(() => { - if (originalRelativeUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; - else process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = originalRelativeUri; - if (originalFullUri == null) delete process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI; - else process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = originalFullUri; - if (originalImdsDisabled == null) delete process.env.AWS_EC2_METADATA_DISABLED; - else process.env.AWS_EC2_METADATA_DISABLED = originalImdsDisabled; - sinon.restore(); - }); - - const loadRemoteProvider = () => { - const containerProvider = sinon.stub().returns('container-provider'); - const instanceProvider = sinon.stub().returns('instance-provider'); - const CredentialsProviderError = class extends Error {}; - - const remoteProvider = proxyquire - .noCallThru() - .load('../../../../../src/utils/aws/provider-chain/remote-provider', { - '@aws-sdk/property-provider': { CredentialsProviderError }, - '@aws-sdk/credential-provider-imds': { - ENV_CMDS_FULL_URI: 'AWS_CONTAINER_CREDENTIALS_FULL_URI', - ENV_CMDS_RELATIVE_URI: 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', - fromContainerMetadata: containerProvider, - fromInstanceMetadata: instanceProvider, - }, - }); - - return { remoteProvider, containerProvider, instanceProvider }; - }; - - it('uses container metadata when ECS credential variables are present', () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs'; - - expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); - expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); - expect(instanceProvider.called).to.equal(false); - }); - - it('uses container metadata when AWS_CONTAINER_CREDENTIALS_FULL_URI is present', () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = 'http://169.254.170.2/v2/credentials/test'; - - expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); - expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); - expect(instanceProvider.called).to.equal(false); - }); - - it('disables IMDS when AWS_EC2_METADATA_DISABLED is set', async () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - process.env.AWS_EC2_METADATA_DISABLED = 'true'; - - await expect(remoteProvider({})()).to.be.eventually.rejectedWith( - 'EC2 Instance Metadata Service access disabled' - ); - expect(containerProvider.called).to.equal(false); - expect(instanceProvider.called).to.equal(false); - }); - - it('falls back to instance metadata when remote env vars are absent', () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - - expect(remoteProvider({ timeout: 5 })).to.equal('instance-provider'); - expect(instanceProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); - expect(containerProvider.called).to.equal(false); - }); - - it('disables IMDS whenever AWS_EC2_METADATA_DISABLED is set', async () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - process.env.AWS_EC2_METADATA_DISABLED = 'false'; - - await expect(remoteProvider({})()).to.be.eventually.rejectedWith( - 'EC2 Instance Metadata Service access disabled' - ); - expect(containerProvider.called).to.equal(false); - expect(instanceProvider.called).to.equal(false); - }); - - it('prefers container metadata over IMDS disable settings', () => { - const { remoteProvider, containerProvider, instanceProvider } = loadRemoteProvider(); - process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/ecs'; - process.env.AWS_EC2_METADATA_DISABLED = 'true'; - - expect(remoteProvider({ timeout: 5 })).to.equal('container-provider'); - expect(containerProvider).to.have.been.calledOnceWithExactly({ timeout: 5 }); - expect(instanceProvider.called).to.equal(false); - }); -});