convex
Version:
Client for the Convex Cloud
373 lines (327 loc) • 11.9 kB
text/typescript
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import path from "path";
import { nodeFs } from "../bundler/fs.js";
import { deploymentTokenCreate } from "./deploymentTokenCreate.js";
import { deploymentTokenDelete } from "./deploymentTokenDelete.js";
import { typedPlatformClient } from "./lib/utils/utils.js";
import {
getDeploymentSelection,
initializeBigBrainAuth,
} from "./lib/deploymentSelection.js";
import { loadSelectedDeploymentCredentials } from "./lib/api.js";
import type { Context } from "../bundler/context.js";
vi.mock("@sentry/node", () => ({
captureException: vi.fn(),
close: vi.fn(),
}));
vi.mock("../bundler/fs.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../bundler/fs.js")>();
return {
...actual,
nodeFs: {
...actual.nodeFs,
exists: vi.fn(),
readUtf8File: vi.fn(),
writeUtf8File: vi.fn(),
mkdir: vi.fn(),
},
};
});
const mockPlatformPost = vi.fn();
vi.mock("./lib/utils/utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/utils/utils.js")>();
return {
...actual,
typedPlatformClient: vi.fn(() => ({ POST: mockPlatformPost })),
};
});
vi.mock("./lib/deploymentSelection.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("./lib/deploymentSelection.js")>();
return {
...actual,
initializeBigBrainAuth: vi.fn(),
getDeploymentSelection: vi.fn(),
};
});
vi.mock("./lib/api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./lib/api.js")>();
return {
...actual,
loadSelectedDeploymentCredentials: vi.fn(),
};
});
// Default in-memory FS used when not overridden by a specific test.
let testFiles: Map<string, string>;
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
function setAuthKind(
kind: "accessToken" | "deploymentKey" | "projectKey" | "previewDeployKey",
) {
vi.mocked(initializeBigBrainAuth).mockImplementation(async (ctx) => {
const fakeAuth =
kind === "accessToken"
? {
kind: "accessToken" as const,
accessToken: "test-token",
header: "Bearer test-token",
}
: kind === "deploymentKey"
? {
kind: "deploymentKey" as const,
deploymentKey: "dev:foo|secret",
header: "Bearer dev:foo|secret",
}
: kind === "projectKey"
? {
kind: "projectKey" as const,
projectKey: "project:foo|secret",
header: "Bearer project:foo|secret",
}
: {
kind: "previewDeployKey" as const,
previewDeployKey: "preview:t:p|secret",
header: "Bearer preview:t:p|secret",
};
(ctx as Context)._updateBigBrainAuth(fakeAuth);
});
}
function setNoAuth() {
vi.mocked(initializeBigBrainAuth).mockImplementation(async (ctx) => {
(ctx as Context)._updateBigBrainAuth(null);
});
}
function mockSelectedDeployment(
deploymentName: string,
deploymentType: "dev" | "prod" | "preview" | "custom" | "local" | "anonymous",
) {
vi.mocked(getDeploymentSelection).mockResolvedValue({
kind: "existingDeployment",
deploymentToActOn: {
url: `https://${deploymentName}.convex.cloud`,
adminKey: "admin-key",
source: "deployKey",
deploymentFields: {
deploymentName,
deploymentType,
teamSlug: "my-team",
projectSlug: "my-project",
},
},
});
vi.mocked(loadSelectedDeploymentCredentials).mockResolvedValue({
url: `https://${deploymentName}.convex.cloud`,
adminKey: "admin-key",
deploymentFields: {
deploymentName,
deploymentType,
teamSlug: "my-team",
projectSlug: "my-project",
},
});
}
beforeEach(() => {
vi.resetAllMocks();
// Set up an in-memory FS that the env-file write/read paths can use.
testFiles = new Map();
vi.mocked(nodeFs.exists).mockImplementation((p: string) =>
testFiles.has(path.resolve(p)),
);
vi.mocked(nodeFs.readUtf8File).mockImplementation((p: string) => {
const content = testFiles.get(path.resolve(p));
if (content === undefined) {
const err: any = new Error(
`ENOENT: no such file or directory, open '${p}'`,
);
err.code = "ENOENT";
throw err;
}
return content;
});
vi.mocked(nodeFs.writeUtf8File).mockImplementation(
(p: string, content: string) => {
testFiles.set(path.resolve(p), content);
},
);
vi.mocked(typedPlatformClient).mockReturnValue({
POST: mockPlatformPost,
} as any);
mockPlatformPost.mockReset();
vi.spyOn(process, "exit").mockImplementation((() => {
throw new Error("process.exit called");
}) as any);
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
// logOutput uses console.log, so spy on it (not stdout.write).
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("deployment token create", () => {
test("crashes when not authed with a personal access token", async () => {
setAuthKind("deploymentKey");
mockSelectedDeployment("joyful-capybara-123", "dev");
await expect(
deploymentTokenCreate.parseAsync(["my-token"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining(
"requires being logged in with a personal access token",
),
);
expect(mockPlatformPost).not.toHaveBeenCalled();
});
test("crashes when no auth is configured at all", async () => {
setNoAuth();
mockSelectedDeployment("joyful-capybara-123", "dev");
await expect(
deploymentTokenCreate.parseAsync(["my-token"], { from: "user" }),
).rejects.toThrow();
expect(mockPlatformPost).not.toHaveBeenCalled();
});
test("rejects local deployments client-side", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("local-deployment", "local");
await expect(
deploymentTokenCreate.parseAsync(["my-token"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("Cannot create a deploy key for a local"),
);
expect(mockPlatformPost).not.toHaveBeenCalled();
});
test("prints the new deploy key to stdout when --save-env is omitted", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({
data: { deployKey: "dev:joyful-capybara-123|new-token" },
});
await deploymentTokenCreate.parseAsync(["my-token"], { from: "user" });
expect(mockPlatformPost).toHaveBeenCalledWith(
"/deployments/{deployment_name}/create_deploy_key",
{
params: { path: { deployment_name: "joyful-capybara-123" } },
body: { name: "my-token" },
},
);
expect(consoleLogSpy).toHaveBeenCalledWith(
"dev:joyful-capybara-123|new-token",
);
// .env.local should not have been written.
expect(testFiles.has(path.resolve(".env.local"))).toBe(false);
});
test("--save-env writes the key to .env.local by default", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({
data: { deployKey: "dev:joyful-capybara-123|new-token" },
});
await deploymentTokenCreate.parseAsync(["my-token", "--save-env"], {
from: "user",
});
const written = testFiles.get(path.resolve(".env.local"));
expect(written).toContain(
"CONVEX_DEPLOY_KEY=dev:joyful-capybara-123|new-token",
);
// The raw deploy key shouldn't have been printed to stdout.
expect(consoleLogSpy).not.toHaveBeenCalled();
});
test("--save-env <path> writes to a custom env file", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({
data: { deployKey: "dev:joyful-capybara-123|new-token" },
});
await deploymentTokenCreate.parseAsync(
["ci-token", "--save-env", ".env.production"],
{ from: "user" },
);
expect(testFiles.has(path.resolve(".env.local"))).toBe(false);
const written = testFiles.get(path.resolve(".env.production"));
expect(written).toContain(
"CONVEX_DEPLOY_KEY=dev:joyful-capybara-123|new-token",
);
});
test("--save-env replaces an existing CONVEX_DEPLOY_KEY in the file", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({
data: { deployKey: "dev:joyful-capybara-123|new-token" },
});
testFiles.set(
path.resolve(".env.local"),
"FOO=bar\nCONVEX_DEPLOY_KEY=dev:joyful-capybara-123|old-token\n",
);
await deploymentTokenCreate.parseAsync(["my-token", "--save-env"], {
from: "user",
});
const written = testFiles.get(path.resolve(".env.local"));
expect(written).toContain("FOO=bar");
expect(written).toContain(
"CONVEX_DEPLOY_KEY=dev:joyful-capybara-123|new-token",
);
expect(written).not.toContain("old-token");
});
});
describe("deployment token delete", () => {
test("crashes when not authed with a personal access token", async () => {
setAuthKind("deploymentKey");
mockSelectedDeployment("joyful-capybara-123", "dev");
await expect(
deploymentTokenDelete.parseAsync(["my-token"], { from: "user" }),
).rejects.toThrow();
expect(mockPlatformPost).not.toHaveBeenCalled();
});
test("sends the friendly name as-is when no `|` is present", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({ data: undefined });
await deploymentTokenDelete.parseAsync(["my-token"], { from: "user" });
expect(mockPlatformPost).toHaveBeenCalledWith(
"/deployments/{deployment_name}/delete_deploy_key",
{
params: { path: { deployment_name: "joyful-capybara-123" } },
body: { id: "my-token" },
},
);
});
test("strips the deploy-key prefix before sending to the server", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
mockPlatformPost.mockResolvedValue({ data: undefined });
await deploymentTokenDelete.parseAsync(
["dev:joyful-capybara-123|the-secret-token"],
{ from: "user" },
);
expect(mockPlatformPost).toHaveBeenCalledWith(
"/deployments/{deployment_name}/delete_deploy_key",
{
params: { path: { deployment_name: "joyful-capybara-123" } },
body: { id: "the-secret-token" },
},
);
});
test("warns and crashes when the value looks like an unquoted partial deploy key", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("joyful-capybara-123", "dev");
await expect(
deploymentTokenDelete.parseAsync(["dev:joyful-capybara-123"], {
from: "user",
}),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("looks like a partial deploy key"),
);
expect(mockPlatformPost).not.toHaveBeenCalled();
});
test("rejects local deployments client-side", async () => {
setAuthKind("accessToken");
mockSelectedDeployment("local-deployment", "local");
await expect(
deploymentTokenDelete.parseAsync(["my-token"], { from: "user" }),
).rejects.toThrow();
expect(process.stderr.write).toHaveBeenCalledWith(
expect.stringContaining("Cannot delete a deploy key for a local"),
);
expect(mockPlatformPost).not.toHaveBeenCalled();
});
});