Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/commands/__tests__/allow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/** Copyright © 2024-present P0 Security

This file is part of @p0security/cli

@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.

@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see <https://www.gnu.org/licenses/>.
**/
import { fetchCommand } from "../../drivers/api";
import { authenticate } from "../../drivers/auth";
import { allowCommand } from "../allow";
import { beforeEach, describe, expect, it, vi, Mock } from "vitest";
import yargs from "yargs";

vi.mock("../../drivers/api");
vi.mock("../../drivers/auth", () => ({ authenticate: vi.fn() }));
vi.mock("../../drivers/stdio");

const mockFetchCommand = fetchCommand as Mock;
const mockAuthenticate = authenticate as Mock;

describe("allow", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchCommand.mockResolvedValue({ ok: true, message: "a message" });
});

it("consumes --debug instead of forwarding it to P0", async () => {
await allowCommand(yargs()).parse("allow gcloud role viewer --debug");
expect(mockFetchCommand).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ debug: true }),
["allow", "gcloud", "role", "viewer"]
);
expect(mockAuthenticate).toHaveBeenCalledWith({ debug: true });
});

it("consumes --debug when it comes before the arguments", async () => {
await allowCommand(yargs()).parse("allow --debug gcloud role viewer");
expect(mockFetchCommand).toHaveBeenCalledWith(
undefined,
expect.objectContaining({ debug: true }),
["allow", "gcloud", "role", "viewer"]
);
});

it("does not set debug without --debug", async () => {
await allowCommand(yargs()).parse("allow gcloud role viewer");
expect(mockFetchCommand).toHaveBeenCalledWith(
undefined,
expect.not.objectContaining({ debug: true }),
["allow", "gcloud", "role", "viewer"]
);
expect(mockAuthenticate).toHaveBeenCalledWith({ debug: undefined });
});

it("forwards --help to P0", async () => {
await allowCommand(yargs()).parse("allow --help");
expect(mockFetchCommand).toHaveBeenCalledWith(
undefined,
expect.anything(),
["allow", "--help"]
);
});
});
6 changes: 4 additions & 2 deletions src/commands/allow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@ const allowArgs = <T>(yargs: yargs.Argv<T>) =>
default: false,
describe: "Block until the command is completed",
})
.option("debug", { type: "boolean", describe: "Print debug information." })
.option("arguments", {
array: true,
string: true,
default: [] as string[],
});

export const allowCommand = (yargs: yargs.Argv) =>
yargs.command<{ arguments: string[] }>(
yargs.command<{ arguments: string[]; debug?: boolean }>(
"allow [arguments..]",
"Create standing access for a resource",
allowArgs,
Expand All @@ -45,10 +46,11 @@ export const allow = async (
args: yargs.ArgumentsCamelCase<{
arguments: string[];
wait?: boolean;
debug?: boolean;
}>,
authn?: Authn
): Promise<AllowResponse | undefined> => {
const resolvedAuthn = authn ?? (await authenticate());
const resolvedAuthn = authn ?? (await authenticate({ debug: args.debug }));
const data = await fetchCommand<AllowResponse>(resolvedAuthn, args, [
"allow",
...args.arguments,
Expand Down
66 changes: 65 additions & 1 deletion src/drivers/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ You should have received a copy of the GNU General Public License along with @p0
import * as config from "../../drivers/config";
import { defaultConfig } from "../../drivers/env";
import { Authn } from "../../types/identity";
import { fetchWithStreaming, requestStatusUrl } from "../api";
import { fetchCommand, fetchWithStreaming, requestStatusUrl } from "../api";
import { print2 } from "../stdio";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

// Mock dependencies
vi.mock("../config");
vi.mock("../env");
vi.mock("../stdio");
vi.mock("../../version", () => ({
getUserAgent: () => "P0 CLI/1.0.0 (npm build; linux; x64; node/v20.0.0)",
}));
Expand Down Expand Up @@ -478,3 +480,65 @@ describe("fetchWithStreaming", () => {
);
});
});

describe("fetchCommand", () => {
const mockAuthn: Authn = {
getToken: vi.fn().mockResolvedValue("mock-token"),
identity: {
org: { slug: "test-org" },
},
} as unknown as Authn;
const debugArgs = { $0: "p0", _: [], debug: true };

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(mockAuthn.getToken).mockResolvedValue("mock-token");
vi.spyOn(config, "getTenantConfig").mockReturnValue(defaultConfig);
});
afterEach(() => {
vi.restoreAllMocks();
});

const mockSuccess = () =>
vi.spyOn(global, "fetch").mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({ ok: true, message: "done" }),
} as Response);

it("prints argv and response when debug is set", async () => {
mockSuccess();
await fetchCommand(mockAuthn, debugArgs, ["allow", "x"]);
expect(print2).toHaveBeenCalledWith('Sending command: ["allow","x"]');
expect(print2).toHaveBeenCalledWith(
'Command response: {"ok":true,"message":"done"}'
);
const printed = JSON.stringify(vi.mocked(print2).mock.calls);
expect(printed).not.toContain("mock-token");
expect(printed).not.toContain("Bearer");
});

it("prints the error when debug is set", async () => {
vi.spyOn(global, "fetch").mockResolvedValue({
ok: false,
status: 400,
text: async () => JSON.stringify({ error: "Resource not found" }),
} as Response);
await expect(
fetchCommand(mockAuthn, debugArgs, ["allow", "x"])
).rejects.toThrow("Resource not found");
expect(print2).toHaveBeenCalledWith('Command error: "Resource not found"');
const printed = JSON.stringify(vi.mocked(print2).mock.calls);
expect(printed).not.toContain("mock-token");
expect(printed).not.toContain("Bearer");
});

it("prints nothing when debug is not set", async () => {
mockSuccess();
await fetchCommand(mockAuthn, { $0: "p0", _: [], debug: undefined }, [
"allow",
"x",
]);
expect(print2).not.toHaveBeenCalled();
});
});
41 changes: 31 additions & 10 deletions src/drivers/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,41 @@ export const fetchStreamingStatus = async function* <T>(
);
};

// JSON.stringify prints Error instances as {} and throws on circular values
const formatDebugError = (error: unknown) => {
try {
return error instanceof Error
? (error.stack ?? error.message)
: (JSON.stringify(error) ?? String(error));
} catch {
return "[unserializable error]";
}
};

export const fetchCommand = async <T>(
authn: Authn,
args: yargs.ArgumentsCamelCase<{ debug?: boolean }>,
argv: string[]
) =>
authFetch<T>(authn, {
url: commandUrl(authn.identity.org.slug),
method: "POST",
body: JSON.stringify({
argv,
scriptName: path.basename(args.$0),
}),
debug: args.debug,
});
) => {
const { debug } = args;
if (debug) print2(`Sending command: ${JSON.stringify(argv)}`);
try {
const data = await authFetch<T>(authn, {
url: commandUrl(authn.identity.org.slug),
method: "POST",
body: JSON.stringify({
argv,
scriptName: path.basename(args.$0),
}),
debug,
});
if (debug) print2(`Command response: ${JSON.stringify(data)}`);
return data;
} catch (error) {
if (debug) print2(`Command error: ${formatDebugError(error)}`);
throw error;
}
};

/** Special admin 'ls' command that can retrieve results for all users. Requires 'owner' permission. */
export const fetchAdminLsCommand = async <T>(
Expand Down
Loading