fix(digitalocean): warn first-time users about required payment method (#2403)

Show a proactive warning before the OAuth/token entry flow when the user
has no saved DigitalOcean config and no DO_API_TOKEN env var. This prevents
new users from completing the full setup flow only to fail at provisioning
because their account has no payment method on file.

Warning is shown only once per first-time setup — returning users (who have
a saved token, even if expired or invalid) skip the reminder.

Closes #2395

Agent: issue-fixer

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
A 2026-03-09 16:54:11 -07:00 committed by GitHub
parent 6380d35a11
commit fa323c8b58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 138 additions and 0 deletions

View file

@ -0,0 +1,130 @@
/**
* do-payment-warning.test.ts
*
* Verifies that ensureDoToken() shows a proactive payment method reminder to
* first-time DigitalOcean users who have no saved config and no env token.
*
* Design note: we spread the real ../shared/ui implementations so other tests
* that run in the same worker (e.g. ui-utils.test.ts, billing-guidance.test.ts)
* still get real validation functions. We only override what we need to control.
*/
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
// ── Import the real ui module so we can spread its implementations ────────────
// This prevents contaminating ui-utils.test.ts which tests real validation logic.
import * as realUI from "../shared/ui";
// ── Controlled overrides ──────────────────────────────────────────────────────
const mockLoadApiToken = mock((_cloud: string): string | null => null);
const warnMessages: string[] = [];
const mockLogWarn = mock((msg: string) => {
warnMessages.push(msg);
});
const mockPrompt = mock(() => Promise.resolve(""));
const mockLogStep = mock(() => {});
const mockLogError = mock(() => {});
const mockLogInfo = mock(() => {});
// Spread real implementations so other test files still get working functions.
// Only override the handful of functions we need to control for this test.
mock.module("../shared/ui", () => ({
...realUI,
loadApiToken: mockLoadApiToken,
logWarn: mockLogWarn,
prompt: mockPrompt,
logStep: mockLogStep,
logError: mockLogError,
logInfo: mockLogInfo,
logStepDone: mock(() => {}),
logStepInline: mock(() => {}),
openBrowser: mock(() => {}),
}));
// ── Import unit under test ────────────────────────────────────────────────────
const { ensureDoToken } = await import("../digitalocean/digitalocean");
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("ensureDoToken — payment method warning for first-time users", () => {
const savedEnv: Record<string, string | undefined> = {};
const originalFetch = globalThis.fetch;
let stderrSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
mockLogWarn.mockClear();
mockLogError.mockClear();
mockLogInfo.mockClear();
mockLogStep.mockClear();
mockPrompt.mockClear();
mockLoadApiToken.mockClear();
warnMessages.length = 0;
// Save and clear DO_API_TOKEN
savedEnv["DO_API_TOKEN"] = process.env.DO_API_TOKEN;
delete process.env.DO_API_TOKEN;
// Fail OAuth connectivity check → tryDoOAuth returns null immediately
globalThis.fetch = mock(() => Promise.reject(new Error("Network unreachable")));
// Suppress stderr noise
stderrSpy = spyOn(process.stderr, "write").mockImplementation(() => true);
});
afterEach(() => {
globalThis.fetch = originalFetch;
stderrSpy.mockRestore();
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it("shows payment method warning for first-time users (no saved token, no env var)", async () => {
mockLoadApiToken.mockImplementation(() => null);
// Empty prompt responses → manual entry fails × 3 → throws
mockPrompt.mockImplementation(() => Promise.resolve(""));
await expect(ensureDoToken()).rejects.toThrow("DigitalOcean authentication failed");
expect(warnMessages.some((msg) => msg.includes("payment method"))).toBe(true);
expect(warnMessages.some((msg) => msg.includes("cloud.digitalocean.com/account/billing"))).toBe(true);
});
it("does NOT show payment warning when a saved token exists (returning user)", async () => {
// Saved token exists but is invalid (fetch rejects so testDoToken fails)
mockLoadApiToken.mockImplementation((cloud) => (cloud === "digitalocean" ? "dop_v1_invalid" : null));
mockPrompt.mockImplementation(() => Promise.resolve(""));
await expect(ensureDoToken()).rejects.toThrow();
expect(warnMessages.some((msg) => msg.includes("payment method"))).toBe(false);
});
it("does NOT show payment warning when DO_API_TOKEN env var is set", async () => {
process.env.DO_API_TOKEN = "dop_v1_invalid_env_token";
mockLoadApiToken.mockImplementation(() => null);
mockPrompt.mockImplementation(() => Promise.resolve(""));
await expect(ensureDoToken()).rejects.toThrow();
expect(warnMessages.some((msg) => msg.includes("payment method"))).toBe(false);
});
it("billing URL in warning points to the DigitalOcean billing page", async () => {
mockLoadApiToken.mockImplementation(() => null);
mockPrompt.mockImplementation(() => Promise.resolve(""));
await expect(ensureDoToken()).rejects.toThrow("DigitalOcean authentication failed");
const billingWarning = warnMessages.find((msg) => msg.includes("billing"));
expect(billingWarning).toBeDefined();
expect(billingWarning).toContain("https://cloud.digitalocean.com/account/billing");
});
});

View file

@ -576,6 +576,14 @@ export async function ensureDoToken(): Promise<boolean> {
}
// 3. Try OAuth browser flow
// Show payment method reminder for first-time users (no saved config, no env token)
if (!saved && !process.env.DO_API_TOKEN) {
process.stderr.write("\n");
logWarn("DigitalOcean requires a payment method before you can create servers.");
logWarn("If you haven't added one yet, visit: https://cloud.digitalocean.com/account/billing");
process.stderr.write("\n");
}
const oauthToken = await tryDoOAuth();
if (oauthToken) {
_state.token = oauthToken;