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(); 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(); return { ...actual, typedPlatformClient: vi.fn(() => ({ POST: mockPlatformPost })), }; }); vi.mock("./lib/deploymentSelection.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, initializeBigBrainAuth: vi.fn(), getDeploymentSelection: vi.fn(), }; }); vi.mock("./lib/api.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSelectedDeploymentCredentials: vi.fn(), }; }); // Default in-memory FS used when not overridden by a specific test. let testFiles: Map; let consoleLogSpy: ReturnType; 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 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(); }); });