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;
+};