diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts new file mode 100644 index 000000000..b2d2257d5 --- /dev/null +++ b/src/commands/configure.gateway.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + text: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), + resolveGatewayPort: vi.fn(), + buildGatewayAuthConfig: vi.fn(), + note: vi.fn(), + randomToken: vi.fn(), +})); + +vi.mock("../config/config.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveGatewayPort: mocks.resolveGatewayPort, + }; +}); + +vi.mock("./configure.shared.js", () => ({ + text: mocks.text, + select: mocks.select, + confirm: mocks.confirm, +})); + +vi.mock("../terminal/note.js", () => ({ + note: mocks.note, +})); + +vi.mock("./configure.gateway-auth.js", () => ({ + buildGatewayAuthConfig: mocks.buildGatewayAuthConfig, +})); + +vi.mock("../infra/tailscale.js", () => ({ + findTailscaleBinary: vi.fn(async () => undefined), +})); + +vi.mock("./onboard-helpers.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + randomToken: mocks.randomToken, + }; +}); + +import { promptGatewayConfig } from "./configure.gateway.js"; + +describe("promptGatewayConfig", () => { + it("generates a token when the prompt returns undefined", async () => { + mocks.resolveGatewayPort.mockReturnValue(18789); + const selectQueue = ["loopback", "token", "off"]; + mocks.select.mockImplementation(async () => selectQueue.shift()); + const textQueue = ["18789", undefined]; + mocks.text.mockImplementation(async () => textQueue.shift()); + mocks.randomToken.mockReturnValue("generated-token"); + mocks.buildGatewayAuthConfig.mockImplementation(({ mode, token, password }) => ({ + mode, + token, + password, + })); + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await promptGatewayConfig({}, runtime); + expect(result.token).toBe("generated-token"); + }); +}); diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index c83c8d2a0..2a1e72857 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -5,7 +5,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { buildGatewayAuthConfig } from "./configure.gateway-auth.js"; import { confirm, select, text } from "./configure.shared.js"; -import { guardCancel, randomToken } from "./onboard-helpers.js"; +import { guardCancel, normalizeGatewayTokenInput, randomToken } from "./onboard-helpers.js"; type GatewayAuthChoice = "token" | "password"; @@ -177,8 +177,7 @@ export async function promptGatewayConfig( }), runtime, ); - const rawInput = tokenInput ? String(tokenInput).trim() : ""; - gatewayToken = rawInput || randomToken(); + gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); } if (authMode === "password") { diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 6873cdec5..42c512310 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { openUrl, resolveBrowserOpenCommand, resolveControlUiLinks } from "./onboard-helpers.js"; +import { + normalizeGatewayTokenInput, + openUrl, + resolveBrowserOpenCommand, + resolveControlUiLinks, +} from "./onboard-helpers.js"; const mocks = vi.hoisted(() => ({ runCommandWithTimeout: vi.fn(async () => ({ @@ -103,3 +108,18 @@ describe("resolveControlUiLinks", () => { expect(links.wsUrl).toBe("ws://127.0.0.1:18789"); }); }); + +describe("normalizeGatewayTokenInput", () => { + it("returns empty string for undefined or null", () => { + expect(normalizeGatewayTokenInput(undefined)).toBe(""); + expect(normalizeGatewayTokenInput(null)).toBe(""); + }); + + it("trims string input", () => { + expect(normalizeGatewayTokenInput(" token ")).toBe("token"); + }); + + it("coerces non-string input to string", () => { + expect(normalizeGatewayTokenInput(123)).toBe("123"); + }); +}); diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 774893213..03fcc9989 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -62,6 +62,11 @@ export function randomToken(): string { return crypto.randomBytes(24).toString("hex"); } +export function normalizeGatewayTokenInput(value: unknown): string { + if (value == null) return ""; + return String(value).trim(); +} + export function printWizardHeader(runtime: RuntimeEnv) { const header = [ "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄", diff --git a/src/wizard/onboarding.gateway-config.test.ts b/src/wizard/onboarding.gateway-config.test.ts new file mode 100644 index 000000000..dcd4a5725 --- /dev/null +++ b/src/wizard/onboarding.gateway-config.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { RuntimeEnv } from "../runtime.js"; +import type { WizardPrompter } from "./prompts.js"; + +const mocks = vi.hoisted(() => ({ + randomToken: vi.fn(), +})); + +vi.mock("../commands/onboard-helpers.js", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + randomToken: mocks.randomToken, + }; +}); + +vi.mock("../infra/tailscale.js", () => ({ + findTailscaleBinary: vi.fn(async () => undefined), +})); + +import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; + +describe("configureGatewayForOnboarding", () => { + it("generates a token when the prompt returns undefined", async () => { + mocks.randomToken.mockReturnValue("generated-token"); + + const selectQueue = ["loopback", "token", "off"]; + const textQueue = ["18789", undefined]; + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => selectQueue.shift() as string), + multiselect: vi.fn(async () => []), + text: vi.fn(async () => textQueue.shift() as string), + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await configureGatewayForOnboarding({ + flow: "advanced", + baseConfig: {}, + nextConfig: {}, + localPort: 18789, + quickstartGateway: { + hasExisting: false, + port: 18789, + bind: "loopback", + authMode: "token", + tailscaleMode: "off", + token: undefined, + password: undefined, + customBindHost: undefined, + tailscaleResetOnExit: false, + }, + prompter, + runtime, + }); + + expect(result.settings.gatewayToken).toBe("generated-token"); + }); +}); diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index b5eb00829..5b872b4df 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -1,4 +1,4 @@ -import { randomToken } from "../commands/onboard-helpers.js"; +import { normalizeGatewayTokenInput, randomToken } from "../commands/onboard-helpers.js"; import type { GatewayAuthChoice } from "../commands/onboard-types.js"; import type { OpenClawConfig } from "../config/config.js"; import { findTailscaleBinary } from "../infra/tailscale.js"; @@ -182,9 +182,7 @@ export async function configureGatewayForOnboarding( placeholder: "Needed for multi-machine or non-loopback access", initialValue: quickstartGateway.token ?? "", }); - // FIX: Ensure undefined becomes an empty string, not "undefined" string - const rawInput = tokenInput ? String(tokenInput).trim() : ""; - gatewayToken = rawInput || randomToken(); + gatewayToken = normalizeGatewayTokenInput(tokenInput) || randomToken(); } }