Skip to content
Merged
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
72 changes: 72 additions & 0 deletions packages/cli/src/commands/__tests__/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,22 @@ describe("env.ts", () => {
it("should update secret when valid secret key is provided", async () => {
vi.mocked(promptApiToken).mockResolvedValue("mock-api-token");
const mockSetSecrets = vi.fn().mockResolvedValue(true);
const mockGetAccountId = vi
.fn()
.mockResolvedValue("1234567890abcdef1234567890abcdef");

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

await envCommand("token");

expect(promptApiToken).toHaveBeenCalledWith(
"1234567890abcdef1234567890abcdef",
);
expect(mockSetSecrets).toHaveBeenCalledWith({
CF_BEARER_TOKEN: "mock-api-token",
});
Expand All @@ -73,10 +80,14 @@ describe("env.ts", () => {
);
vi.mocked(getScriptSnippet).mockReturnValue("mock-snippet");
const mockSetSecrets = vi.fn().mockResolvedValue(true);
const mockGetAccountId = vi
.fn()
.mockResolvedValue("1234567890abcdef1234567890abcdef");

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

Expand All @@ -86,6 +97,8 @@ describe("env.ts", () => {

await envCommand("tracker-script");

expect(mockGetAccountId).not.toHaveBeenCalled();
expect(promptTrackerScriptName).toHaveBeenCalledWith(undefined);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("Use this HTML snippet"),
);
Expand Down Expand Up @@ -121,10 +134,14 @@ describe("env.ts", () => {
vi.mocked(select).mockResolvedValue("token");
vi.mocked(promptApiToken).mockResolvedValue("mock-api-token");
const mockSetSecrets = vi.fn().mockResolvedValue(true);
const mockGetAccountId = vi
.fn()
.mockResolvedValue("1234567890abcdef1234567890abcdef");

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

Expand Down Expand Up @@ -165,10 +182,14 @@ describe("env.ts", () => {
it("should handle secret update failure", async () => {
vi.mocked(promptApiToken).mockResolvedValue("mock-api-token");
const mockSetSecrets = vi.fn().mockResolvedValue(false);
const mockGetAccountId = vi
.fn()
.mockResolvedValue("1234567890abcdef1234567890abcdef");

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

Expand Down Expand Up @@ -196,6 +217,15 @@ describe("env.ts", () => {
vi.mocked(promptApiToken).mockRejectedValue(
new Error("Prompt error"),
);
const mockGetAccountId = vi
.fn()
.mockResolvedValue("1234567890abcdef1234567890abcdef");

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
getAccountId: mockGetAccountId,
} as any;
});
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
Expand All @@ -217,6 +247,48 @@ describe("env.ts", () => {
processSpy.mockRestore();
});

it("should fall back to user-token validation when account ID lookup fails", async () => {
vi.mocked(promptApiToken).mockResolvedValue("mock-api-token");
const mockSetSecrets = vi.fn().mockResolvedValue(true);
const mockGetAccountId = vi
.fn()
.mockRejectedValue(new Error("Not authenticated"));

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

await envCommand("token");

expect(promptApiToken).toHaveBeenCalledWith(undefined);
expect(mockSetSecrets).toHaveBeenCalledWith({
CF_BEARER_TOKEN: "mock-api-token",
});
});

it("should fall back to user-token validation when no account ID is found", async () => {
vi.mocked(promptApiToken).mockResolvedValue("mock-api-token");
const mockSetSecrets = vi.fn().mockResolvedValue(true);
const mockGetAccountId = vi.fn().mockResolvedValue(null);

vi.mocked(CloudflareClient).mockImplementation(function () {
return {
setCloudflareSecrets: mockSetSecrets,
getAccountId: mockGetAccountId,
} as any;
});

await envCommand("token");

expect(promptApiToken).toHaveBeenCalledWith(undefined);
expect(mockSetSecrets).toHaveBeenCalledWith({
CF_BEARER_TOKEN: "mock-api-token",
});
});

it("should throw error when secret configuration not found", async () => {
vi.mocked(select).mockResolvedValue("nonexistent");
vi.mocked(isCancel).mockReturnValue(false);
Expand Down
83 changes: 82 additions & 1 deletion packages/cli/src/commands/__tests__/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ vi.mock("@clack/prompts", () => ({
text: vi.fn(),
select: vi.fn(),
intro: vi.fn(),
note: vi.fn(),
outro: vi.fn(),
spinner: vi.fn(() => ({
start: vi.fn(),
stop: vi.fn(),
Expand All @@ -20,6 +22,24 @@ vi.mock("@clack/prompts", () => ({
},
}));

vi.mock("../../lib/config.js");

vi.mock("../../lib/ui.js", () => ({
CLI_COLORS: {
orange: [245, 107, 61],
tan: [243, 227, 190],
teal: [0, 205, 205],
},
MIN_PASSWORD_LENGTH: 8,
getTitle: vi.fn(),
highlightTheme: {},
getScriptSnippet: vi.fn(),
getPackageSnippet: vi.fn(),
promptForPassword: vi.fn(),
promptApiToken: vi.fn(),
promptTrackerScriptName: vi.fn(),
}));

vi.mock("../../lib/cloudflare.js", () => {
const mockValidateToken = vi.fn();
const MockCloudflareClient = vi.fn().mockImplementation(() => ({
Expand All @@ -37,15 +57,22 @@ vi.mock("../../lib/cloudflare.js", () => {
});

// Now import the actual modules
import { isCancel } from "@clack/prompts";
import { isCancel, note, confirm, spinner } from "@clack/prompts";

// Import after mocks are set up
import {
promptDeploy,
promptProjectConfig,
promptAccountSelection,
install,
type AccountInfo,
} from "../install.js";
import { CloudflareClient } from "../../lib/cloudflare.js";
import {
getWorkerAndDatasetName,
stageDeployConfig,
} from "../../lib/config.js";
import { promptApiToken } from "../../lib/ui.js";

describe("install prompts", () => {
let mockExit: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -258,6 +285,60 @@ describe("install prompts", () => {
});
});

describe("install", () => {
it("should prompt for an API token with the selected account ID when CF_BEARER_TOKEN is missing", async () => {
const accountId = "1234567890abcdef1234567890abcdef";
const apiToken = "m".repeat(40);

vi.mocked(spinner).mockImplementation(
() =>
({
start: vi.fn(),
stop: vi.fn(),
}) as any,
);

const mockSetSecrets = vi.fn().mockResolvedValue(true);
vi.mocked(CloudflareClient).mockImplementation(function () {
return {
getAccounts: vi
.fn()
.mockResolvedValue([
{ id: accountId, name: "Test Account" },
]),
getCloudflareSecrets: vi.fn().mockResolvedValue({
CF_AUTH_ENABLED: "true",
CF_PASSWORD_HASH: "hash",
CF_JWT_SECRET: "secret",
}),
setCloudflareSecrets: mockSetSecrets,
deploy: vi.fn(),
} as any;
});

vi.mocked(getWorkerAndDatasetName).mockReturnValue({
workerName: "counterscale",
analyticsDataset: "metricsDataset",
});
vi.mocked(stageDeployConfig).mockResolvedValue(undefined);
vi.mocked(promptApiToken).mockResolvedValue(apiToken);
vi.mocked(confirm).mockResolvedValue(false);

await install({} as any, "/mock/server/dir", { version: "3.5.0" });

expect(promptApiToken).toHaveBeenCalledWith(accountId);
expect(note).toHaveBeenCalledWith(
expect.stringContaining(
`https://dash.cloudflare.com/${accountId}/api-tokens`,
),
);
expect(mockSetSecrets).toHaveBeenCalledWith({
CF_ACCOUNT_ID: accountId,
CF_BEARER_TOKEN: apiToken,
});
});
});

describe("account selection logic", () => {
it("should handle single account case", () => {
const mockAccounts: AccountInfo[] = [
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/commands/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface SecretConfig {
key: SupportedSecret;
name: string;
description: string;
prompt: () => Promise<string>;
prompt: (accountId?: string) => Promise<string>;
}

export const SECRETS_BY_ALIAS = new Map<string, SecretConfig>([
Expand Down Expand Up @@ -89,7 +89,16 @@ export async function envCommand(secretKey?: string) {

console.log(`Updating ${selectedSecret.name}...`);

const secretValue = await selectedSecret.prompt();
let accountId: string | undefined;
if (selectedSecret.key === "CF_BEARER_TOKEN") {
try {
accountId = (await cloudflare.getAccountId()) ?? undefined;
} catch {
accountId = undefined;
}
}

const secretValue = await selectedSecret.prompt(accountId);

const success = await cloudflare.setCloudflareSecrets({
[selectedSecret.key]: secretValue,
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,15 @@ export async function install(
"https://dash.cloudflare.com/profile/api-tokens",
)}

Or use an account-owned token from your account's API Tokens page: ${chalk.bold(
`https://dash.cloudflare.com/${accountId}/api-tokens`,
)}

Your token needs these permissions:

- Account Analytics: Read`,
);
const apiToken = await promptApiToken();
const apiToken = await promptApiToken(accountId);
if (apiToken) {
const s = spinner();
s.start(`Setting Cloudflare API token ...`);
Expand Down
Loading
Loading