Skip to content

Commit a3757e9

Browse files
committed
non-TTY check for required variables:
Added a check in non-TTY environments for `account_id`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`. If `account_id` exists in `wrangler.toml` then `CLOUDFLARE_ACCOUNT_ID` is not needed in non-TTY scope. The `CLOUDFLARE_API_TOKEN` is necessary in non-TTY scope and will always error if missing. resolves #827
1 parent 97f945f commit a3757e9

File tree

6 files changed

+188
-20
lines changed

6 files changed

+188
-20
lines changed

.changeset/eighty-yaks-jump.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
feat: non-TTY check for required variables
6+
Added a check in non-TTY environments for `account_id`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN`. If `account_id` exists in `wrangler.toml`
7+
then `CLOUDFLARE_ACCOUNT_ID` is not needed in non-TTY scope. The `CLOUDFLARE_API_TOKEN` is necessary in non-TTY scope and will always error if missing.
8+
9+
resolves #827

packages/wrangler/src/__tests__/helpers/mock-oauth-flow.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fetchMock from "jest-fetch-mock";
22
import { Request } from "undici";
3+
import { getCloudflareApiBaseUrl } from "../../cfetch";
34
import openInBrowser from "../../open-in-browser";
45
import { mockHttpServer } from "./mock-http-server";
56

@@ -85,6 +86,28 @@ export const mockOAuthFlow = () => {
8586
return outcome;
8687
};
8788

89+
const mockGetMemberships = (args: {
90+
success: boolean;
91+
result: { id: string; account: { id: string; name: string } }[];
92+
}) => {
93+
const outcome = {
94+
actual: new Request("https://example.org"),
95+
expected: new Request(`${getCloudflareApiBaseUrl()}/memberships`, {
96+
method: "GET",
97+
}),
98+
};
99+
100+
fetchMock.mockIf(outcome.expected.url, async (req) => {
101+
outcome.actual = req;
102+
return {
103+
status: 200,
104+
body: JSON.stringify(args),
105+
};
106+
});
107+
108+
return outcome;
109+
};
110+
88111
const mockGrantAccessToken = ({
89112
respondWith,
90113
}: {
@@ -150,10 +173,11 @@ export const mockOAuthFlow = () => {
150173
};
151174

152175
return {
153-
mockOAuthServerCallback,
176+
mockGetMemberships,
177+
mockGrantAccessToken,
154178
mockGrantAuthorization,
179+
mockOAuthServerCallback,
155180
mockRevokeAuthorization,
156-
mockGrantAccessToken,
157181
mockExchangeRefreshTokenForAccessToken,
158182
};
159183
};

packages/wrangler/src/__tests__/publish.test.ts

+125
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type { WorkerMetadata } from "../create-worker-upload-form";
2222
import type { KVNamespaceInfo } from "../kv";
2323
import type { CfWorkerInit } from "../worker";
2424
import type { FormData, File } from "undici";
25+
import { useMockIsTTY } from "./helpers/mock-istty";
2526

2627
describe("publish", () => {
2728
mockAccountId();
@@ -32,6 +33,7 @@ describe("publish", () => {
3233
mockOAuthServerCallback,
3334
mockGrantAccessToken,
3435
mockGrantAuthorization,
36+
mockGetMemberships,
3537
} = mockOAuthFlow();
3638

3739
beforeEach(() => {
@@ -56,6 +58,7 @@ describe("publish", () => {
5658
});
5759

5860
it("drops a user into the login flow if they're unauthenticated", async () => {
61+
// Should not throw missing Errors in TTY environment
5962
writeWranglerToml();
6063
writeWorkerSource();
6164
mockSubDomainRequest();
@@ -116,6 +119,128 @@ describe("publish", () => {
116119
`);
117120
expect(std.err).toMatchInlineSnapshot(`""`);
118121
});
122+
123+
describe("non-TTY", () => {
124+
const ENV_COPY = process.env;
125+
const { setIsTTY } = useMockIsTTY();
126+
127+
afterEach(() => {
128+
process.env = ENV_COPY;
129+
});
130+
131+
it("should not throw an error in non-TTY if 'CLOUDFLARE_API_TOKEN' & 'account_id' are in scope", async () => {
132+
process.env = {
133+
CLOUDFLARE_API_TOKEN: "123456789",
134+
};
135+
setIsTTY(false);
136+
writeWranglerToml({
137+
account_id: "some-account-id",
138+
});
139+
writeWorkerSource();
140+
mockSubDomainRequest();
141+
mockUploadWorkerRequest();
142+
mockOAuthServerCallback();
143+
144+
await runWrangler("publish index.js");
145+
146+
expect(std.out).toMatchInlineSnapshot(`
147+
"Uploaded test-name (TIMINGS)
148+
Published test-name (TIMINGS)
149+
test-name.test-sub-domain.workers.dev"
150+
`);
151+
expect(std.err).toMatchInlineSnapshot(`""`);
152+
});
153+
154+
it("should not throw an error if 'CLOUDFLARE_ACCOUNT_ID' & 'CLOUDFLARE_API_TOKEN' are in scope", async () => {
155+
process.env = {
156+
CLOUDFLARE_API_TOKEN: "hunter2",
157+
CLOUDFLARE_ACCOUNT_ID: "some-account-id",
158+
};
159+
setIsTTY(false);
160+
writeWranglerToml();
161+
writeWorkerSource();
162+
mockSubDomainRequest();
163+
mockUploadWorkerRequest();
164+
mockOAuthServerCallback();
165+
mockGetMemberships({
166+
success: true,
167+
result: [],
168+
});
169+
170+
await runWrangler("publish index.js");
171+
172+
expect(std.out).toMatchInlineSnapshot(`
173+
"Uploaded test-name (TIMINGS)
174+
Published test-name (TIMINGS)
175+
test-name.test-sub-domain.workers.dev"
176+
`);
177+
expect(std.err).toMatchInlineSnapshot(`""`);
178+
});
179+
180+
it("should throw an error in non-TTY if 'account_id' & 'CLOUDFLARE_ACCOUNT_ID' is missing", async () => {
181+
setIsTTY(false);
182+
process.env = {
183+
CLOUDFLARE_API_TOKEN: "hunter2",
184+
CLOUDFLARE_ACCOUNT_ID: undefined,
185+
};
186+
writeWranglerToml({
187+
account_id: undefined,
188+
});
189+
writeWorkerSource();
190+
mockSubDomainRequest();
191+
mockUploadWorkerRequest();
192+
mockOAuthServerCallback();
193+
mockGetMemberships({
194+
success: true,
195+
result: [
196+
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
197+
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
198+
],
199+
});
200+
201+
await expect(runWrangler("publish index.js")).rejects
202+
.toMatchInlineSnapshot(`
203+
[Error: More than one account available but unable to select one in non-interactive mode.
204+
Please set the appropriate \`account_id\` in your \`wrangler.toml\` file.
205+
Available accounts are ("<name>" - "<id>"):
206+
"enterprise" - "1701")
207+
"enterprise-nx" - "nx01")]
208+
`);
209+
});
210+
211+
it("should throw error in non-TTY if 'CLOUDFLARE_API_TOKEN' is missing", async () => {
212+
setIsTTY(false);
213+
writeWranglerToml({
214+
account_id: undefined,
215+
});
216+
process.env = {
217+
CLOUDFLARE_API_TOKEN: undefined,
218+
CLOUDFLARE_ACCOUNT_ID: "badwolf",
219+
};
220+
writeWorkerSource();
221+
mockSubDomainRequest();
222+
mockUploadWorkerRequest();
223+
mockOAuthServerCallback();
224+
mockGetMemberships({
225+
success: true,
226+
result: [
227+
{ id: "IG-88", account: { id: "1701", name: "enterprise" } },
228+
{ id: "R2-D2", account: { id: "nx01", name: "enterprise-nx" } },
229+
],
230+
});
231+
232+
await expect(runWrangler("publish index.js")).rejects.toThrowError();
233+
234+
expect(std.err).toMatchInlineSnapshot(`
235+
"X [ERROR] Missing 'CLOUDFLARE_API_TOKEN' from non-TTY environment, please see docs for more info: TBD
236+
237+
238+
X [ERROR] Did not login, quitting...
239+
240+
"
241+
`);
242+
});
243+
});
119244
});
120245

121246
describe("environments", () => {

packages/wrangler/src/__tests__/sentry.test.ts

+8-12
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,27 @@ import * as Sentry from "@sentry/node";
77
import prompts from "prompts";
88

99
import { mockConsoleMethods } from "./helpers/mock-console";
10+
import { useMockIsTTY } from "./helpers/mock-istty";
1011
import { runInTempDir } from "./helpers/run-in-tmp";
1112
import { runWrangler } from "./helpers/run-wrangler";
1213
const { reportError } = jest.requireActual("../reporting");
1314

1415
describe("Error Reporting", () => {
16+
const { setIsTTY } = useMockIsTTY();
17+
1518
runInTempDir({ homedir: "./home" });
1619
mockConsoleMethods();
1720
const reportingTOMLPath = ".wrangler/config/reporting.toml";
1821

19-
const originalTTY = process.stdout.isTTY;
2022
beforeEach(() => {
2123
jest.mock("@sentry/node");
2224
jest.spyOn(Sentry, "captureException");
23-
process.stdout.isTTY = true;
25+
setIsTTY(true);
2426
});
2527

2628
afterEach(() => {
2729
jest.unmock("@sentry/node");
2830
jest.clearAllMocks();
29-
process.stdout.isTTY = originalTTY;
3031
});
3132

3233
it("should confirm user will allow error reporting usage", async () => {
@@ -127,20 +128,15 @@ describe("Error Reporting", () => {
127128
});
128129

129130
it("should not prompt in non-TTY environment", async () => {
130-
process.stdout.isTTY = false;
131-
131+
setIsTTY(false);
132132
await reportError(new Error("test error"), "testFalse");
133133

134-
const { error_tracking_opt, error_tracking_opt_date } = TOML.parse(
135-
await fsp.readFile(path.join(os.homedir(), reportingTOMLPath), "utf-8")
136-
);
137-
138-
expect(error_tracking_opt).toBe(false);
139-
expect(error_tracking_opt_date).toBeTruthy();
134+
expect(
135+
fs.existsSync(path.join(os.homedir(), reportingTOMLPath, "utf-8"))
136+
).toBe(false);
140137

141138
expect(Sentry.captureException).not.toHaveBeenCalledWith(
142139
new Error("test error")
143140
);
144-
process.stdout.isTTY = originalTTY;
145141
});
146142
});

packages/wrangler/src/reporting.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ function exceptionTransaction(error: Error, origin = "") {
9393
}
9494

9595
export async function reportError(err: Error, origin = "") {
96-
if (!process.stdout.isTTY) return await appendReportingDecision("false");
96+
// If the user has not opted in to error reporting, we don't want to do anything in CI or non-interactive environments
97+
if (!process.stdout.isTTY) return;
9798

9899
const errorTrackingOpt = await reportingPermission();
99100

packages/wrangler/src/user.tsx

+18-5
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,22 @@ export function getAPIToken(): string | undefined {
410410
"If you wish to authenticate via an API token then please set the `CLOUDFLARE_API_TOKEN` environment variable."
411411
);
412412
}
413-
return LocalState.accessToken?.value;
413+
414+
const localAPIToken = getCloudflareAPITokenFromEnv();
415+
416+
if (
417+
!process.stdout.isTTY &&
418+
!localAPIToken &&
419+
!LocalState.accessToken?.value
420+
) {
421+
logger.error(
422+
"Missing 'CLOUDFLARE_API_TOKEN' from non-TTY environment, please see docs for more info: TBD"
423+
);
424+
425+
return;
426+
}
427+
428+
return localAPIToken ?? LocalState.accessToken?.value;
414429
}
415430

416431
interface AccessContext {
@@ -1155,10 +1170,8 @@ export function ChooseAccount(props: {
11551170
/**
11561171
* Ensure that a user is logged in, and a valid account_id is available.
11571172
*/
1158-
export async function requireAuth(
1159-
config: Config,
1160-
isInteractive = true
1161-
): Promise<string> {
1173+
export async function requireAuth(config: Config): Promise<string> {
1174+
const isInteractive = process.stdout.isTTY;
11621175
const loggedIn = await loginOrRefreshIfRequired(isInteractive);
11631176
if (!loggedIn) {
11641177
// didn't login, let's just quit

0 commit comments

Comments
 (0)