Skip to content

Commit

Permalink
test(credential-provider-imds): cases for fromInstanceMetadata (#1318)
Browse files Browse the repository at this point in the history
  • Loading branch information
trivikr committed Jul 2, 2020
1 parent 2a55e1d commit f48ce92
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 81 deletions.
223 changes: 149 additions & 74 deletions packages/credential-provider-imds/src/fromInstanceMetadata.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,99 +2,174 @@ import { fromInstanceMetadata } from "./fromInstanceMetadata";
import { httpGet } from "./remoteProvider/httpGet";
import {
fromImdsCredentials,
ImdsCredentials
isImdsCredentials
} from "./remoteProvider/ImdsCredentials";
import { providerConfigFromInit } from "./remoteProvider/RemoteProviderInit";
import { retry } from "./remoteProvider/retry";
import { ProviderError } from "@aws-sdk/property-provider";

const mockHttpGet = <any>httpGet;
jest.mock("./remoteProvider/httpGet", () => ({ httpGet: jest.fn() }));

beforeEach(() => {
mockHttpGet.mockReset();
});
jest.mock("./remoteProvider/httpGet");
jest.mock("./remoteProvider/ImdsCredentials");
jest.mock("./remoteProvider/retry");
jest.mock("./remoteProvider/RemoteProviderInit");

describe("fromInstanceMetadata", () => {
const creds: ImdsCredentials = Object.freeze({
const mockTimeout = 1000;
const mockMaxRetries = 3;
const mockProfile = "foo";

const mockHttpGetOptions = {
host: "169.254.169.254",
path: "/latest/meta-data/iam/security-credentials/",
timeout: mockTimeout
};

const mockImdsCreds = Object.freeze({
AccessKeyId: "foo",
SecretAccessKey: "bar",
Token: "baz",
Expiration: new Date().toISOString()
});

it("should resolve credentials by fetching them from the container metadata service", async () => {
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
expect(await fromInstanceMetadata()()).toEqual(fromImdsCredentials(creds));
const mockCreds = Object.freeze({
accessKeyId: mockImdsCreds.AccessKeyId,
secretAccessKey: mockImdsCreds.SecretAccessKey,
sessionToken: mockImdsCreds.Token,
expiration: new Date(mockImdsCreds.Expiration)
});

it("should retry the fetching operation up to maxRetries times", async () => {
const maxRetries = 5;
mockHttpGet.mockReturnValueOnce(Promise.resolve("foo"));
for (let i = 0; i < maxRetries - 1; i++) {
mockHttpGet.mockReturnValueOnce(Promise.reject("No!"));
}
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));

expect(await fromInstanceMetadata({ maxRetries })()).toEqual(
fromImdsCredentials(creds)
);
expect(mockHttpGet.mock.calls.length).toEqual(maxRetries + 1);
beforeEach(() => {
((isImdsCredentials as unknown) as jest.Mock).mockReturnValue(true);
(providerConfigFromInit as jest.Mock).mockReturnValue({
timeout: mockTimeout,
maxRetries: mockMaxRetries
});
});

it("should retry responses that receive invalid response values", async () => {
mockHttpGet.mockReturnValueOnce(Promise.resolve("foo"));
for (let key of Object.keys(creds)) {
const invalidCreds: any = { ...creds };
delete invalidCreds[key];
mockHttpGet.mockReturnValueOnce(
Promise.resolve(JSON.stringify(invalidCreds))
);
}
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));

await fromInstanceMetadata({ maxRetries: 100 })();
expect(mockHttpGet.mock.calls.length).toEqual(
Object.keys(creds).length + 2
);
afterEach(() => {
jest.resetAllMocks();
});

it("should pass relevant configuration to httpGet", async () => {
const timeout = Math.ceil(Math.random() * 1000);
const profile = "foo-profile";
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
mockHttpGet.mockReturnValue(Promise.resolve(JSON.stringify(creds)));
await fromInstanceMetadata({ timeout })();
expect(mockHttpGet.mock.calls.length).toEqual(2);
expect(mockHttpGet.mock.calls[0][0]).toEqual({
host: "169.254.169.254",
path: "/latest/meta-data/iam/security-credentials/",
timeout
});
expect(mockHttpGet.mock.calls[1][0]).toEqual({
host: "169.254.169.254",
path: `/latest/meta-data/iam/security-credentials/${profile}`,
timeout
it("gets profile name from IMDS, and passes profile name to fetch credentials", async () => {
(httpGet as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));

(retry as jest.Mock).mockImplementation((fn: any) => fn());
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);

await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
expect(httpGet).toHaveBeenCalledTimes(2);
expect(httpGet).toHaveBeenNthCalledWith(1, mockHttpGetOptions);
expect(httpGet).toHaveBeenNthCalledWith(2, {
...mockHttpGetOptions,
path: `${mockHttpGetOptions.path}${mockProfile}`
});
});

it("should retry the profile name fetch as necessary", async () => {
const defaultTimeout = 1000;
const profile = "foo-profile";
mockHttpGet.mockReturnValueOnce(Promise.reject("Too busy"));
mockHttpGet.mockReturnValueOnce(Promise.resolve(profile));
mockHttpGet.mockReturnValueOnce(Promise.resolve(JSON.stringify(creds)));

await fromInstanceMetadata({ maxRetries: 1 })();
expect(mockHttpGet.mock.calls.length).toEqual(3);
expect(mockHttpGet.mock.calls[2][0]).toEqual({
host: "169.254.169.254",
path: `/latest/meta-data/iam/security-credentials/${profile}`,
timeout: defaultTimeout
it("trims profile returned name from IMDS", async () => {
(httpGet as jest.Mock)
.mockResolvedValueOnce(" " + mockProfile + " ")
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));

(retry as jest.Mock).mockImplementation((fn: any) => fn());
(fromImdsCredentials as jest.Mock).mockReturnValue(mockCreds);

await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
expect(httpGet).toHaveBeenCalledTimes(2);
expect(httpGet).toHaveBeenNthCalledWith(1, mockHttpGetOptions);
expect(httpGet).toHaveBeenNthCalledWith(2, {
...mockHttpGetOptions,
path: `${mockHttpGetOptions.path}${mockProfile}`
});
for (let index of [0, 1]) {
expect(mockHttpGet.mock.calls[index][0]).toEqual({
host: "169.254.169.254",
path: "/latest/meta-data/iam/security-credentials/",
timeout: defaultTimeout
});
}
});

it("passes {} to providerConfigFromInit if init not defined", async () => {
(retry as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(mockCreds);

await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
expect(providerConfigFromInit).toHaveBeenCalledTimes(1);
expect(providerConfigFromInit).toHaveBeenCalledWith({});
});

it("passes init to providerConfigFromInit", async () => {
(retry as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(mockCreds);

const init = { maxRetries: 5, timeout: 1213 };
await expect(fromInstanceMetadata(init)()).resolves.toEqual(mockCreds);
expect(providerConfigFromInit).toHaveBeenCalledTimes(1);
expect(providerConfigFromInit).toHaveBeenCalledWith(init);
});

it("passes maxRetries returned from providerConfigFromInit to retry", async () => {
(retry as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(mockCreds);

await expect(fromInstanceMetadata()()).resolves.toEqual(mockCreds);
expect(retry).toHaveBeenCalledTimes(2);
expect((retry as jest.Mock).mock.calls[0][1]).toBe(mockMaxRetries);
expect((retry as jest.Mock).mock.calls[1][1]).toBe(mockMaxRetries);
});

it("throws ProviderError if credentials returned are incorrect", async () => {
(httpGet as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(JSON.stringify(mockImdsCreds));

(retry as jest.Mock).mockImplementation((fn: any) => fn());
((isImdsCredentials as unknown) as jest.Mock).mockReturnValueOnce(false);

await expect(fromInstanceMetadata()()).rejects.toEqual(
new ProviderError(
"Invalid response received from instance metadata service."
)
);
expect(retry).toHaveBeenCalledTimes(2);
expect(httpGet).toHaveBeenCalledTimes(2);
expect(isImdsCredentials).toHaveBeenCalledTimes(1);
expect(isImdsCredentials).toHaveBeenCalledWith(mockImdsCreds);
expect(fromImdsCredentials).not.toHaveBeenCalled();
});

it("throws Error if requestFromEc2Imds for profile fails", async () => {
const mockError = new Error("profile not found");
(httpGet as jest.Mock).mockRejectedValueOnce(mockError);
(retry as jest.Mock).mockImplementation((fn: any) => fn());

await expect(fromInstanceMetadata()()).rejects.toEqual(mockError);
expect(retry).toHaveBeenCalledTimes(1);
expect(httpGet).toHaveBeenCalledTimes(1);
});

it("throws Error if requestFromEc2Imds for credentials fails", async () => {
const mockError = new Error("creds not found");
(httpGet as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockRejectedValueOnce(mockError);
(retry as jest.Mock).mockImplementation((fn: any) => fn());

await expect(fromInstanceMetadata()()).rejects.toEqual(mockError);
expect(retry).toHaveBeenCalledTimes(2);
expect(httpGet).toHaveBeenCalledTimes(2);
expect(fromImdsCredentials).not.toHaveBeenCalled();
});

it("throws SyntaxError if requestFromEc2Imds returns unparseable creds", async () => {
(httpGet as jest.Mock)
.mockResolvedValueOnce(mockProfile)
.mockResolvedValueOnce(".");
(retry as jest.Mock).mockImplementation((fn: any) => fn());

await expect(fromInstanceMetadata()()).rejects.toEqual(
new SyntaxError("Unexpected token . in JSON at position 0")
);
expect(retry).toHaveBeenCalledTimes(2);
expect(httpGet).toHaveBeenCalledTimes(2);
expect(fromImdsCredentials).not.toHaveBeenCalled();
});
});
18 changes: 11 additions & 7 deletions packages/credential-provider-imds/src/fromInstanceMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ import { ProviderError } from "@aws-sdk/property-provider";
* Creates a credential provider that will source credentials from the EC2
* Instance Metadata Service
*/
export function fromInstanceMetadata(
export const fromInstanceMetadata = (
init: RemoteProviderInit = {}
): CredentialProvider {
): CredentialProvider => {
const { timeout, maxRetries } = providerConfigFromInit(init);
return async () => {
const profile = (
Expand All @@ -40,15 +40,19 @@ export function fromInstanceMetadata(
return fromImdsCredentials(credsResponse);
}, maxRetries);
};
}
};

const IMDS_IP = "169.254.169.254";
const IMDS_PATH = "latest/meta-data/iam/security-credentials";

function requestFromEc2Imds(timeout: number, path?: string): Promise<string> {
return httpGet({
const requestFromEc2Imds = async (
timeout: number,
path?: string
): Promise<string> => {
const buffer = await httpGet({
host: IMDS_IP,
path: `/${IMDS_PATH}/${path ? path : ""}`,
timeout
}).then(buffer => buffer.toString());
}
});
return buffer.toString();
};

0 comments on commit f48ce92

Please sign in to comment.