diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e54c5d7903d9..1680ac278844 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -76,7 +76,6 @@ extensions/copilot/src/attempt.test.ts extensions/copilot/src/event-bridge.test.ts extensions/copilot/src/tool-bridge.test.ts extensions/crabbox/src/crabbox-worker-provider.test.ts -extensions/device-pair/index.test.ts extensions/device-pair/index.ts extensions/diagnostics-otel/src/service.test.ts extensions/diagnostics-prometheus/src/service.ts diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 73d1c67351e9..a90b6e9ddc41 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -29,30 +29,28 @@ const pluginApiMocks = vi.hoisted(() => ({ }), })); -vi.mock("./api.js", () => { - return { - PAIRING_SETUP_BOOTSTRAP_PROFILE: { - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], - }, - approveDevicePairing: vi.fn(), - clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens, - definePluginEntry: vi.fn((entry) => entry), - issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken, - listDevicePairing: vi.fn(async () => ({ pending: [] })), - renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl, - revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken, - resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir, - resolveAdvertisedLanHost: vi.fn(async () => null), - resolveGatewayBindUrl: vi.fn(), - resolveGatewayPort: pluginApiMocks.resolveGatewayPort, - resolveTailnetHostWithRunner: vi.fn(), - resolveTailscaleServeGatewayUrlsWithRunner: - pluginApiMocks.resolveTailscaleServeGatewayUrlsWithRunner, - runPluginCommandWithTimeout: vi.fn(), - writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile, - }; -}); +vi.mock("./api.js", () => ({ + PAIRING_SETUP_BOOTSTRAP_PROFILE: { + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + }, + approveDevicePairing: vi.fn(), + clearDeviceBootstrapTokens: pluginApiMocks.clearDeviceBootstrapTokens, + definePluginEntry: vi.fn((entry) => entry), + issueDeviceBootstrapToken: pluginApiMocks.issueDeviceBootstrapToken, + listDevicePairing: vi.fn(async () => ({ pending: [] })), + renderQrPngDataUrl: pluginApiMocks.renderQrPngDataUrl, + revokeDeviceBootstrapToken: pluginApiMocks.revokeDeviceBootstrapToken, + resolvePreferredOpenClawTmpDir: pluginApiMocks.resolvePreferredOpenClawTmpDir, + resolveAdvertisedLanHost: vi.fn(async () => null), + resolveGatewayBindUrl: vi.fn(), + resolveGatewayPort: pluginApiMocks.resolveGatewayPort, + resolveTailnetHostWithRunner: vi.fn(), + resolveTailscaleServeGatewayUrlsWithRunner: + pluginApiMocks.resolveTailscaleServeGatewayUrlsWithRunner, + runPluginCommandWithTimeout: vi.fn(), + writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile, +})); vi.mock("./notify.js", () => ({ armPairNotifyOnce: vi.fn(async () => false), @@ -70,65 +68,68 @@ import { } from "./api.js"; import registerDevicePair from "./index.js"; -async function expectPathMissing(targetPath: string): Promise { - let error: unknown; - try { - await fs.access(targetPath); - } catch (caught) { - error = caught; - } - expect(error).toBeInstanceOf(Error); - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); -} - -afterAll(() => { - vi.doUnmock("./api.js"); - vi.doUnmock("./notify.js"); - vi.resetModules(); -}); - type ListedPendingPairingRequest = Awaited>["pending"][number]; type ApproveDevicePairingResolved = Awaited>; type ApprovedPairingResult = Extract< NonNullable, { status: "approved" } >; -type ApprovedPairingDevice = ApprovedPairingResult["device"]; -const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"]; -const INTERNAL_SETUP_SCOPES = [...INTERNAL_PAIRING_SCOPES, "operator.talk.secrets"]; - -function createApi(params?: { +type RegisterPairOptions = { config?: OpenClawPluginApi["config"]; runtime?: OpenClawPluginApi["runtime"]; pluginConfig?: Record; - registerCommand?: (command: OpenClawPluginCommandDefinition) => void; -}): OpenClawPluginApi { +}; + +const INTERNAL_PAIRING_SCOPES = ["operator.write", "operator.pairing"]; +const INTERNAL_SETUP_SCOPES = [...INTERNAL_PAIRING_SCOPES, "operator.talk.secrets"]; +const LIMITED_SETUP_REQUEST = { + profile: { + roles: ["node", "operator"], + scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], + }, +}; +const FULL_SETUP_REQUEST = { + profile: { + roles: ["node", "operator"], + scopes: [ + "operator.admin", + "operator.approvals", + "operator.read", + "operator.talk.secrets", + "operator.write", + ], + purpose: "mobile-full", + }, +}; +const PAIRING_REQUIRED = "⚠️ This command requires operator.pairing."; +const TALK_SECRETS_REQUIRED = + "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets."; +const SECURE_URL_REQUIRED = "Tailscale and public mobile pairing require a secure gateway URL"; +// Tagged tables quote `$name`; a row toString preserves the exact existing test title via `%s`. +const exactTestTitle = (title: string) => () => title; + +function createApi( + params: RegisterPairOptions & { + registerCommand?: (command: OpenClawPluginCommandDefinition) => void; + } = {}, +): OpenClawPluginApi { return createTestPluginApi({ id: "device-pair", name: "device-pair", source: "test", - config: params?.config ?? { - gateway: { - auth: { - mode: "token", - token: "gateway-token", - }, - }, + config: params.config ?? { + gateway: { auth: { mode: "token", token: "gateway-token" } }, }, pluginConfig: { publicUrl: "wss://gateway.example.test", - ...params?.pluginConfig, + ...params.pluginConfig, }, - runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"], - registerCommand: params?.registerCommand, + runtime: (params.runtime ?? {}) as OpenClawPluginApi["runtime"], + registerCommand: params.registerCommand, }); } -function registerPairCommand(params?: { - config?: OpenClawPluginApi["config"]; - runtime?: OpenClawPluginApi["runtime"]; - pluginConfig?: Record; -}): OpenClawPluginCommandDefinition { +function registerPairCommand(params: RegisterPairOptions = {}): OpenClawPluginCommandDefinition { let command: OpenClawPluginCommandDefinition | undefined; registerDevicePair.register( createApi({ @@ -144,6 +145,54 @@ function registerPairCommand(params?: { return command; } +function createCommandContext(params: Partial = {}): PluginCommandContext { + return { + channel: "webchat", + isAuthorizedSender: true, + commandBody: "/pair qr", + args: "qr", + config: {}, + requestConversationBinding: async () => ({ status: "error", message: "unsupported" }), + detachConversationBinding: async () => ({ removed: false }), + getCurrentConversationBinding: async () => null, + ...params, + }; +} + +async function runPair(context: Partial, options: RegisterPairOptions = {}) { + return await registerPairCommand(options).handler(createCommandContext(context)); +} + +async function runDefaultSetup( + options: RegisterPairOptions = {}, + context: Partial = {}, +) { + return await runPair( + { + channel: "webchat", + args: "", + commandBody: "/pair", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + ...context, + }, + options, + ); +} + +async function expectSetupRejected( + options: RegisterPairOptions, + expectedText: string, + exact = false, +): Promise { + const result = await runDefaultSetup(options); + expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); + if (exact) { + expect(result).toEqual({ text: expectedText }); + } else { + expect(requireText(result)).toContain(expectedText); + } +} + function requireText(result: { text?: unknown } | null | undefined): string { if (typeof result?.text !== "string") { throw new Error("pair command did not return a text response"); @@ -158,48 +207,49 @@ function requireMediaUrl(opts: { mediaUrl?: string }): string { return opts.mediaUrl; } +async function expectPathMissing(targetPath: string): Promise { + let error: unknown; + try { + await fs.access(targetPath); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); +} + +async function expectRejectedCommand(params: { + context: Partial; + untouched: unknown; + text: string; +}): Promise { + const result = await runPair(params.context); + expect(params.untouched).not.toHaveBeenCalled(); + expect(result).toEqual({ text: params.text }); +} + function createChannelRuntime( - runtimeKey: string, - sendKey: string, + channel: string, sendMessage: (...args: unknown[]) => Promise, ): OpenClawPluginApi["runtime"] { return { channel: { outbound: { loadAdapter: async (channelId: string) => - channelId === runtimeKey - ? ({ + channelId === channel + ? { sendText: async ({ to, text, ...opts }: Record) => await sendMessage(to, text, opts), sendMedia: async ({ to, text, ...opts }: Record) => await sendMessage(to, text, opts), - } as const) + } : undefined, }, }, } as unknown as OpenClawPluginApi["runtime"]; } -function createCommandContext(params?: Partial): PluginCommandContext { - return { - channel: "webchat", - isAuthorizedSender: true, - commandBody: "/pair qr", - args: "qr", - config: {}, - requestConversationBinding: async () => ({ - status: "error", - message: "unsupported", - }), - detachConversationBinding: async () => ({ removed: false }), - getCurrentConversationBinding: async () => null, - ...params, - }; -} - -function makePendingPairingRequest( - overrides: Partial = {}, -): ListedPendingPairingRequest { +function makePendingPairingRequest(): ListedPendingPairingRequest { return { requestId: "req-1", deviceId: "victim-phone", @@ -207,47 +257,29 @@ function makePendingPairingRequest( displayName: "Victim Phone", platform: "ios", ts: Date.now(), - ...overrides, }; } -function makeApprovedPairingDevice( - overrides: Partial = {}, -): ApprovedPairingDevice { - return { - deviceId: "victim-phone", - publicKey: "victim-public-key", - displayName: "Victim Phone", - platform: "ios", - role: "operator", - roles: ["operator"], - scopes: ["operator.pairing"], - approvedScopes: ["operator.pairing"], - tokens: { - operator: { - token: "token-1", - role: "operator", - scopes: ["operator.pairing"], - createdAtMs: Date.now(), - }, - }, - createdAtMs: Date.now(), - approvedAtMs: Date.now(), - ...overrides, - }; -} - -function makeApprovedPairingResult( - overrides: Omit, "device"> & { - device?: Partial; - } = {}, -): ApprovedPairingResult { - const { device, ...resultOverrides } = overrides; +function makeApprovedPairingResult(): ApprovedPairingResult { return { status: "approved", requestId: "req-1", - device: makeApprovedPairingDevice(device), - ...resultOverrides, + device: { + deviceId: "victim-phone", + publicKey: "victim-public-key", + displayName: "Victim Phone", + platform: "ios", + createdAtMs: Date.now(), + approvedAtMs: Date.now(), + }, + }; +} + +function makeForbiddenPairingResult(): ApproveDevicePairingResolved { + return { + status: "forbidden", + reason: "caller-missing-scope", + scope: "operator.admin", }; } @@ -258,43 +290,31 @@ function mockPendingPairingList() { }); } -function createInternalApproveLatestContext() { - return createCommandContext({ - channel: "webchat", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, +beforeEach(async () => { + vi.clearAllMocks(); + pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({ + token: "boot-token", + expiresAtMs: Date.now() + 10 * 60_000, }); -} + await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true }); +}); -function expectApproveCalledWithInternalPairingScopes() { - expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { - callerScopes: INTERNAL_PAIRING_SCOPES, - }); -} +afterEach(async () => { + await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true, force: true }); +}); + +afterAll(() => { + vi.doUnmock("./api.js"); + vi.doUnmock("./notify.js"); + vi.resetModules(); +}); describe("device-pair /pair qr", () => { - beforeEach(async () => { - vi.clearAllMocks(); - pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({ - token: "boot-token", - expiresAtMs: Date.now() + 10 * 60_000, - }); - await fs.mkdir(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true }); - }); - - afterEach(async () => { - await fs.rm(pluginApiMocks.resolvePreferredOpenClawTmpDir(), { recursive: true, force: true }); - }); - it("returns an inline QR image for webchat surfaces", async () => { const command = registerPairCommand(); expect(command.requiredScopes).toEqual(["operator.pairing"]); const result = await command.handler( - createCommandContext({ - channel: "webchat", - gatewayClientScopes: ["operator.admin"], - }), + createCommandContext({ channel: "webchat", gatewayClientScopes: ["operator.admin"] }), ); const payload = result as { text?: string; @@ -305,19 +325,7 @@ describe("device-pair /pair qr", () => { const text = requireText(result); expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1); - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ - profile: { - roles: ["node", "operator"], - scopes: [ - "operator.admin", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], - purpose: "mobile-full", - }, - }); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(FULL_SETUP_REQUEST); expect(text).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(payload.mediaUrl).toBeUndefined(); expect(payload.channelData?.openclawPairingQr).toEqual({ @@ -331,78 +339,28 @@ describe("device-pair /pair qr", () => { expect(text).not.toContain("![OpenClaw pairing QR]"); }); - it("rejects qr setup for internal gateway callers without operator.pairing", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "qr", - commandBody: "/pair qr", - gatewayClientScopes: ["operator.write"], - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects qr setup for non-gateway command surfaces without pairing scopes", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "qr", - commandBody: "/pair qr", - gatewayClientScopes: undefined, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects qr setup for internal callers without Talk secret scope", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "qr", - commandBody: "/pair qr", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.", + it.each` + toString | context | text + ${exactTestTitle("rejects qr setup for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED} + ${exactTestTitle("rejects qr setup for non-gateway command surfaces without pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED} + ${exactTestTitle("rejects qr setup for internal callers without Talk secret scope")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${TALK_SECRETS_REQUIRED} + `("%s", async ({ context, text }) => { + await expectRejectedCommand({ + context: { ...context, args: "qr", commandBody: "/pair qr" }, + untouched: pluginApiMocks.issueDeviceBootstrapToken, + text, }); }); it("reissues the bootstrap token if webchat QR rendering fails before falling back", async () => { pluginApiMocks.issueDeviceBootstrapToken - .mockResolvedValueOnce({ - token: "first-token", - expiresAtMs: Date.now() + 10 * 60_000, - }) - .mockResolvedValueOnce({ - token: "second-token", - expiresAtMs: Date.now() + 10 * 60_000, - }); + .mockResolvedValueOnce({ token: "first-token", expiresAtMs: Date.now() + 10 * 60_000 }) + .mockResolvedValueOnce({ token: "second-token", expiresAtMs: Date.now() + 10 * 60_000 }); pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed")); - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + const text = requireText( + await runPair({ channel: "webchat", gatewayClientScopes: INTERNAL_SETUP_SCOPES }), ); - const text = requireText(result); - expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ token: "first-token", }); @@ -413,144 +371,48 @@ describe("device-pair /pair qr", () => { expect(text).toContain("Pairing setup code generated."); }); - it.each([ - { - label: "Telegram", - runtimeKey: "telegram", - sendKey: "sendMessageTelegram", - ctx: { - channel: "telegram", - senderId: "123", - accountId: "default", - messageThreadId: 271, - }, - expectedTarget: "123", - expectedOpts: { - accountId: "default", - threadId: 271, - }, - }, - { - label: "Discord", - runtimeKey: "discord", - sendKey: "sendMessageDiscord", - ctx: { - channel: "discord", - senderId: "123", - accountId: "default", - }, - expectedTarget: "user:123", - expectedOpts: { - accountId: "default", - }, - }, - { - label: "Slack", - runtimeKey: "slack", - sendKey: "sendMessageSlack", - ctx: { - channel: "slack", - senderId: "user:U123", - accountId: "default", - messageThreadId: "1234567890.000001", - }, - expectedTarget: "user:U123", - expectedOpts: { - accountId: "default", - threadId: "1234567890.000001", - }, - }, - { - label: "Signal", - runtimeKey: "signal", - sendKey: "sendMessageSignal", - ctx: { - channel: "signal", - senderId: "signal:+15551234567", - accountId: "default", - }, - expectedTarget: "signal:+15551234567", - expectedOpts: { - accountId: "default", - }, - }, - { - label: "iMessage", - runtimeKey: "imessage", - sendKey: "sendMessageIMessage", - ctx: { - channel: "imessage", - senderId: "+15551234567", - accountId: "default", - }, - expectedTarget: "+15551234567", - expectedOpts: { - accountId: "default", - }, - }, - { - label: "WhatsApp", - runtimeKey: "whatsapp", - sendKey: "sendMessageWhatsApp", - ctx: { - channel: "whatsapp", - senderId: "+15551234567", - accountId: "default", - }, - expectedTarget: "+15551234567", - expectedOpts: { - accountId: "default", - verbose: false, - }, - }, - ])("sends $label a real QR image attachment", async (testCase) => { + it.each` + toString | channel | context | target | opts + ${exactTestTitle("sends Telegram a real QR image attachment")} | ${"telegram"} | ${{ senderId: "123", accountId: "default", messageThreadId: 271 }} | ${"123"} | ${{ accountId: "default", threadId: 271 }} + ${exactTestTitle("sends Discord a real QR image attachment")} | ${"discord"} | ${{ senderId: "123", accountId: "default" }} | ${"user:123"} | ${{ accountId: "default" }} + ${exactTestTitle("sends Slack a real QR image attachment")} | ${"slack"} | ${{ senderId: "user:U123", accountId: "default", messageThreadId: "1234567890.000001" }} | ${"user:U123"} | ${{ accountId: "default", threadId: "1234567890.000001" }} + ${exactTestTitle("sends Signal a real QR image attachment")} | ${"signal"} | ${{ senderId: "signal:+15551234567", accountId: "default" }} | ${"signal:+15551234567"} | ${{ accountId: "default" }} + ${exactTestTitle("sends iMessage a real QR image attachment")} | ${"imessage"} | ${{ senderId: "+15551234567", accountId: "default" }} | ${"+15551234567"} | ${{ accountId: "default" }} + ${exactTestTitle("sends WhatsApp a real QR image attachment")} | ${"whatsapp"} | ${{ senderId: "+15551234567", accountId: "default" }} | ${"+15551234567"} | ${{ accountId: "default", verbose: false }} + `("%s", async ({ channel, context, target, opts }) => { let sentPng = ""; - const sendMessage = vi.fn().mockImplementation(async (_target, _caption, opts) => { - if (opts?.mediaUrl) { - sentPng = await fs.readFile(opts.mediaUrl, "utf8"); + const sendMessage = vi.fn().mockImplementation(async (_target, _caption, sendOpts) => { + if (sendOpts?.mediaUrl) { + sentPng = await fs.readFile(sendOpts.mediaUrl, "utf8"); } return { messageId: "1" }; }); - const command = registerPairCommand({ - runtime: createChannelRuntime(testCase.runtimeKey, testCase.sendKey, sendMessage), - }); - - const result = await command.handler( - createCommandContext({ - ...testCase.ctx, - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + const result = await runPair( + { channel, ...context, gatewayClientScopes: INTERNAL_SETUP_SCOPES }, + { runtime: createChannelRuntime(channel, sendMessage) }, ); const text = requireText(result); expect(sendMessage).toHaveBeenCalledTimes(1); - const [target, caption, opts] = sendMessage.mock.calls[0] as [ + const [actualTarget, caption, sendOpts] = sendMessage.mock.calls[0] as [ string, string, - { - mediaUrl?: string; - mediaLocalRoots?: string[]; - accountId?: string; - } & Record, + { mediaUrl?: string; mediaLocalRoots?: string[]; accountId?: string } & Record< + string, + unknown + >, ]; - expect(target).toBe(testCase.expectedTarget); + expect(actualTarget).toBe(target); expect(caption).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(caption).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); expect(caption).toContain("If this QR code leaks, run /pair cleanup immediately."); - const mediaUrl = requireMediaUrl(opts); + const mediaUrl = requireMediaUrl(sendOpts); expect(mediaUrl).toMatch(/pair-qr\.png$/); - expect(opts).toEqual({ - cfg: { - gateway: { - auth: { - mode: "token", - token: "gateway-token", - }, - }, - }, + expect(sendOpts).toEqual({ + cfg: { gateway: { auth: { mode: "token", token: "gateway-token" } } }, mediaUrl, mediaLocalRoots: [path.dirname(mediaUrl)], - ...testCase.expectedOpts, + ...opts, }); expect(sentPng).toBe("fakepng"); await expectPathMissing(mediaUrl); @@ -560,28 +422,19 @@ describe("device-pair /pair qr", () => { it("reissues the bootstrap token after QR delivery failure before falling back", async () => { pluginApiMocks.issueDeviceBootstrapToken - .mockResolvedValueOnce({ - token: "first-token", - expiresAtMs: Date.now() + 10 * 60_000, - }) - .mockResolvedValueOnce({ - token: "second-token", - expiresAtMs: Date.now() + 10 * 60_000, - }); - + .mockResolvedValueOnce({ token: "first-token", expiresAtMs: Date.now() + 10 * 60_000 }) + .mockResolvedValueOnce({ token: "second-token", expiresAtMs: Date.now() + 10 * 60_000 }); const sendMessage = vi.fn().mockRejectedValue(new Error("upload failed")); - const command = registerPairCommand({ - runtime: createChannelRuntime("discord", "sendMessageDiscord", sendMessage), - }); - - const result = await command.handler( - createCommandContext({ - channel: "discord", - senderId: "123", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + const text = requireText( + await runPair( + { + channel: "discord", + senderId: "123", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }, + { runtime: createChannelRuntime("discord", sendMessage) }, + ), ); - const text = requireText(result); expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ token: "first-token", @@ -592,16 +445,13 @@ describe("device-pair /pair qr", () => { }); it("falls back to the setup code instead of ASCII when the channel cannot send media", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ + const text = requireText( + await runPair({ channel: "msteams", senderId: "8:orgid:123", gatewayClientScopes: INTERNAL_SETUP_SCOPES, }), ); - const text = requireText(result); - expect(text).toContain("QR image delivery is not available on this channel"); expect(text).toContain("Setup code:"); expect(text).toContain("IMPORTANT: After pairing finishes, run /pair cleanup."); @@ -612,20 +462,20 @@ describe("device-pair /pair qr", () => { "requires QR channel sender %s to be an own entry", async (channel) => { const loadAdapter = vi.fn(async () => undefined); - const command = registerPairCommand({ - runtime: { - channel: { outbound: { loadAdapter } }, - } as unknown as OpenClawPluginApi["runtime"], - }); - const result = await command.handler( - createCommandContext({ - channel, - senderId: "prototype-channel", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + const text = requireText( + await runPair( + { + channel, + senderId: "prototype-channel", + gatewayClientScopes: INTERNAL_SETUP_SCOPES, + }, + { + runtime: { + channel: { outbound: { loadAdapter } }, + } as unknown as OpenClawPluginApi["runtime"], + }, + ), ); - const text = requireText(result); - expect(pluginApiMocks.writeQrPngTempFile).not.toHaveBeenCalled(); expect(loadAdapter).not.toHaveBeenCalled(); expect(pluginApiMocks.revokeDeviceBootstrapToken).not.toHaveBeenCalled(); @@ -636,170 +486,45 @@ describe("device-pair /pair qr", () => { ); it("supports invalidating unused setup codes", async () => { - const command = registerPairCommand(); - const result = await command?.handler( - createCommandContext({ - channel: "telegram", - args: "cleanup", - commandBody: "/pair cleanup", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, - }), - ); - + const result = await runPair({ + channel: "telegram", + args: "cleanup", + commandBody: "/pair cleanup", + gatewayClientScopes: INTERNAL_PAIRING_SCOPES, + }); expect(pluginApiMocks.clearDeviceBootstrapTokens).toHaveBeenCalledTimes(1); expect(result).toEqual({ text: "Invalidated 2 unused setup codes." }); }); - it("rejects cleanup for internal gateway callers without operator.pairing", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "cleanup", - commandBody: "/pair cleanup", - gatewayClientScopes: ["operator.write"], - }), - ); - - expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("fails closed for cleanup when internal gateway scopes are absent", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "cleanup", - commandBody: "/pair cleanup", - gatewayClientScopes: undefined, - }), - ); - - expect(pluginApiMocks.clearDeviceBootstrapTokens).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects status for non-gateway command surfaces without pairing scopes", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "status", - commandBody: "/pair status", - gatewayClientScopes: undefined, - }), - ); - - expect(vi.mocked(listDevicePairing)).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); + it.each` + toString | context | untouched + ${exactTestTitle("rejects cleanup for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", args: "cleanup", commandBody: "/pair cleanup", gatewayClientScopes: ["operator.write"] }} | ${pluginApiMocks.clearDeviceBootstrapTokens} + ${exactTestTitle("fails closed for cleanup when internal gateway scopes are absent")} | ${{ channel: "webchat", args: "cleanup", commandBody: "/pair cleanup", gatewayClientScopes: undefined }} | ${pluginApiMocks.clearDeviceBootstrapTokens} + ${exactTestTitle("rejects status for non-gateway command surfaces without pairing scopes")} | ${{ channel: "telegram", args: "status", commandBody: "/pair status", gatewayClientScopes: undefined }} | ${listDevicePairing} + `("%s", async ({ context, untouched }) => { + await expectRejectedCommand({ context, untouched, text: PAIRING_REQUIRED }); }); }); describe("device-pair /pair default setup code", () => { - beforeEach(() => { - vi.clearAllMocks(); - pluginApiMocks.issueDeviceBootstrapToken.mockResolvedValue({ - token: "boot-token", - expiresAtMs: Date.now() + 10 * 60_000, - }); - }); - - it("rejects setup code issuance for internal gateway callers without operator.pairing", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: ["operator.write"], - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects unknown subcommands that fall back to setup code issuance without operator.pairing", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "foo", - commandBody: "/pair foo", - gatewayClientScopes: ["operator.write"], - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects setup code issuance for internal callers without Talk secret scope", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ Setup code handoff includes Talk secrets and requires operator.talk.secrets.", - }); - }); - - it("fails closed for webchat setup code issuance when scopes are absent", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: undefined, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("fails closed for non-gateway setup code issuance when scopes are absent", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "", - commandBody: "/pair", - gatewayClientScopes: undefined, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", + it.each` + toString | context | text + ${exactTestTitle("rejects setup code issuance for internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED} + ${exactTestTitle("rejects unknown subcommands that fall back to setup code issuance without operator.pairing")} | ${{ channel: "webchat", args: "foo", commandBody: "/pair foo", gatewayClientScopes: ["operator.write"] }} | ${PAIRING_REQUIRED} + ${exactTestTitle("rejects setup code issuance for internal callers without Talk secret scope")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${TALK_SECRETS_REQUIRED} + ${exactTestTitle("fails closed for webchat setup code issuance when scopes are absent")} | ${{ channel: "webchat", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED} + ${exactTestTitle("fails closed for non-gateway setup code issuance when scopes are absent")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${PAIRING_REQUIRED} + `("%s", async ({ context, text }) => { + await expectRejectedCommand({ + context: { args: "", commandBody: "/pair", ...context }, + untouched: pluginApiMocks.issueDeviceBootstrapToken, + text, }); }); it("allows command owners to issue setup codes from non-gateway command surfaces", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ + const text = requireText( + await runPair({ channel: "telegram", args: "", commandBody: "/pair", @@ -807,144 +532,19 @@ describe("device-pair /pair default setup code", () => { senderIsOwner: true, }), ); - const text = requireText(result); - - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ - profile: { - roles: ["node", "operator"], - scopes: [ - "operator.admin", - "operator.approvals", - "operator.read", - "operator.talk.secrets", - "operator.write", - ], - purpose: "mobile-full", - }, - }); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(FULL_SETUP_REQUEST); expect(text).toContain("Pairing setup code generated."); }); - it("normalizes secure bare publicUrl host ports before issuing setup codes", async () => { - const command = registerPairCommand({ - config: { - gateway: { - tls: { enabled: true }, - auth: { - mode: "token", - token: "gateway-token", - }, - }, - }, - pluginConfig: { - publicUrl: "gateway.example.test:18789/setup", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: ["operator.admin"], - }), - ); - const text = requireText(result); - + it.each` + toString | options | context | expectedText + ${exactTestTitle("normalizes secure bare publicUrl host ports before issuing setup codes")} | ${{ config: { gateway: { tls: { enabled: true }, auth: { mode: "token", token: "gateway-token" } } }, pluginConfig: { publicUrl: "gateway.example.test:18789/setup" } }} | ${{ gatewayClientScopes: ["operator.admin"] }} | ${"Gateway: wss://gateway.example.test:18789"} + ${exactTestTitle("allows loopback cleartext setup urls")} | ${{ pluginConfig: { publicUrl: "ws://127.0.0.1:18789" } }} | ${undefined} | ${"Gateway: ws://127.0.0.1:18789"} + ${exactTestTitle("allows mdns cleartext setup urls")} | ${{ pluginConfig: { publicUrl: "ws://openclaw.local:18789" } }} | ${undefined} | ${"Gateway: ws://openclaw.local:18789"} + `("%s", async ({ options, context, expectedText }) => { + const text = requireText(await runDefaultSetup(options, context)); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(text).toContain("Gateway: wss://gateway.example.test:18789"); - }); - - it("keeps secure setup limited for non-admin gateway callers", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ - profile: { - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], - }, - }); - const text = requireText(result); - expect(text).toContain("Access: limited"); - expect(text).not.toContain("Plaintext ws:// was limited for safety"); - }); - - it("allows loopback cleartext setup urls", async () => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl: "ws://127.0.0.1:18789", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - const text = requireText(result); - - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(text).toContain("Gateway: ws://127.0.0.1:18789"); - }); - - it.each(["ws://0.0.0.0:18789", "ws://[::]:18789"])( - "rejects unspecified cleartext setup url %s before issuing setup codes", - async (publicUrl) => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(requireText(result)).toContain( - "Tailscale and public mobile pairing require a secure gateway URL", - ); - }, - ); - - it("allows private LAN cleartext setup urls", async () => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl: "ws://192.168.1.20:18789", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: ["operator.admin"], - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ - profile: { - roles: ["node", "operator"], - scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"], - }, - }); - const text = requireText(result); - expect(text).toContain("Gateway: ws://192.168.1.20:18789"); - expect(text).toContain("Access: limited"); - expect(text).toContain("Plaintext ws:// was limited for safety"); + expect(text).toContain(expectedText); }); it.each([ @@ -953,41 +553,46 @@ describe("device-pair /pair default setup code", () => { "ws://[fe80::1]:18789", "ws://[febf::1]:18789", ])("allows IPv6 ULA and link-local cleartext setup url %s", async (publicUrl) => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - + const text = requireText(await runDefaultSetup({ pluginConfig: { publicUrl } })); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(requireText(result)).toContain(`Gateway: ${publicUrl}`); + expect(text).toContain(`Gateway: ${publicUrl}`); }); - it("allows mdns cleartext setup urls", async () => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl: "ws://openclaw.local:18789", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, + it("uses Tailscale Serve MagicDNS as a secure setup url", async () => { + vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net"); + const text = requireText( + await runDefaultSetup({ + config: { + gateway: { + tailscale: { mode: "serve" }, + auth: { mode: "token", token: "gateway-token" }, + }, + }, + pluginConfig: { publicUrl: undefined }, }), ); - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789"); + expect(text).toContain("Gateway: wss://gateway.tailnet.ts.net"); + }); + + it("keeps secure setup limited for non-admin gateway callers", async () => { + const text = requireText(await runDefaultSetup()); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(LIMITED_SETUP_REQUEST); + expect(text).toContain("Access: limited"); + expect(text).not.toContain("Plaintext ws:// was limited for safety"); + }); + + it("allows private LAN cleartext setup urls", async () => { + const text = requireText( + await runDefaultSetup( + { pluginConfig: { publicUrl: "ws://192.168.1.20:18789" } }, + { gatewayClientScopes: ["operator.admin"] }, + ), + ); + expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(LIMITED_SETUP_REQUEST); + expect(text).toContain("Gateway: ws://192.168.1.20:18789"); + expect(text).toContain("Access: limited"); + expect(text).toContain("Plaintext ws:// was limited for safety"); }); it("uses the advertised LAN helper for bind-derived setup urls", async () => { @@ -996,32 +601,17 @@ describe("device-pair /pair default setup code", () => { url: `ws://${params.pickLanHost()}:18789`, source: "gateway.bind=lan", })); - const command = registerPairCommand({ - config: { - gateway: { - bind: "lan", - auth: { - mode: "token", - token: "gateway-token", - }, + const text = requireText( + await runDefaultSetup({ + config: { + gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } }, }, - }, - pluginConfig: { - publicUrl: undefined, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, + pluginConfig: { publicUrl: undefined }, }), ); - expect(resolveAdvertisedLanHost).toHaveBeenCalledTimes(1); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(requireText(result)).toContain("Gateway: ws://10.211.55.3:18789"); + expect(text).toContain("Gateway: ws://10.211.55.3:18789"); }); it("includes a Tailscale Serve fallback for LAN bind-derived setup urls", async () => { @@ -1033,27 +623,16 @@ describe("device-pair /pair default setup code", () => { vi.mocked(resolveTailscaleServeGatewayUrlsWithRunner).mockResolvedValueOnce([ "wss://clawmac.tail.ts.net:8443", ]); - const command = registerPairCommand({ - config: { - gateway: { - bind: "lan", - auth: { mode: "token", token: "gateway-token" }, + const text = requireText( + await runDefaultSetup({ + config: { + gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } }, }, - }, - pluginConfig: { publicUrl: undefined }, - }); - - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, + pluginConfig: { publicUrl: undefined }, }), ); - - expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789"); - expect(requireText(result)).toContain("Fallback: wss://clawmac.tail.ts.net:8443"); + expect(text).toContain("Gateway: ws://192.168.139.3:18789"); + expect(text).toContain("Fallback: wss://clawmac.tail.ts.net:8443"); }); it("does not advertise a loopback Serve route for a custom bind", async () => { @@ -1061,49 +640,34 @@ describe("device-pair /pair default setup code", () => { url: "ws://192.168.139.3:18789", source: "gateway.bind=custom", }); - const command = registerPairCommand({ - config: { - gateway: { - bind: "custom", - customBindHost: "192.168.139.3", - auth: { mode: "token", token: "gateway-token" }, + const text = requireText( + await runDefaultSetup({ + config: { + gateway: { + bind: "custom", + customBindHost: "192.168.139.3", + auth: { mode: "token", token: "gateway-token" }, + }, }, - }, - pluginConfig: { publicUrl: undefined }, - }); - - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, + pluginConfig: { publicUrl: undefined }, }), ); - expect(resolveTailscaleServeGatewayUrlsWithRunner).not.toHaveBeenCalled(); - expect(requireText(result)).toContain("Gateway: ws://192.168.139.3:18789"); - expect(requireText(result)).not.toContain("Fallback:"); + expect(text).toContain("Gateway: ws://192.168.139.3:18789"); + expect(text).not.toContain("Fallback:"); }); - it("rejects public cleartext setup urls before issuing setup codes", async () => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl: "ws://gateway.example.test:18789", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); + it.each(["ws://0.0.0.0:18789", "ws://[::]:18789"])( + "rejects unspecified cleartext setup url %s before issuing setup codes", + async (publicUrl) => { + await expectSetupRejected({ pluginConfig: { publicUrl } }, SECURE_URL_REQUIRED); + }, + ); - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(requireText(result)).toContain( - "Tailscale and public mobile pairing require a secure gateway URL", + it("rejects public cleartext setup urls before issuing setup codes", async () => { + await expectSetupRejected( + { pluginConfig: { publicUrl: "ws://gateway.example.test:18789" } }, + SECURE_URL_REQUIRED, ); }); @@ -1112,134 +676,51 @@ describe("device-pair /pair default setup code", () => { url: "ws://100.64.0.9:18789", source: "gateway.bind=tailnet", }); - const command = registerPairCommand({ - config: { - gateway: { - bind: "tailnet", - auth: { - mode: "token", - token: "gateway-token", + await expectSetupRejected( + { + config: { + gateway: { + bind: "tailnet", + auth: { mode: "token", token: "gateway-token" }, }, }, + pluginConfig: { publicUrl: undefined }, }, - pluginConfig: { - publicUrl: undefined, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + "prefer gateway.tailscale.mode=serve", ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve"); }); it.each(["ws://[2001:db8::1]:18789", "ws://[fe7f::1]:18789", "ws://[fec0::1]:18789"])( "rejects non-LAN IPv6 cleartext setup url %s before issuing setup codes", async (publicUrl) => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(requireText(result)).toContain( - "Tailscale and public mobile pairing require a secure gateway URL", - ); + await expectSetupRejected({ pluginConfig: { publicUrl } }, SECURE_URL_REQUIRED); }, ); - it("uses Tailscale Serve MagicDNS as a secure setup url", async () => { - vi.mocked(resolveTailnetHostWithRunner).mockResolvedValueOnce("gateway.tailnet.ts.net"); - const command = registerPairCommand({ - config: { - gateway: { - tailscale: { mode: "serve" }, - auth: { - mode: "token", - token: "gateway-token", - }, - }, - }, - pluginConfig: { - publicUrl: undefined, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), - ); - const text = requireText(result); - - expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); - expect(text).toContain("Gateway: wss://gateway.tailnet.ts.net"); - }); - it("rejects invalid bare publicUrl host ports", async () => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl: "localhost:notaport", - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + await expectSetupRejected( + { pluginConfig: { publicUrl: "localhost:notaport" } }, + "Error: Configured publicUrl is invalid.", + true, ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); }); it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => { - const command = registerPairCommand({ - config: { - gateway: { - bind: "custom", - customBindHost: "127.0.0.1", - remote: { url: "http://localhost:notaport" }, - auth: { - mode: "token", - token: "gateway-token", + await expectSetupRejected( + { + config: { + gateway: { + bind: "custom", + customBindHost: "127.0.0.1", + remote: { url: "http://localhost:notaport" }, + auth: { mode: "token", token: "gateway-token" }, }, }, + pluginConfig: { publicUrl: undefined }, }, - pluginConfig: { - publicUrl: undefined, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + "Error: Configured gateway.remote.url is invalid.", + true, ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ text: "Error: Configured gateway.remote.url is invalid." }); }); it.each([ @@ -1251,22 +732,11 @@ describe("device-pair /pair default setup code", () => { "mailto:foo@example.com", "ws://user:pass@gateway.example.test:18789", ])("rejects invalid publicUrl %s before issuing setup codes", async (publicUrl) => { - const command = registerPairCommand({ - pluginConfig: { - publicUrl, - }, - }); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "", - commandBody: "/pair", - gatewayClientScopes: INTERNAL_SETUP_SCOPES, - }), + await expectSetupRejected( + { pluginConfig: { publicUrl } }, + "Error: Configured publicUrl is invalid.", + true, ); - - expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); - expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); }); }); @@ -1274,7 +744,7 @@ describe("device-pair notify pending formatting", () => { it("includes role and scopes for pending requests", async () => { const { formatPendingRequests } = await vi.importActual("./notify.ts"); - const pending: Parameters[0] = [ + const text = formatPendingRequests([ { requestId: "req-1", deviceId: "device-1", @@ -1284,9 +754,7 @@ describe("device-pair notify pending formatting", () => { scopes: ["operator.admin", "operator.read"], remoteIp: "198.51.100.2", }, - ]; - - const text = formatPendingRequests(pending); + ]); expect(text).toContain("Pending device pairing requests:"); expect(text).toContain("name=dev one"); expect(text).toContain("platform=ios"); @@ -1298,165 +766,44 @@ describe("device-pair notify pending formatting", () => { it("falls back to roles list and no scopes when role/scopes are absent", async () => { const { formatPendingRequests } = await vi.importActual("./notify.ts"); - const pending: Parameters[0] = [ - { - requestId: "req-2", - deviceId: "device-2", - roles: ["node", "operator"], - scopes: [], - }, - ]; - - const text = formatPendingRequests(pending); + const text = formatPendingRequests([ + { requestId: "req-2", deviceId: "device-2", roles: ["node", "operator"], scopes: [] }, + ]); expect(text).toContain("role=node, operator"); expect(text).toContain("scopes=none"); }); }); describe("device-pair /pair approve", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("rejects internal gateway callers without operator.pairing", async () => { - mockPendingPairingList(); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: ["operator.write"], - }), - ); - - expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", + it.each` + toString | context | pending | approved | expectedCall | expectedText + ${exactTestTitle("rejects internal gateway callers without operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: ["operator.write"] }} | ${true} | ${undefined} | ${null} | ${PAIRING_REQUIRED} + ${exactTestTitle("allows internal gateway callers with operator.pairing")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."} + ${exactTestTitle("rejects non-gateway approvals without pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: undefined }} | ${false} | ${undefined} | ${null} | ${PAIRING_REQUIRED} + ${exactTestTitle("allows command owners to approve from non-gateway command surfaces")} | ${{ channel: "telegram", gatewayClientScopes: undefined, senderIsOwner: true }} | ${true} | ${makeApprovedPairingResult} | ${["operator.pairing"]} | ${"✅ Paired Victim Phone (ios)."} + ${exactTestTitle("preserves gateway caller scopes for command-owner approvals")} | ${{ channel: "telegram", gatewayClientScopes: INTERNAL_PAIRING_SCOPES, senderIsOwner: true }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."} + ${exactTestTitle("fails closed for approvals when internal gateway scopes are absent")} | ${{ channel: "webchat", gatewayClientScopes: undefined }} | ${true} | ${undefined} | ${null} | ${PAIRING_REQUIRED} + ${exactTestTitle("rejects approvals that request scopes above the caller session")} | ${{ channel: "webchat", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeForbiddenPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"⚠️ This command requires operator.admin to approve this pairing request."} + ${exactTestTitle("approves from command surfaces that carry pairing scopes")} | ${{ channel: "telegram", gatewayClientScopes: INTERNAL_PAIRING_SCOPES }} | ${true} | ${makeApprovedPairingResult} | ${INTERNAL_PAIRING_SCOPES} | ${"✅ Paired Victim Phone (ios)."} + `("%s", async ({ context, pending, approved, expectedCall, expectedText }) => { + if (pending) { + mockPendingPairingList(); + } + if (approved) { + vi.mocked(approveDevicePairing).mockResolvedValueOnce(approved()); + } + const result = await runPair({ + ...context, + args: "approve latest", + commandBody: "/pair approve latest", }); - }); - - it("allows internal gateway callers with operator.pairing", async () => { - mockPendingPairingList(); - vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult()); - - const command = registerPairCommand(); - const result = await command.handler(createInternalApproveLatestContext()); - - expectApproveCalledWithInternalPairingScopes(); - expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); - }); - - it("rejects non-gateway approvals without pairing scopes", async () => { - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: undefined, - }), - ); - - expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("allows command owners to approve from non-gateway command surfaces", async () => { - mockPendingPairingList(); - vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult()); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: undefined, - senderIsOwner: true, - }), - ); - - expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { - callerScopes: ["operator.pairing"], - }); - expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); - }); - - it("preserves gateway caller scopes for command-owner approvals", async () => { - mockPendingPairingList(); - vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult()); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, - senderIsOwner: true, - }), - ); - - expectApproveCalledWithInternalPairingScopes(); - expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); - }); - - it("fails closed for approvals when internal gateway scopes are absent", async () => { - mockPendingPairingList(); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "webchat", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: undefined, - }), - ); - - expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.pairing.", - }); - }); - - it("rejects approvals that request scopes above the caller session", async () => { - mockPendingPairingList(); - vi.mocked(approveDevicePairing).mockResolvedValueOnce({ - status: "forbidden", - reason: "caller-missing-scope", - scope: "operator.admin", - }); - - const command = registerPairCommand(); - const result = await command.handler(createInternalApproveLatestContext()); - - expectApproveCalledWithInternalPairingScopes(); - expect(result).toEqual({ - text: "⚠️ This command requires operator.admin to approve this pairing request.", - }); - }); - - it("approves from command surfaces that carry pairing scopes", async () => { - mockPendingPairingList(); - vi.mocked(approveDevicePairing).mockResolvedValueOnce(makeApprovedPairingResult()); - - const command = registerPairCommand(); - const result = await command.handler( - createCommandContext({ - channel: "telegram", - args: "approve latest", - commandBody: "/pair approve latest", - gatewayClientScopes: INTERNAL_PAIRING_SCOPES, - }), - ); - - expectApproveCalledWithInternalPairingScopes(); - expect(result).toEqual({ text: "✅ Paired Victim Phone (ios)." }); + if (expectedCall) { + expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { + callerScopes: expectedCall, + }); + } else { + expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled(); + } + expect(result).toEqual({ text: expectedText }); }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */