* chore(deps-dev): bump vitest from 3.2.4 to 4.1.5 (#1748) * chore(deps-dev): bump vitest from 3.2.4 to 4.1.5 Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.4 to 4.1.5. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.5 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> * chore(deps-dev): update @vitest/coverage-v8 Bump @vitest/coverage-v8 to ^4.1.5 to match peer dependency, and move vi.mock('node:fs') calls to the top level of test files to reflect actual hoisting semantics required by vitest 4.x. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tom Keller <kellertk@amazon.com> (cherry picked from commit78f374f6d1) * chore(deps): bump @actions/core from 2.0.3 to 3.0.1 (#1746) * chore(deps): bump @actions/core from 2.0.3 to 3.0.1 Bumps [@actions/core](https://github.com/actions/toolkit/tree/HEAD/packages/core) from 2.0.3 to 3.0.1. - [Changelog](https://github.com/actions/toolkit/blob/main/packages/core/RELEASES.md) - [Commits](https://github.com/actions/toolkit/commits/HEAD/packages/core) --- updated-dependencies: - dependency-name: "@actions/core" dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> * chore: update test mocks for @actions/core ESM @actions/core v3 ships as an ESM module with non-configurable exports, breaking vi.spyOn(). Switch to vi.mock('@actions/core') which intercepts at the module loader level. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Tom Keller <kellertk@amazon.com> (cherry picked from commit64d8e82527) * chore(deps): bump @aws-sdk/client-sts from 3.1043.0 to 3.1044.0 (#1754) Bumps [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) from 3.1043.0 to 3.1044.0. - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1044.0/clients/client-sts) --- updated-dependencies: - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1042.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit4cfda40a13) * chore: bump unit test node version (#1758) (cherry picked from commit39d1702721) * chore: automatic major version tagging (#1565) * Update release-please.yml to auto-update version tag * chore: configure release-please auto floating tag --------- Co-authored-by: Tom Keller <kellertk@amazon.com> (cherry picked from commitc36525a567) * feat: Allow custom session tags to be passed when assuming a role (#1759) * Add possibility to input custom session tags * Use json for input to custom-tags, add documentation for custom-tags * Add more examples * Simplify example to avoid parse error * Add input validation for custom tags * Fix unit tests for custom-tags * Add debugging message * Skip failing test for now * Build package * Remove some unused validation for custom tags * feat: add validation for custom session tags Harden the custom-tags feature against misuse and misconfiguration: - Validate input is a JSON object (reject arrays, primitives, null) - Enforce STS tag constraints: key length (128), value length (256), allowed characters - Reject nested object/array values that would silently stringify to '[object Object]' - Block overriding default session tags (GitHub, Repository, Workflow, etc.) - Enforce 50-tag session limit - Warn when custom-tags used with OIDC or web identity - Fix missing await on helpers test assertion - Remove unused CUSTOM_TAGS_JSON_INPUTS fixture - Normalize test mocking to vi.mocked() pattern --------- Co-authored-by: Sylvain Verly <sylvain.verly@gmail.com> (cherry picked from commit61f50f630f) * chore: configure codeql to ignore generated code (#1760) (cherry picked from commitdc2353e57a) * feat: support custom STS endpoints (#1762) Closes #1067. This is a advanced option and is not needed for most deployments. (cherry picked from commit8d52d05d7a) * chore: automate README version bumping (#1763) Closes #1420. (cherry picked from commit07ada0fe07) * feat: add more retry logic and better logging (#1764) Wraps exportAccountId and validateCredentials calls in retryAndBackoff. Closes #1681. Adds a label parameter to retryAndBackoff for better info-level log messages. (cherry picked from commit540d0c13ae) * feat: add regex validation to role-session-name (#1765) Previously invalid role session names would get errors from the STS API instead of this action rejecting them, causing unnecessary retries. Now we check them and fail early. Closes #1656. That FR recommended that we sanitize the name before sending to STS, but instead we error to not silently change the user's selected session name (avoiding the potential security sharp edge) (cherry picked from commite35449909c) * chore: update documentation for environment workflows (#1766) Closes #1238. (cherry picked from commit3f7e1b63d7) * chore(deps): bump @aws-sdk/client-sts from 3.1044.0 to 3.1045.0 (#1767) Bumps [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) from 3.1044.0 to 3.1045.0. - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1045.0/clients/client-sts) --- updated-dependencies: - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1045.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commita388f23f7d) * chore(deps-dev): bump @vitest/coverage-v8 from 4.1.5 to 4.1.6 (#1768) Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.5 to 4.1.6. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8) --- updated-dependencies: - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit1fb495c4b2) * chore(deps-dev): bump @smithy/property-provider from 4.2.14 to 4.3.1 (#1771) Bumps [@smithy/property-provider](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/property-provider) from 4.2.14 to 4.3.1. - [Release notes](https://github.com/smithy-lang/smithy-typescript/releases) - [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/property-provider/CHANGELOG.md) - [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/property-provider@4.3.1/packages/property-provider) --- updated-dependencies: - dependency-name: "@smithy/property-provider" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit1ab31502aa) * chore(deps): bump @smithy/node-http-handler from 4.6.1 to 4.7.1 (#1770) Bumps [@smithy/node-http-handler](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/node-http-handler) from 4.6.1 to 4.7.1. - [Release notes](https://github.com/smithy-lang/smithy-typescript/releases) - [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/node-http-handler/CHANGELOG.md) - [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/node-http-handler@4.7.1/packages/node-http-handler) --- updated-dependencies: - dependency-name: "@smithy/node-http-handler" dependency-version: 4.7.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commitdbd503f368) * chore(deps-dev): bump @biomejs/biome from 2.4.14 to 2.4.15 (#1772) Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.14 to 2.4.15. - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.15/packages/@biomejs/biome) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.4.15 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit7521c55910) * chore(deps-dev): bump @types/node from 25.6.0 to 25.7.0 (#1773) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.6.0 to 25.7.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.7.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commitef734cca81) * feat: expose run id in STS client user-agent (#1774) * feat: expose run id in STS client user-agent Closes #483. This commit modifies the user-agent string so that it includes the GITHUB_RUN_ID and the GITHUB_RUN_ATTEMPT, in the format typically used by the SDK. User agent strings are logged to CloudTrail, allowing users to correlate CloudTrail events with GHA runs. We took this approach instead of logging the ACCESS_KEY_ID as suggested in the issue to avoid logging sensitive information. * feat: add github_action to ua string (cherry picked from commit29d1be3027) * feat: add additional session tags by default (#1775) Closes #390. Note that 50 session tags are the AWS default, and this commit changes our default set from 7 tags to 15 tags. This commit includes logic to split the tags into "required" vs "overridable". Required tags are this action's previous defaults and could never be overridden. Overridable tags are the new set and can be overridden by custom-tags. The action will not add tags if the addition plus the required plus the user's custom tags exceed the AWS limit of 50 total tags. This ensures backwards compat for the tag additions. (cherry picked from commite0ba768507) * chore: document forgejo compatibility (#1776) * chore: document forgejo compatibility * chore: linting fixes (cherry picked from commitf35a7d7d7e) * fix: skip credential check on output-env-credentials: false (#1778) Closes #1554. (cherry picked from commit58e7c47adf) * chore: update README for additional claim support (#1779) * chore: update README for additional claim support * chore: lint fix (whitespace) (cherry picked from commit713aaabfec) * chore(deps): bump @smithy/node-http-handler from 4.7.1 to 4.7.3 (#1781) Bumps [@smithy/node-http-handler](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/node-http-handler) from 4.7.1 to 4.7.3. - [Release notes](https://github.com/smithy-lang/smithy-typescript/releases) - [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/node-http-handler/CHANGELOG.md) - [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/node-http-handler@4.7.3/packages/node-http-handler) --- updated-dependencies: - dependency-name: "@smithy/node-http-handler" dependency-version: 4.7.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commita7c33ae483) * chore(deps-dev): bump @types/node from 25.7.0 to 25.9.0 (#1785) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.7.0 to 25.9.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.9.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commitffde832a1d) * chore(deps-dev): bump @aws-sdk/credential-provider-env (#1784) Bumps [@aws-sdk/credential-provider-env](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages-internal/credential-provider-env) from 3.972.34 to 3.972.38. - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages-internal/credential-provider-env/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/HEAD/packages-internal/credential-provider-env) --- updated-dependencies: - dependency-name: "@aws-sdk/credential-provider-env" dependency-version: 3.972.38 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commitbc1093db1d) * chore(deps-dev): bump @smithy/property-provider from 4.3.1 to 4.3.3 (#1783) Bumps [@smithy/property-provider](https://github.com/smithy-lang/smithy-typescript/tree/HEAD/packages/property-provider) from 4.3.1 to 4.3.3. - [Release notes](https://github.com/smithy-lang/smithy-typescript/releases) - [Changelog](https://github.com/smithy-lang/smithy-typescript/blob/main/packages/property-provider/CHANGELOG.md) - [Commits](https://github.com/smithy-lang/smithy-typescript/commits/@smithy/property-provider@4.3.3/packages/property-provider) --- updated-dependencies: - dependency-name: "@smithy/property-provider" dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commitfe6ad3af19) * chore(deps): bump @aws-sdk/client-sts from 3.1045.0 to 3.1049.0 (#1782) Bumps [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) from 3.1045.0 to 3.1049.0. - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1049.0/clients/client-sts) --- updated-dependencies: - dependency-name: "@aws-sdk/client-sts" dependency-version: 3.1049.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> (cherry picked from commit4684f47f89) * chore: reconcile lockfile and test formatting * chore: Update dist --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Michael Lehmann <lehmanmj@amazon.com> Co-authored-by: Sylvain Verly <sylvain.verly@gmail.com>
1561 lines
75 KiB
TypeScript
1561 lines
75 KiB
TypeScript
import * as core from '@actions/core';
|
|
import {
|
|
AssumeRoleCommand,
|
|
AssumeRoleWithWebIdentityCommand,
|
|
GetCallerIdentityCommand,
|
|
STSClient,
|
|
} from '@aws-sdk/client-sts';
|
|
import { mockClient } from 'aws-sdk-client-mock';
|
|
import { fs, vol } from 'memfs';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { CredentialsClient } from '../src/CredentialsClient';
|
|
import * as helpers from '../src/helpers';
|
|
import { run } from '../src/index';
|
|
import * as profileManager from '../src/profileManager';
|
|
import mocks from './mockinputs.test';
|
|
|
|
vi.mock('@actions/core');
|
|
vi.mock('node:fs');
|
|
|
|
const mockedSTSClient = mockClient(STSClient);
|
|
|
|
describe('Configure AWS Credentials', {}, () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockedSTSClient.reset();
|
|
vi.mocked(core.getInput).mockReturnValue('');
|
|
vi.mocked(core.getMultilineInput).mockReturnValue([]);
|
|
// Inject no-op sleep to avoid real delays during retries in tests
|
|
helpers.withsleep(() => Promise.resolve());
|
|
// Remove any existing environment variables before each test to prevent the
|
|
// SDK from picking them up
|
|
process.env = { ...mocks.envs };
|
|
});
|
|
|
|
afterEach(() => {
|
|
helpers.reset();
|
|
});
|
|
|
|
describe('GitHub OIDC Authentication', {}, () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
});
|
|
it('exports environment variables', async () => {
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(2);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(2);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
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');
|
|
expect(core.exportVariable).toHaveBeenCalledTimes(5);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
it('handles the special character workaround', async () => {
|
|
mockedSTSClient
|
|
.on(AssumeRoleWithWebIdentityCommand)
|
|
.resolvesOnce(mocks.outputs.ODD_CHARACTER_CREDENTIALS)
|
|
.resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC');
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(3);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(2);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
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');
|
|
expect(core.exportVariable).toHaveBeenCalledTimes(5);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('IAM User Authentication', {}, () => {
|
|
beforeEach(() => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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',
|
|
});
|
|
});
|
|
it('exports environment variables', async () => {
|
|
await run();
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MYAWSACCESSKEYID');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MYAWSSECRETACCESSKEY');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledTimes(4);
|
|
expect(core.setSecret).toHaveBeenCalledWith('MYAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('MYAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(2);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(2);
|
|
expect(core.info).toHaveBeenCalledWith('Proceeding with IAM user credentials');
|
|
expect(core.info).toHaveBeenCalledOnce();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AssumeRole with IAM LTC', {}, () => {
|
|
beforeEach(() => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY }); // 3 times
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
|
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
|
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
|
});
|
|
it('exports environment variables', async () => {
|
|
await run();
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'MYAWSACCESSKEYID');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'MYAWSSECRETACCESSKEY');
|
|
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).toHaveBeenCalledTimes(7);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledWith('MYAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('MYAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(5);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(4);
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// Regression test for #1554: IAM keys + role-to-assume on a self-hosted runner
|
|
// with ambient credentials (e.g. an EC2 instance profile), and output-env-credentials=false.
|
|
// The post-assume-role validation must be skipped, otherwise the SDK loads the runner's
|
|
// ambient access key (which doesn't match the assumed role's) and the action fails.
|
|
describe('AssumeRole with IAM LTC and output-env-credentials=false', {}, () => {
|
|
it('does not validate against ambient credentials', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_NO_ENV_INPUTS));
|
|
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
// Simulate the runner's ambient instance-profile credentials.
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
|
accessKeyId: 'AMBIENTINSTANCEPROFILEID',
|
|
});
|
|
await run();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
expect(core.exportVariable).not.toHaveBeenCalled();
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-access-key-id', 'STSAWSACCESSKEYID');
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-secret-access-key', 'STSAWSSECRETACCESSKEY');
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-session-token', 'STSAWSSESSIONTOKEN');
|
|
});
|
|
});
|
|
|
|
describe('AssumeRole with WebIdentityTokeFile', {}, () => {
|
|
beforeEach(() => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.WEBIDENTITY_TOKEN_FILE_INPUTS));
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
vol.reset();
|
|
fs.mkdirSync('/home/github', { recursive: true });
|
|
fs.writeFileSync('/home/github/file.txt', 'test-token');
|
|
});
|
|
it('exports environment variables', async () => {
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with web identity token file');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(2);
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
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).toHaveBeenCalledTimes(5);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(2);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Assume existing role', {}, () => {
|
|
beforeEach(() => {
|
|
mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env = { ...mocks.envs };
|
|
});
|
|
it('exports environment variables from env variables', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.EXISTING_ROLE_INPUTS));
|
|
process.env.AWS_ACCESS_KEY_ID = 'MYAWSACCESSKEYID';
|
|
process.env.AWS_SECRET_ACCESS_KEY = 'MYAWSSECRETACCESSKEY';
|
|
process.env.AWS_SESSION_TOKEN = 'MYAWSSESSIONTOKEN';
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(2);
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
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).toHaveBeenCalledTimes(5);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(4);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
it('exports environment variables from inputs', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.EXISTING_ROLE_INPUTS,
|
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
|
'aws-session-token': 'MYAWSSESSIONTOKEN',
|
|
}),
|
|
);
|
|
// 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();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(core.info).toHaveBeenCalledTimes(2);
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
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).toHaveBeenCalledTimes(8);
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSACCESSKEYID');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSECRETACCESSKEY');
|
|
expect(core.setSecret).toHaveBeenCalledWith('STSAWSSESSIONTOKEN');
|
|
expect(core.setSecret).toHaveBeenCalledTimes(6);
|
|
expect(core.setOutput).toHaveBeenCalledWith('aws-account-id', '111111111111');
|
|
expect(core.setOutput).toHaveBeenCalledTimes(4);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Default session tags', {}, () => {
|
|
beforeEach(() => {
|
|
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(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' });
|
|
});
|
|
it('emits exactly the expected default tag set with no custom-tags', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
// 7 protected (GitHub + Repository, Workflow, Action, Actor, Commit, Branch)
|
|
// + 8 overrideable (EventName, BaseRef, HeadRef, RefName, RunId, RefType, Job, TriggeringActor).
|
|
// No custom-tags, all env vars set in mocks.envs → all 15 should be present, nothing else.
|
|
expect(tags).toHaveLength(15);
|
|
const tagsByKey = Object.fromEntries(tags.map((t) => [t.Key, t.Value]));
|
|
expect(tagsByKey).toEqual({
|
|
GitHub: 'Actions',
|
|
Repository: 'MY-REPOSITORY-NAME',
|
|
Workflow: 'MY-WORKFLOW-ID',
|
|
Action: 'MY-ACTION-NAME',
|
|
Actor: 'MY-USERNAME_bot_',
|
|
Commit: 'MY-COMMIT-ID',
|
|
Branch: 'refs/pull/42/merge',
|
|
EventName: 'pull_request',
|
|
BaseRef: 'main',
|
|
HeadRef: 'feature-branch',
|
|
RefName: 'feature-branch',
|
|
RunId: '16412345678',
|
|
RefType: 'branch',
|
|
Job: 'build',
|
|
TriggeringActor: 'MY-USERNAME_bot_',
|
|
});
|
|
});
|
|
it('omits overrideable tags whose env vars are unset', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
delete process.env.GITHUB_BASE_REF;
|
|
delete process.env.GITHUB_HEAD_REF;
|
|
delete process.env.GITHUB_TRIGGERING_ACTOR;
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
const tagKeys = tags.map((t) => t.Key);
|
|
expect(tagKeys).not.toContain('BaseRef');
|
|
expect(tagKeys).not.toContain('HeadRef');
|
|
expect(tagKeys).not.toContain('TriggeringActor');
|
|
expect(tagKeys).toContain('EventName');
|
|
expect(tagKeys).toContain('RunId');
|
|
});
|
|
it('sanitizes invalid characters in env-derived tag values', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
process.env.GITHUB_HEAD_REF = 'feature/has spaces&bad?chars';
|
|
await run();
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({
|
|
Tags: expect.arrayContaining([{ Key: 'HeadRef', Value: 'feature/has spaces_bad_chars' }]),
|
|
});
|
|
});
|
|
it('truncates env-derived tag values longer than 256 characters', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
process.env.GITHUB_HEAD_REF = 'a'.repeat(300);
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
const headRef = tags.find((t) => t.Key === 'HeadRef');
|
|
expect(headRef?.Value).toHaveLength(256);
|
|
});
|
|
});
|
|
|
|
describe('Custom Tags', {}, () => {
|
|
beforeEach(() => {
|
|
mockedSTSClient.on(AssumeRoleCommand).resolvesOnce(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' });
|
|
});
|
|
it('rejects invalid JSON in custom tags', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_JSON_INPUTS));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith('custom-tags: input is not valid JSON');
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('handles valid custom tags', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_OBJECT_INPUTS));
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with user credentials');
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input).toMatchObject({
|
|
Tags: expect.arrayContaining([
|
|
{ Key: 'GitHub', Value: 'Actions' },
|
|
{ Key: 'Repository', Value: 'MY-REPOSITORY-NAME' },
|
|
{ Key: 'Workflow', Value: 'MY-WORKFLOW-ID' },
|
|
{ Key: 'Action', Value: 'MY-ACTION-NAME' },
|
|
{ Key: 'Actor', Value: 'MY-USERNAME_bot_' },
|
|
{ Key: 'Commit', Value: 'MY-COMMIT-ID' },
|
|
{ Key: 'Branch', Value: 'refs/pull/42/merge' },
|
|
{ Key: 'BaseRef', Value: 'main' },
|
|
{ Key: 'HeadRef', Value: 'feature-branch' },
|
|
{ Key: 'EventName', Value: 'pull_request' },
|
|
{ Key: 'RunId', Value: '16412345678' },
|
|
{ Key: 'Job', Value: 'build' },
|
|
{ Key: 'RefName', Value: 'feature-branch' },
|
|
{ Key: 'RefType', Value: 'branch' },
|
|
{ Key: 'TriggeringActor', Value: 'MY-USERNAME_bot_' },
|
|
{ Key: 'Environment', Value: 'Production' },
|
|
{ Key: 'Team', Value: 'DevOps' },
|
|
]),
|
|
});
|
|
});
|
|
it('rejects array input for custom tags', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_ARRAY_INPUTS));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
'custom-tags: input must be a JSON object (not an array or primitive)',
|
|
);
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('rejects custom tags that conflict with protected session tags', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_RESERVED_KEY_INPUTS));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
"custom-tags: key 'Repository' conflicts with a protected session tag set by this action and cannot be overridden",
|
|
);
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('rejects custom tags with invalid key characters', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.CUSTOM_TAGS_INVALID_KEY_CHARS_INPUTS));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
expect.stringContaining("custom-tags: key 'invalid{key}' contains invalid characters"),
|
|
);
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('warns when custom tags are used with OIDC', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'custom-tags': JSON.stringify({ MyTag: 'value' }),
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(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').mockResolvedValue({
|
|
accessKeyId: 'STSAWSACCESSKEYID',
|
|
});
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
await run();
|
|
expect(core.warning).toHaveBeenCalledWith(expect.stringContaining("'custom-tags' is set but will be ignored"));
|
|
});
|
|
it('lets custom tags override overrideable default tag keys', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify({ EventName: 'workflow_dispatch', BaseRef: 'release/2026' }),
|
|
}),
|
|
);
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
const eventNameTags = tags.filter((t) => t.Key === 'EventName');
|
|
const baseRefTags = tags.filter((t) => t.Key === 'BaseRef');
|
|
expect(eventNameTags).toHaveLength(1);
|
|
expect(eventNameTags[0]?.Value).toBe('workflow_dispatch');
|
|
expect(baseRefTags).toHaveLength(1);
|
|
expect(baseRefTags[0]?.Value).toBe('release/2026');
|
|
});
|
|
it('rejects custom tags that conflict with the protected Branch tag', {}, async () => {
|
|
// Regression guard: Branch was a default before v6.2 and must remain unoverridable.
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify({ Branch: 'evil-branch' }),
|
|
}),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
"custom-tags: key 'Branch' conflicts with a protected session tag set by this action and cannot be overridden",
|
|
);
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('drops lower-priority overrideable tags when custom-tags would exceed the session-tag limit', {}, async () => {
|
|
// 7 protected (GitHub + 6 from PROTECTED_TAG_SOURCES) + 40 custom = 47 used → 3 overrideable slots.
|
|
// The first 3 overrideable tags by priority are EventName, BaseRef, HeadRef (RefName, RunId, RefType,
|
|
// Job, TriggeringActor must be dropped).
|
|
const customTagsObj: Record<string, string> = {};
|
|
for (let i = 0; i < 40; i++) {
|
|
customTagsObj[`Custom${i}`] = `value${i}`;
|
|
}
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify(customTagsObj),
|
|
}),
|
|
);
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
const tagKeys = tags.map((t) => t.Key);
|
|
expect(tags).toHaveLength(50);
|
|
expect(tagKeys).toContain('Branch');
|
|
expect(tagKeys).toContain('EventName');
|
|
expect(tagKeys).toContain('BaseRef');
|
|
expect(tagKeys).toContain('HeadRef');
|
|
expect(tagKeys).not.toContain('RefName');
|
|
expect(tagKeys).not.toContain('RunId');
|
|
expect(tagKeys).not.toContain('RefType');
|
|
expect(tagKeys).not.toContain('Job');
|
|
expect(tagKeys).not.toContain('TriggeringActor');
|
|
});
|
|
it('overridden overrideable tags free a slot for a lower-priority overrideable tag', {}, async () => {
|
|
// Same 40-custom-tag scenario as above, but one of the customs overrides BaseRef.
|
|
// BaseRef no longer competes for the overrideable budget, so the next-priority overrideable (RefName) gets in.
|
|
const customTagsObj: Record<string, string> = { BaseRef: 'release/2026' };
|
|
for (let i = 0; i < 39; i++) {
|
|
customTagsObj[`Custom${i}`] = `value${i}`;
|
|
}
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify(customTagsObj),
|
|
}),
|
|
);
|
|
await run();
|
|
const tags = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input.Tags ?? [];
|
|
const tagKeys = tags.map((t) => t.Key);
|
|
expect(tags).toHaveLength(50);
|
|
expect(tagKeys).toContain('Branch');
|
|
expect(tagKeys).toContain('EventName');
|
|
expect(tagKeys).toContain('BaseRef');
|
|
expect(tagKeys).toContain('HeadRef');
|
|
expect(tagKeys).toContain('RefName');
|
|
expect(tagKeys).not.toContain('RunId');
|
|
});
|
|
it('rejects custom-tags that would exceed the session-tag limit on their own', {}, async () => {
|
|
// 7 protected + 44 custom = 51, which is over 50 even with zero overrideable tags.
|
|
const customTagsObj: Record<string, string> = {};
|
|
for (let i = 0; i < 44; i++) {
|
|
customTagsObj[`Custom${i}`] = `value${i}`;
|
|
}
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify(customTagsObj),
|
|
}),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('would exceed the AWS limit of 50'));
|
|
expect(mockedSTSClient.commandCalls(AssumeRoleCommand)).toHaveLength(0);
|
|
});
|
|
it('drops transitive-tag-keys entries that refer to evicted overrideable tags', {}, async () => {
|
|
// Force eviction of all overrideable tags below EventName/BaseRef/HeadRef. The user transitive-tags
|
|
// RunId (which gets evicted) and Repository (which is protected and stays). The TransitiveTagKeys
|
|
// payload must include only the keys that actually appear in Tags.
|
|
const customTagsObj: Record<string, string> = {};
|
|
for (let i = 0; i < 40; i++) {
|
|
customTagsObj[`Custom${i}`] = `value${i}`;
|
|
}
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'custom-tags': JSON.stringify(customTagsObj),
|
|
}),
|
|
);
|
|
vi.mocked(core.getMultilineInput).mockImplementation((name: string) => {
|
|
if (name === 'transitive-tag-keys') return ['Repository', 'RunId'];
|
|
return [];
|
|
});
|
|
await run();
|
|
const callInput = mockedSTSClient.commandCalls(AssumeRoleCommand)[0].args[0].input;
|
|
const tagKeys = (callInput.Tags ?? []).map((t) => t.Key);
|
|
expect(tagKeys).not.toContain('RunId');
|
|
expect(callInput.TransitiveTagKeys).toEqual(['Repository']);
|
|
});
|
|
});
|
|
|
|
describe('Odd inputs', {}, () => {
|
|
it('fails when github env vars are missing', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_ASSUMEROLE_INPUTS));
|
|
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',
|
|
});
|
|
delete process.env.GITHUB_REPOSITORY;
|
|
delete process.env.GITHUB_SHA;
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
});
|
|
it('does not fail if GITHUB_REF is missing', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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',
|
|
});
|
|
delete process.env.GITHUB_REF;
|
|
await run();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
it('fails with an invalid region', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput({ 'aws-region': '$|<1B1D1 701L37' }));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
});
|
|
it('fails with a role-session-name containing invalid characters', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({ ...mocks.IAM_ASSUMEROLE_INPUTS, 'role-session-name': 'invalid session!' }),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('Role session name is not valid'));
|
|
});
|
|
it('fails with a role-session-name that is too short', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({ ...mocks.IAM_ASSUMEROLE_INPUTS, 'role-session-name': 'a' }),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('must be between 2 and 64 characters'));
|
|
});
|
|
it('fails with a role-session-name that is too long', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({ ...mocks.IAM_ASSUMEROLE_INPUTS, 'role-session-name': 'a'.repeat(65) }),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('must be between 2 and 64 characters'));
|
|
});
|
|
it('fails if access key id is provided without secret access key', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({ ...mocks.IAM_USER_INPUTS, 'aws-secret-access-key': '' }),
|
|
);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles improper retry-max-attempts input', {}, async () => {
|
|
// This should mean we retry one time
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'retry-max-attempts': '-1',
|
|
'special-characters-workaround': 'false',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
mockedSTSClient
|
|
.on(AssumeRoleWithWebIdentityCommand)
|
|
.rejectsOnce(new Error('test error'))
|
|
.rejectsOnce(new Error('test error'))
|
|
.resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
});
|
|
it('fails if doing OIDC without the ACTIONS_ID_TOKEN_REQUEST_TOKEN', {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(
|
|
new Error('No credentials available'),
|
|
);
|
|
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
});
|
|
it("gets new creds if told to reuse existing but they're invalid", {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.USE_EXISTING_CREDENTIALS_INPUTS));
|
|
mockedSTSClient.on(GetCallerIdentityCommand).rejects();
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(
|
|
new Error('No credentials available'),
|
|
);
|
|
await run();
|
|
expect(core.notice).toHaveBeenCalledWith('No valid credentials exist. Running as normal.');
|
|
});
|
|
it("doesn't get new creds if there are already valid ones and we said use them", {}, async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.USE_EXISTING_CREDENTIALS_INPUTS));
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves(mocks.outputs.GET_CALLER_IDENTITY);
|
|
await run();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
it("doesn't export credentials as environment variables if told not to", {}, async () => {
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.NO_ENV_CREDS_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
await run();
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
expect(core.exportVariable).toHaveBeenCalledTimes(0);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
it('can export creds as step outputs without exporting as env variables', {}, async () => {
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.STEP_BUT_NO_ENV_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
await run();
|
|
expect(core.setSecret).toHaveBeenCalledTimes(3);
|
|
expect(core.exportVariable).toHaveBeenCalledTimes(0);
|
|
expect(core.setOutput).toHaveBeenCalledTimes(4);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Force Skip OIDC', {}, () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedSTSClient.reset();
|
|
});
|
|
|
|
it('skips OIDC when force-skip-oidc is true with IAM credentials', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'force-skip-oidc': 'true',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
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' });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
expect(core.getIDToken).not.toHaveBeenCalled();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips OIDC when force-skip-oidc is true with web identity token file', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.WEBIDENTITY_TOKEN_FILE_INPUTS,
|
|
'force-skip-oidc': 'true',
|
|
}),
|
|
);
|
|
vi.mocked(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';
|
|
vol.reset();
|
|
fs.mkdirSync('/home/github', { recursive: true });
|
|
fs.writeFileSync('/home/github/file.txt', 'test-token');
|
|
|
|
await run();
|
|
expect(core.getIDToken).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with web identity token file');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails when force-skip-oidc is true but no alternative credentials provided', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
'role-to-assume': 'arn:aws:iam::111111111111:role/MY-ROLE',
|
|
'aws-region': 'fake-region-1',
|
|
'force-skip-oidc': 'true',
|
|
}),
|
|
);
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
"If 'force-skip-oidc' is true and 'role-to-assume' is set, 'aws-access-key-id' or 'web-identity-token-file' must be set",
|
|
);
|
|
});
|
|
|
|
it('allows force-skip-oidc without role-to-assume', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'force-skip-oidc': 'true',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
|
|
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',
|
|
});
|
|
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
expect(core.getIDToken).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Proceeding with IAM user credentials');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses OIDC when force-skip-oidc is false (default behavior)', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'force-skip-oidc': 'false',
|
|
}),
|
|
);
|
|
vi.mocked(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.getIDToken).toHaveBeenCalledWith('');
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses OIDC when force-skip-oidc is not set (default behavior)', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(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.getIDToken).toHaveBeenCalledWith('');
|
|
expect(core.info).toHaveBeenCalledWith('Assuming role with OIDC');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('works with role chaining when force-skip-oidc is true', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.EXISTING_ROLE_INPUTS,
|
|
'force-skip-oidc': 'true',
|
|
'aws-access-key-id': 'MYAWSACCESSKEYID',
|
|
'aws-secret-access-key': 'MYAWSSECRETACCESSKEY',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
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' });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
expect(core.getIDToken).not.toHaveBeenCalled();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Account ID Validation', {}, () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedSTSClient.reset();
|
|
});
|
|
|
|
it('succeeds when account ID matches allowed list', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '111111111111',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Proceeding with IAM user credentials');
|
|
});
|
|
|
|
it('succeeds with multiple allowed account IDs when account matches', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '999999999999,111111111111,222222222222',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails when account ID does not match allowed list', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '999999999999',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999',
|
|
);
|
|
});
|
|
|
|
it('fails when account ID does not match any in multiple allowed accounts', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '999999999999,888888888888',
|
|
}),
|
|
);
|
|
|
|
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();
|
|
expect(core.setFailed).toHaveBeenCalledWith(
|
|
'The account ID of the provided credentials (111111111111) does not match any of the expected account IDs: 999999999999, 888888888888',
|
|
);
|
|
});
|
|
|
|
it('works with assume role when account ID matches', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'allowed-account-ids': '111111111111',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
});
|
|
|
|
it('works with OIDC when account ID matches', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'allowed-account-ids': '111111111111',
|
|
}),
|
|
);
|
|
vi.mocked(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).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
});
|
|
|
|
it('handles GetCallerIdentity API failure gracefully', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '111111111111',
|
|
}),
|
|
);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).rejects(new Error('API Error'));
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
|
accessKeyId: 'MYAWSACCESSKEYID',
|
|
});
|
|
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalledWith('Could not validate account ID of credentials: API Error');
|
|
});
|
|
|
|
it('ignores validation when allowed-account-ids is empty', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': '',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Proceeding with IAM user credentials');
|
|
});
|
|
|
|
it('handles whitespace in allowed-account-ids input', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'allowed-account-ids': ' 111111111111 , 222222222222 ',
|
|
}),
|
|
);
|
|
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();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Global Timeout Configuration', {}, () => {
|
|
beforeEach(() => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...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',
|
|
});
|
|
});
|
|
|
|
it('sets timeout when action-timeout-s is provided', async () => {
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
|
const infoSpy = vi.mocked(core.info);
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'action-timeout-s': '30',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Setting a global timeout of 30 seconds for the action');
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
|
expect(clearTimeoutSpy).toHaveBeenCalledWith(expect.any(Object));
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not set timeout when action-timeout-s is 0', async () => {
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const infoSpy = vi.mocked(core.info);
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'action-timeout-s': '0',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
|
|
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not set timeout when action-timeout-s is not provided', async () => {
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const infoSpy = vi.mocked(core.info);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).not.toHaveBeenCalledWith(expect.stringContaining('Setting a global timeout'));
|
|
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('timeout callback calls setFailed and exits process', async () => {
|
|
const setTimeoutSpy = vi.spyOn(global, 'setTimeout');
|
|
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'action-timeout-s': '5',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
// Get the timeout callback function
|
|
const timeoutCallback = setTimeoutSpy.mock.calls[0][0] as () => void;
|
|
|
|
// Execute the timeout callback
|
|
timeoutCallback();
|
|
|
|
expect(core.setFailed).toHaveBeenCalledWith('Action timed out after 5 seconds');
|
|
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
});
|
|
});
|
|
|
|
describe('Custom STS Endpoint', {}, () => {
|
|
it('passes sts-endpoint to the STS client', async () => {
|
|
const client = new CredentialsClient({
|
|
region: 'us-east-1',
|
|
stsEndpoint: 'https://sts.custom.example.com',
|
|
roleChaining: false,
|
|
});
|
|
const endpoint = await client.stsClient.config.endpoint();
|
|
expect(endpoint).toMatchObject({ hostname: 'sts.custom.example.com', protocol: 'https:' });
|
|
});
|
|
|
|
it('does not override endpoint when sts-endpoint is not provided', () => {
|
|
const client = new CredentialsClient({
|
|
region: 'us-east-1',
|
|
roleChaining: false,
|
|
});
|
|
expect(client.stsClient.config.endpoint).toBeUndefined();
|
|
});
|
|
|
|
it('works with http endpoints for local services', async () => {
|
|
const client = new CredentialsClient({
|
|
region: 'us-east-1',
|
|
stsEndpoint: 'http://localhost:9000',
|
|
roleChaining: false,
|
|
});
|
|
const endpoint = await client.stsClient.config.endpoint();
|
|
expect(endpoint).toMatchObject({ hostname: 'localhost', protocol: 'http:', port: 9000 });
|
|
});
|
|
|
|
it('succeeds in a full action run with sts-endpoint input', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'sts-endpoint': 'https://sts.custom.example.com',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
expect(core.info).toHaveBeenCalledWith('Authenticated as assumedRoleId AROAFAKEASSUMEDROLEID');
|
|
});
|
|
});
|
|
|
|
describe('HTTP Proxy Configuration', {}, () => {
|
|
beforeEach(() => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolvesOnce({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolvesOnce(mocks.outputs.STS_CREDENTIALS);
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
});
|
|
|
|
it('configures proxy from http-proxy input', async () => {
|
|
const infoSpy = vi.mocked(core.info);
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'http-proxy': 'http://proxy.example.com:8080',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Configuring proxy handler for STS client');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('configures proxy from HTTP_PROXY environment variable', async () => {
|
|
const infoSpy = vi.mocked(core.info);
|
|
process.env.HTTP_PROXY = 'http://proxy.example.com:8080';
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Configuring proxy handler for STS client');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('configures proxy from HTTPS_PROXY environment variable', async () => {
|
|
const infoSpy = vi.mocked(core.info);
|
|
process.env.HTTPS_PROXY = 'https://proxy.example.com:8080';
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Configuring proxy handler for STS client');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('prioritizes http-proxy input over environment variables', async () => {
|
|
const infoSpy = vi.mocked(core.info);
|
|
process.env.HTTP_PROXY = 'http://env-proxy.example.com:8080';
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'http-proxy': 'http://input-proxy.example.com:8080',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Configuring proxy handler for STS client');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('properly configures proxy agent in STS client', async () => {
|
|
const infoSpy = vi.mocked(core.info);
|
|
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'http-proxy': 'http://proxy.example.com:8080',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(infoSpy).toHaveBeenCalledWith('Configuring proxy handler for STS client');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('configures no-proxy setting', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'http-proxy': 'http://proxy.example.com:8080',
|
|
'no-proxy': 'localhost,127.0.0.1',
|
|
}),
|
|
);
|
|
|
|
await run();
|
|
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('works without proxy configuration', async () => {
|
|
await run();
|
|
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('AWS Profile Support', {}, () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedSTSClient.reset();
|
|
vol.reset();
|
|
});
|
|
|
|
it('writes profile files with OIDC authentication', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'aws-profile': 'dev',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
|
|
// Verify credentials were NOT exported to environment variables
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
|
|
|
// Verify profile files were written
|
|
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes profile files with IAM user credentials', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'aws-profile': 'production',
|
|
}),
|
|
);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
|
accessKeyId: 'MYAWSACCESSKEYID',
|
|
});
|
|
|
|
await run();
|
|
|
|
// Verify credentials were NOT exported to environment variables
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
|
|
|
// Verify profile files were written
|
|
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: production');
|
|
expect(core.info).toHaveBeenCalledWith('Writing config to profile: production');
|
|
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: production');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('writes profile files with IAM user role assumption', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_ASSUMEROLE_INPUTS,
|
|
'aws-profile': 'assumed-role',
|
|
}),
|
|
);
|
|
mockedSTSClient.on(AssumeRoleCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
|
.mockResolvedValueOnce({ accessKeyId: 'MYAWSACCESSKEYID' })
|
|
.mockResolvedValueOnce({ accessKeyId: 'STSAWSACCESSKEYID' });
|
|
|
|
vi.spyOn(profileManager, 'writeProfileFiles');
|
|
await run();
|
|
|
|
// Verify credentials were NOT exported to environment variables
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_SESSION_TOKEN', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_REGION', expect.anything());
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_DEFAULT_REGION', expect.anything());
|
|
|
|
// Verify profile files were written
|
|
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: assumed-role');
|
|
expect(core.info).toHaveBeenCalledWith('Writing config to profile: assumed-role');
|
|
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: assumed-role');
|
|
|
|
// Verify profile files were written twice (first to write access key id and access key, second to write
|
|
// actual session token after role assumption
|
|
expect(profileManager.writeProfileFiles).toHaveBeenCalledTimes(2);
|
|
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects output-env-credentials=true with profiles', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'aws-profile': 'dev',
|
|
'output-env-credentials': 'true',
|
|
}),
|
|
);
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
|
|
// verify that env vars were exported
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'STSAWSSECRETACCESSKEY');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'STSAWSSESSIONTOKEN');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_PROFILE', 'dev');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
|
|
// Verify profile files were still written
|
|
expect(core.info).toHaveBeenCalledWith('Writing credentials to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('Writing config to profile: dev');
|
|
expect(core.info).toHaveBeenCalledWith('✓ Successfully configured AWS profile: dev');
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maintains backward compatibility when aws-profile is not specified', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
|
|
await run();
|
|
|
|
// Verify credentials WERE exported to environment variables (backward compatibility)
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_ACCESS_KEY_ID', 'STSAWSACCESSKEYID');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SECRET_ACCESS_KEY', 'STSAWSSECRETACCESSKEY');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_SESSION_TOKEN', 'STSAWSSESSIONTOKEN');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_REGION', 'fake-region-1');
|
|
expect(core.exportVariable).toHaveBeenCalledWith('AWS_DEFAULT_REGION', 'fake-region-1');
|
|
|
|
// Verify AWS_PROFILE was NOT exported
|
|
expect(core.exportVariable).not.toHaveBeenCalledWith('AWS_PROFILE', expect.anything());
|
|
|
|
// Verify profile files were NOT written
|
|
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Writing credentials to profile'));
|
|
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('✓ Successfully configured AWS profile:'));
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('handles default profile correctly', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'aws-profile': 'default',
|
|
}),
|
|
);
|
|
vi.mocked(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.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.GH_OIDC_INPUTS,
|
|
'aws-profile': 'invalid profile',
|
|
}),
|
|
);
|
|
vi.mocked(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'));
|
|
});
|
|
});
|
|
|
|
describe('Retry Behavior', {}, () => {
|
|
it('retries exportAccountId on transient GetCallerIdentity failure', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockResolvedValue({
|
|
accessKeyId: 'MYAWSACCESSKEYID',
|
|
});
|
|
mockedSTSClient
|
|
.on(GetCallerIdentityCommand)
|
|
.rejectsOnce(new Error('throttled'))
|
|
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries validateCredentials on transient loadCredentials failure', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.IAM_USER_INPUTS));
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials')
|
|
.mockRejectedValueOnce(new Error('network glitch'))
|
|
.mockResolvedValue({ accessKeyId: 'MYAWSACCESSKEYID' });
|
|
mockedSTSClient.on(GetCallerIdentityCommand).resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry validateCredentials'));
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('respects disable-retry for validateCredentials', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(
|
|
mocks.getInput({
|
|
...mocks.IAM_USER_INPUTS,
|
|
'disable-retry': 'true',
|
|
}),
|
|
);
|
|
// biome-ignore lint/suspicious/noExplicitAny: any required to mock private method
|
|
vi.spyOn(CredentialsClient.prototype as any, 'loadCredentials').mockRejectedValue(new Error('network glitch'));
|
|
await run();
|
|
expect(core.setFailed).toHaveBeenCalled();
|
|
expect(core.info).not.toHaveBeenCalledWith(expect.stringContaining('Retry'));
|
|
});
|
|
|
|
it('retries exportAccountId after role assumption (issue #1681)', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient.on(AssumeRoleWithWebIdentityCommand).resolves(mocks.outputs.STS_CREDENTIALS);
|
|
mockedSTSClient
|
|
.on(GetCallerIdentityCommand)
|
|
.rejectsOnce(new Error('The security token included in the request is invalid'))
|
|
.resolves({ ...mocks.outputs.GET_CALLER_IDENTITY });
|
|
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'fake-token';
|
|
await run();
|
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Retry exportAccountId'));
|
|
expect(core.info).toHaveBeenCalledWith(
|
|
expect.stringContaining('The security token included in the request is invalid'),
|
|
);
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('retries AssumeRole and shows info-level retry messages', async () => {
|
|
vi.mocked(core.getInput).mockImplementation(mocks.getInput(mocks.GH_OIDC_INPUTS));
|
|
vi.mocked(core.getIDToken).mockResolvedValue('testoidctoken');
|
|
mockedSTSClient
|
|
.on(AssumeRoleWithWebIdentityCommand)
|
|
.rejectsOnce(new Error('Rate exceeded'))
|
|
.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.info).toHaveBeenCalledWith(expect.stringContaining('Retry AssumeRole'));
|
|
expect(core.info).toHaveBeenCalledWith(expect.stringContaining('Rate exceeded'));
|
|
expect(core.setFailed).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('User-Agent enrichment', {}, () => {
|
|
async function getCustomUserAgent(): Promise<unknown> {
|
|
const { CredentialsClient: FreshClient } = await import('../src/CredentialsClient');
|
|
const client = new FreshClient({ region: 'fake-region-1', roleChaining: false });
|
|
// biome-ignore lint/suspicious/noExplicitAny: SDK config readout
|
|
return (client.stsClient.config as any).customUserAgent;
|
|
}
|
|
|
|
it('includes action, run_id and attempt tokens when env vars are valid', async () => {
|
|
vi.resetModules();
|
|
process.env.GITHUB_ACTION = '__run_2';
|
|
process.env.GITHUB_RUN_ID = '16412345678';
|
|
process.env.GITHUB_RUN_ATTEMPT = '1';
|
|
const ua = await getCustomUserAgent();
|
|
expect(ua).toEqual([
|
|
['configure-aws-credentials-for-github-actions'],
|
|
['md', 'action#__run_2'],
|
|
['md', 'run_id#16412345678'],
|
|
['md', 'attempt#1'],
|
|
]);
|
|
expect(core.warning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('omits tokens when env vars are unset, with no warning', async () => {
|
|
vi.resetModules();
|
|
delete process.env.GITHUB_ACTION;
|
|
delete process.env.GITHUB_RUN_ID;
|
|
delete process.env.GITHUB_RUN_ATTEMPT;
|
|
const ua = await getCustomUserAgent();
|
|
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
|
|
expect(core.warning).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns and skips when env vars are malformed', async () => {
|
|
vi.resetModules();
|
|
process.env.GITHUB_ACTION = '$(curl evil)';
|
|
process.env.GITHUB_RUN_ID = '$(curl evil)';
|
|
process.env.GITHUB_RUN_ATTEMPT = '1; rm -rf /';
|
|
const ua = await getCustomUserAgent();
|
|
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
|
|
expect(core.warning).toHaveBeenCalledWith('GITHUB_ACTION has unexpected format; omitting from User-Agent');
|
|
expect(core.warning).toHaveBeenCalledWith('GITHUB_RUN_ID has unexpected format; omitting from User-Agent');
|
|
expect(core.warning).toHaveBeenCalledWith('GITHUB_RUN_ATTEMPT has unexpected format; omitting from User-Agent');
|
|
expect(core.warning).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('warns and skips when env vars exceed the length bound', async () => {
|
|
vi.resetModules();
|
|
process.env.GITHUB_ACTION = 'a'.repeat(200);
|
|
process.env.GITHUB_RUN_ID = '1'.repeat(50);
|
|
process.env.GITHUB_RUN_ATTEMPT = '1'.repeat(50);
|
|
const ua = await getCustomUserAgent();
|
|
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
|
|
expect(core.warning).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('rejects GITHUB_ACTION containing whitespace or other characters', async () => {
|
|
vi.resetModules();
|
|
process.env.GITHUB_ACTION = 'has space';
|
|
delete process.env.GITHUB_RUN_ID;
|
|
delete process.env.GITHUB_RUN_ATTEMPT;
|
|
const ua = await getCustomUserAgent();
|
|
expect(ua).toEqual([['configure-aws-credentials-for-github-actions']]);
|
|
expect(core.warning).toHaveBeenCalledWith('GITHUB_ACTION has unexpected format; omitting from User-Agent');
|
|
});
|
|
|
|
it('sets AWS_EXECUTION_ENV to GitHubActions when unset', async () => {
|
|
vi.resetModules();
|
|
await import('../src/CredentialsClient');
|
|
expect(process.env.AWS_EXECUTION_ENV).toBe('GitHubActions');
|
|
});
|
|
|
|
it('preserves a pre-existing AWS_EXECUTION_ENV value', async () => {
|
|
vi.resetModules();
|
|
process.env.AWS_EXECUTION_ENV = 'CustomRunner';
|
|
await import('../src/CredentialsClient');
|
|
expect(process.env.AWS_EXECUTION_ENV).toBe('CustomRunner');
|
|
});
|
|
});
|
|
});
|