diff --git a/__mocks__/@firebase/auth.ts b/__mocks__/@firebase/auth.ts new file mode 100644 index 0000000..4023916 --- /dev/null +++ b/__mocks__/@firebase/auth.ts @@ -0,0 +1,13 @@ +export const getAuth = jest.fn().mockReturnValue({}); + +export const signInWithCredential = jest.fn(); + +export const SignInMethod = { + GOOGLE: "google.com", +}; + +export class OAuthProvider { + credential() { + return "test-credential"; + } +} diff --git a/__mocks__/firebase/firestore.ts b/__mocks__/firebase/firestore.ts index 00fa259..281f8c0 100644 --- a/__mocks__/firebase/firestore.ts +++ b/__mocks__/firebase/firestore.ts @@ -1,15 +1,37 @@ -import { noop } from "lodash"; - export const doc = jest.fn(); +export const getCollection = jest.fn(); + +export const getDoc = jest.fn(); + export const getFirestore = jest.fn().mockReturnValue({}); let snapshotCallbacks: ((snapshot: object) => void)[] = []; +/** Triggerable mock onSnapshot + * + * Usage: + * ``` + * import { onSnapshot } from "firebase/firestore"; + * + * beforeEach(() => { + * (onSnapshot as any).clear(); + * }) + * + * test(..., () => { + * // call code under test here + * (onSnapshot as any).trigger(data) + * }) + * ``` + * + * Note that only one `onSnapshot` may be tested at a time. + */ export const onSnapshot = Object.assign( jest.fn().mockImplementation((_doc, cb) => { snapshotCallbacks.push(cb); - return noop; + return () => { + snapshotCallbacks = []; + }; }), { clear: (snapshotCallbacks = []), @@ -21,4 +43,6 @@ export const onSnapshot = Object.assign( } ); +export const query = jest.fn(); + export const terminate = jest.fn(); diff --git a/jest.config.js b/jest.config.js index 5eb9351..60dc0bb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,7 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { + testRegex: ".*\\.test\\.ts$", + prettierPath: null, preset: "ts-jest", testEnvironment: "node", modulePathIgnorePatterns: ["/build"], diff --git a/src/commands/__tests__/__snapshots__/login.test.ts.snap b/src/commands/__tests__/__snapshots__/login.test.ts.snap new file mode 100644 index 0000000..8475ae4 --- /dev/null +++ b/src/commands/__tests__/__snapshots__/login.test.ts.snap @@ -0,0 +1,40 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`login organization exists should write the user's identity to the file system 1`] = ` +[ + [ + "/path/to/home/.p0", + "{ + "credential": { + "access_token": "test-access-token", + "id_token": "test-id-token", + "token_type": "oidc", + "scope": "oidc", + "expires_in": 3600, + "refresh_token": "test-refresh-token", + "device_secret": "test-device-secret", + "expires_at": 1600003599 + }, + "org": { + "slug": "test-org", + "tenantId": "test-tenant", + "ssoProvider": "google" + } +}", + { + "mode": "600", + }, + ], +] +`; + +exports[`login organization exists validates authentication 1`] = ` +[ + [ + { + "tenantId": "test-tenant", + }, + "test-credential", + ], +] +`; diff --git a/src/commands/__tests__/login.test.ts b/src/commands/__tests__/login.test.ts new file mode 100644 index 0000000..f029c7d --- /dev/null +++ b/src/commands/__tests__/login.test.ts @@ -0,0 +1,66 @@ +import { pluginLoginMap } from "../../plugins/login"; +import { mockGetDoc } from "../../testing/firestore"; +import { login } from "../login"; +import { signInWithCredential } from "firebase/auth"; +import { readFile, writeFile } from "fs/promises"; + +jest.spyOn(Date, "now").mockReturnValue(1.6e12); +jest.mock("fs/promises"); +jest.mock("../../drivers/auth", () => ({ + ...jest.requireActual("../../drivers/auth"), + IDENTITY_FILE_PATH: "/path/to/home/.p0", +})); +jest.mock("../../drivers/stdio"); +jest.mock("../../plugins/login"); + +const mockReadFile = readFile as jest.Mock; +const mockWriteFile = writeFile as jest.Mock; + +describe("login", () => { + it("prints a friendly error if the org is not found", async () => { + mockGetDoc(undefined); + await expect(login({ org: "test-org" })).rejects.toMatchInlineSnapshot( + `"Could not find organization"` + ); + }); + it("should print a friendly error if unsupported login", async () => { + mockGetDoc({ + slug: "test-org", + tenantId: "test-tenant", + ssoProvider: "microsoft", + }); + await expect(login({ org: "test-org" })).rejects.toMatchInlineSnapshot( + `"Unsupported login for your organization"` + ); + }); + describe("organization exists", () => { + let credentialData: string = ""; + mockReadFile.mockImplementation(async () => + Buffer.from(credentialData, "utf-8") + ); + mockWriteFile.mockImplementation(async (_path, data) => { + credentialData = data; + }); + beforeEach(() => { + credentialData = ""; + jest.clearAllMocks(); + mockGetDoc({ + slug: "test-org", + tenantId: "test-tenant", + ssoProvider: "google", + }); + }); + it("should call the provider's login function", async () => { + await login({ org: "test-org" }); + expect(pluginLoginMap.google).toHaveBeenCalled(); + }); + it("should write the user's identity to the file system", async () => { + await login({ org: "test-org" }); + expect(mockWriteFile.mock.calls).toMatchSnapshot(); + }); + it("validates authentication", async () => { + await login({ org: "test-org" }); + expect((signInWithCredential as jest.Mock).mock.calls).toMatchSnapshot(); + }); + }); +}); diff --git a/src/commands/__tests__/ls.test.ts b/src/commands/__tests__/ls.test.ts index 03f128e..fa9c01b 100644 --- a/src/commands/__tests__/ls.test.ts +++ b/src/commands/__tests__/ls.test.ts @@ -1,5 +1,6 @@ import { fetchCommand } from "../../drivers/api"; import { print1, print2 } from "../../drivers/stdio"; +import { failure } from "../../testing/yargs"; import { lsCommand } from "../ls"; import yargs from "yargs"; @@ -51,14 +52,7 @@ Unknown argument: foo`, }); it("should print error message", async () => { - let error: any; - try { - await lsCommand(yargs) - .fail((_, err) => (error = err)) - .parse(command); - } catch (thrown: any) { - error = thrown; - } + const error = await failure(lsCommand(yargs), command); expect(error).toMatchSnapshot(); }); }); diff --git a/src/commands/__tests__/request.test.ts b/src/commands/__tests__/request.test.ts index 4aa9454..c0b7c55 100644 --- a/src/commands/__tests__/request.test.ts +++ b/src/commands/__tests__/request.test.ts @@ -1,5 +1,6 @@ import { fetchCommand } from "../../drivers/api"; import { print1, print2 } from "../../drivers/stdio"; +import { failure } from "../../testing/yargs"; import { RequestResponse } from "../../types/request"; import { sleep } from "../../util"; import { requestCommand } from "../request"; @@ -83,14 +84,7 @@ Unknown argument: foo`, }); it("should print error message", async () => { - let error: any; - try { - await requestCommand(yargs) - .fail((_, err) => (error = err)) - .parse(command); - } catch (thrown: any) { - error = thrown; - } + const error = await failure(requestCommand(yargs), command); expect(error).toMatchSnapshot(); }); }); diff --git a/src/commands/aws/__tests__/__input__/saml-response.ts b/src/commands/aws/__tests__/__input__/saml-response.ts new file mode 100644 index 0000000..fd37d49 --- /dev/null +++ b/src/commands/aws/__tests__/__input__/saml-response.ts @@ -0,0 +1,5 @@ +export const samlResponse = ` + + + +`; diff --git a/src/commands/aws/__tests__/__input__/sts-response.ts b/src/commands/aws/__tests__/__input__/sts-response.ts new file mode 100644 index 0000000..36acb2a --- /dev/null +++ b/src/commands/aws/__tests__/__input__/sts-response.ts @@ -0,0 +1,24 @@ +export const stsResponse = ` + + https://signin.aws.amazon.com/saml + + ABCDEFGHIJLMNOPQRST:test-user@test.com + arn:aws:sts::1:assumed-role/Role1/test-user@test.com + + + test-access-key + secret-access-key + session-token + 2024-02-22T00:18:21Z + + test-user@test.com + abcdefghijklmnop + test-user@test.com + 2 + unspecified + http://www.okta.com/abc + + + f5b94ad4-f322-4d7b-b568-84f2ec184cd7 + +`; diff --git a/src/commands/aws/__tests__/__snapshots__/role.test.ts.snap b/src/commands/aws/__tests__/__snapshots__/role.test.ts.snap new file mode 100644 index 0000000..63d4bca --- /dev/null +++ b/src/commands/aws/__tests__/__snapshots__/role.test.ts.snap @@ -0,0 +1,44 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`aws role a single installed account with Okta SAML assume should assume a role 1`] = ` +[ + [ + "Execute the following commands: +", + ], + [ + " +Or, populate these environment variables using BASH command substitution: + + $(p0 aws role assume undefined) +", + ], +] +`; + +exports[`aws role a single installed account with Okta SAML assume should assume a role 2`] = ` +[ + [ + " export AWS_ACCESS_KEY_ID=test-access-key + export AWS_SECRET_ACCESS_KEY=secret-access-key + export AWS_SESSION_TOKEN=session-token", + ], +] +`; + +exports[`aws role a single installed account with Okta SAML ls lists roles 1`] = ` +[ + [ + "Your available roles for account 1:", + ], +] +`; + +exports[`aws role a single installed account with Okta SAML ls lists roles 2`] = ` +[ + [ + " Role1 + Role2", + ], +] +`; diff --git a/src/commands/aws/__tests__/role.test.ts b/src/commands/aws/__tests__/role.test.ts new file mode 100644 index 0000000..053caab --- /dev/null +++ b/src/commands/aws/__tests__/role.test.ts @@ -0,0 +1,86 @@ +import { awsCommand } from ".."; +import { print1, print2 } from "../../../drivers/stdio"; +import { mockGetDoc } from "../../../testing/firestore"; +import { failure } from "../../../testing/yargs"; +import { samlResponse } from "./__input__/saml-response"; +import { stsResponse } from "./__input__/sts-response"; +import yargs from "yargs"; + +jest.mock("fs/promises"); +jest.mock("../../../drivers/auth"); +jest.mock("../../../drivers/stdio"); +jest.mock("typescript", () => ({ + ...jest.requireActual("typescript"), + sys: { + writeOutputIsTTY: () => true, + }, +})); + +const mockFetch = jest.spyOn(global, "fetch"); +const mockPrint1 = print1 as jest.Mock; +const mockPrint2 = print2 as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockImplementation( + async (url: RequestInfo | URL) => + ({ + ok: true, + // This is the token response from fetchSsoWebToken + json: async () => ({}), + // This is the XML response from fetchSamlResponse or stsAssumeRole + text: async () => + (url as string).match(/okta.com/) ? samlResponse : stsResponse, + }) as Response + ); +}); + +describe("aws role", () => { + describe("a single installed account", () => { + const item = { + account: { + id: "1", + description: "1 (test)", + }, + state: "installed", + }; + describe("without Okta SAML", () => { + mockGetDoc({ workflows: { items: [item] } }); + describe.each([ + ["ls", "aws role ls"], + ["assume", "aws role assume Role1"], + ])("%s", (_, command) => { + it("should print a friendly error message", async () => { + const error = await failure(awsCommand(yargs), command); + expect(error).toMatchInlineSnapshot( + `"Account 1 (test) is not configured for Okta SAML login."` + ); + }); + }); + }); + describe("with Okta SAML", () => { + beforeEach(() => { + mockGetDoc({ + workflows: { + items: [{ ...item, uidLocation: { id: "okta_saml_sso" } }], + }, + }); + }); + describe("assume", () => { + it("should assume a role", async () => { + await awsCommand(yargs).parse("aws role assume Role1"); + expect(mockPrint2.mock.calls).toMatchSnapshot(); + expect(mockPrint1.mock.calls).toMatchSnapshot(); + }); + }); + describe("ls", () => { + const command = "aws role ls"; + it("lists roles", async () => { + await awsCommand(yargs).parse(command); + expect(mockPrint2.mock.calls).toMatchSnapshot(); + expect(mockPrint1.mock.calls).toMatchSnapshot(); + }); + }); + }); + }); +}); diff --git a/src/commands/login.ts b/src/commands/login.ts index fce5c18..51d88dd 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,8 +1,7 @@ import { IDENTITY_FILE_PATH, authenticate } from "../drivers/auth"; import { doc, guard } from "../drivers/firestore"; import { print2 } from "../drivers/stdio"; -import { googleLogin } from "../plugins/google/login"; -import { oktaLogin } from "../plugins/okta/login"; +import { pluginLoginMap } from "../plugins/login"; import { TokenResponse } from "../types/oidc"; import { OrgData } from "../types/org"; import { getDoc } from "firebase/firestore"; @@ -10,13 +9,11 @@ import * as fs from "fs/promises"; import * as path from "path"; import yargs from "yargs"; -const pluginLoginMap: Record Promise> = - { - google: googleLogin, - okta: oktaLogin, - "oidc-pkce": async (org) => await pluginLoginMap[org.providerType!]!(org), - }; - +/** Logs in the user + * + * Currently only supports login to a single organization. Login credentials, together + * with organization details, are saved to ~/.p0/identity.json. + */ export const login = async ( args: { org: string }, options?: { skipAuthenticate?: boolean } diff --git a/src/drivers/__mocks__/auth.ts b/src/drivers/__mocks__/auth.ts index 4f1a8e2..66305a9 100644 --- a/src/drivers/__mocks__/auth.ts +++ b/src/drivers/__mocks__/auth.ts @@ -1,6 +1,12 @@ export const authenticate = async () => ({ identity: { + credential: { + access_token: "test-access-token", + }, org: { + ssoProvider: "oidc-pkce", + providerDomain: "test.okta.com", + providerType: "okta", slug: "test-org", tenantId: "test-tenant", }, @@ -11,3 +17,6 @@ export const authenticate = async () => ({ }, }, }); + +export const cached = async (_label: string, callback: () => Promise) => + await callback(); diff --git a/src/plugins/__mocks__/login.ts b/src/plugins/__mocks__/login.ts new file mode 100644 index 0000000..4b4b067 --- /dev/null +++ b/src/plugins/__mocks__/login.ts @@ -0,0 +1,11 @@ +export const pluginLoginMap = { + google: jest.fn().mockResolvedValue({ + access_token: "test-access-token", + id_token: "test-id-token", + token_type: "oidc", + scope: "oidc", + expires_in: 3600, + refresh_token: "test-refresh-token", + device_secret: "test-device-secret", + }), +}; diff --git a/src/plugins/__mocks__/assumeRole.ts b/src/plugins/aws/__mocks__/assumeRole.ts similarity index 82% rename from src/plugins/__mocks__/assumeRole.ts rename to src/plugins/aws/__mocks__/assumeRole.ts index 5952650..7e1bb3f 100644 --- a/src/plugins/__mocks__/assumeRole.ts +++ b/src/plugins/aws/__mocks__/assumeRole.ts @@ -1,4 +1,4 @@ -import { AwsCredentials } from "../aws/types"; +import { AwsCredentials } from "../types"; export const assumeRoleWithSaml = async (): Promise => ({ AWS_ACCESS_KEY_ID: "test-access-key-id", diff --git a/src/plugins/login.ts b/src/plugins/login.ts new file mode 100644 index 0000000..3ce7745 --- /dev/null +++ b/src/plugins/login.ts @@ -0,0 +1,13 @@ +import { TokenResponse } from "../types/oidc"; +import { OrgData } from "../types/org"; +import { googleLogin } from "./google/login"; +import { oktaLogin } from "./okta/login"; + +export const pluginLoginMap: Record< + string, + (org: OrgData) => Promise +> = { + google: googleLogin, + okta: oktaLogin, + "oidc-pkce": async (org) => await pluginLoginMap[org.providerType!]!(org), +}; diff --git a/src/testing/firestore.ts b/src/testing/firestore.ts new file mode 100644 index 0000000..eec9a2d --- /dev/null +++ b/src/testing/firestore.ts @@ -0,0 +1,4 @@ +import { getDoc } from "firebase/firestore"; + +export const mockGetDoc = (data: any) => + (getDoc as jest.Mock).mockResolvedValue({ data: () => data }); diff --git a/src/testing/yargs.ts b/src/testing/yargs.ts new file mode 100644 index 0000000..ce7d9b7 --- /dev/null +++ b/src/testing/yargs.ts @@ -0,0 +1,11 @@ +import yargs from "yargs"; + +export const failure = async (spec: yargs.Argv, command: string) => { + let error: any; + try { + await spec.fail((_, err) => (error = err)).parse(command); + } catch (thrown: any) { + error = thrown; + } + return error; +};