import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { OpenClawPluginCommandDefinition, PluginCommandContext, } from "openclaw/plugin-sdk/core"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi } from "./api.js"; const pluginApiMocks = vi.hoisted(() => ({ clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })), issueDeviceBootstrapToken: vi.fn(async () => ({ token: "boot-token", expiresAtMs: Date.now() + 10 * 60_000, })), revokeDeviceBootstrapToken: vi.fn(async () => ({ removed: true })), renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,ZmFrZXBuZw=="), resolveGatewayPort: vi.fn(() => 18789), resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")), writeQrPngTempFile: vi.fn(async (_data: string, opts: { tmpRoot: string }) => { const dirPath = await fs.mkdtemp(path.join(opts.tmpRoot, "device-pair-qr-")); const filePath = path.join(dirPath, "pair-qr.png"); await fs.writeFile(filePath, "fakepng"); return { filePath, dirPath, mediaLocalRoots: [dirPath] }; }), })); vi.mock("./api.js", () => { return { PAIRING_SETUP_BOOTSTRAP_PROFILE: { roles: ["node"], scopes: [], }, 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, resolveGatewayBindUrl: vi.fn(), resolveGatewayPort: pluginApiMocks.resolveGatewayPort, resolveTailnetHostWithRunner: vi.fn(), runPluginCommandWithTimeout: vi.fn(), writeQrPngTempFile: pluginApiMocks.writeQrPngTempFile, }; }); vi.mock("./notify.js", () => ({ armPairNotifyOnce: vi.fn(async () => false), formatPendingRequests: vi.fn(() => "No pending device pairing requests."), handleNotifyCommand: vi.fn(async () => ({ text: "notify" })), registerPairingNotifierService: vi.fn(), })); import { approveDevicePairing, listDevicePairing, resolveGatewayBindUrl, resolveTailnetHostWithRunner, } from "./api.js"; import registerDevicePair from "./index.js"; 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"]; function createApi(params?: { config?: OpenClawPluginApi["config"]; runtime?: OpenClawPluginApi["runtime"]; pluginConfig?: Record; 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", }, }, }, pluginConfig: { publicUrl: "wss://gateway.example.test", ...params?.pluginConfig, }, runtime: (params?.runtime ?? {}) as OpenClawPluginApi["runtime"], registerCommand: params?.registerCommand, }); } function registerPairCommand(params?: { config?: OpenClawPluginApi["config"]; runtime?: OpenClawPluginApi["runtime"]; pluginConfig?: Record; }): OpenClawPluginCommandDefinition { let command: OpenClawPluginCommandDefinition | undefined; registerDevicePair.register( createApi({ ...params, registerCommand: (nextCommand) => { command = nextCommand; }, }), ); if (!command) { throw new Error("device-pair plugin did not register its /pair command"); } return command; } function requireText(result: { text?: unknown } | null | undefined): string { if (typeof result?.text !== "string") { throw new Error("pair command did not return a text response"); } return result.text; } function createChannelRuntime( runtimeKey: string, sendKey: string, sendMessage: (...args: unknown[]) => Promise, ): OpenClawPluginApi["runtime"] { return { channel: { outbound: { loadAdapter: async (channelId: string) => channelId === runtimeKey ? ({ 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 { return { requestId: "req-1", deviceId: "victim-phone", publicKey: "victim-public-key", 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; return { status: "approved", requestId: "req-1", device: makeApprovedPairingDevice(device), ...resultOverrides, }; } function mockPendingPairingList() { vi.mocked(listDevicePairing).mockResolvedValueOnce({ pending: [makePendingPairingRequest()], paired: [], }); } function createInternalApproveLatestContext() { return createCommandContext({ channel: "webchat", args: "approve latest", commandBody: "/pair approve latest", gatewayClientScopes: INTERNAL_PAIRING_SCOPES, }); } function expectApproveCalledWithInternalPairingScopes() { expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", { callerScopes: INTERNAL_PAIRING_SCOPES, }); } 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.write", "operator.pairing"], }), ); const payload = result as { text?: string; mediaUrl?: string; sensitiveMedia?: boolean }; const text = requireText(result); expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith({ profile: { roles: ["node"], scopes: [], }, }); expect(text).toContain("Scan this QR code with the OpenClaw iOS app:"); expect(payload.mediaUrl).toBe("data:image/png;base64,ZmFrZXBuZw=="); expect(payload.sensitiveMedia).toBe(true); expect(text).toContain("- Security: single-use bootstrap token"); expect(text).toContain("**Important:** Run `/pair cleanup` after pairing finishes."); expect(text).toContain("If this QR code leaks, run `/pair cleanup` immediately."); 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("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, }); pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed")); const command = registerPairCommand(); const result = await command.handler( createCommandContext({ channel: "webchat", gatewayClientScopes: ["operator.write", "operator.pairing"], }), ); const text = requireText(result); expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ token: "first-token", }); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2); expect(text).toContain( "QR image delivery is not available on this channel right now, so I generated a pasteable setup code instead.", ); 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) => { let sentPng = ""; const sendMessage = vi.fn().mockImplementation(async (_target, _caption, opts) => { if (opts?.mediaUrl) { sentPng = await fs.readFile(opts.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_PAIRING_SCOPES, }), ); const text = requireText(result); expect(sendMessage).toHaveBeenCalledTimes(1); const [target, caption, opts] = sendMessage.mock.calls[0] as [ string, string, { mediaUrl?: string; mediaLocalRoots?: string[]; accountId?: string; } & Record, ]; expect(target).toBe(testCase.expectedTarget); 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."); expect(opts.mediaUrl).toMatch(/pair-qr\.png$/); expect(opts.mediaLocalRoots).toEqual([path.dirname(opts.mediaUrl!)]); expect(opts).toMatchObject(testCase.expectedOpts); expect(sentPng).toBe("fakepng"); await expect(fs.access(opts.mediaUrl!)).rejects.toThrow(); expect(text).toContain("QR code sent above."); expect(text).toContain("IMPORTANT: Run /pair cleanup after pairing finishes."); }); 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, }); 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_PAIRING_SCOPES, }), ); const text = requireText(result); expect(pluginApiMocks.revokeDeviceBootstrapToken).toHaveBeenCalledWith({ token: "first-token", }); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(2); expect(text).toContain("Pairing setup code generated."); expect(text).toContain("If this code leaks or you are done, run /pair cleanup"); }); 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({ channel: "msteams", senderId: "8:orgid:123", gatewayClientScopes: INTERNAL_PAIRING_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."); expect(text).not.toContain("```"); }); 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, }), ); 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.", }); }); }); 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("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("allows command owners to issue setup codes from non-gateway command surfaces", async () => { const command = registerPairCommand(); const result = await command.handler( createCommandContext({ channel: "telegram", args: "", commandBody: "/pair", gatewayClientScopes: undefined, senderIsOwner: true, }), ); const text = requireText(result); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); 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.write", "operator.pairing"], }), ); const text = requireText(result); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); expect(text).toContain("Gateway: wss://gateway.example.test:18789"); }); 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: ["operator.write", "operator.pairing"], }), ); const text = requireText(result); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); expect(text).toContain("Gateway: ws://127.0.0.1:18789"); }); 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.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); expect(requireText(result)).toContain("Gateway: ws://192.168.1.20:18789"); }); 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: ["operator.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1); expect(requireText(result)).toContain("Gateway: ws://openclaw.local:18789"); }); 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: ["operator.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); expect(requireText(result)).toContain( "Tailscale and public mobile pairing require a secure gateway URL", ); }); it("rejects tailnet cleartext setup urls before issuing setup codes", async () => { vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({ url: "ws://100.64.0.9:18789", source: "gateway.bind=tailnet", }); const command = registerPairCommand({ config: { gateway: { bind: "tailnet", auth: { mode: "token", token: "gateway-token", }, }, }, pluginConfig: { publicUrl: undefined, }, }); const result = await command.handler( createCommandContext({ channel: "webchat", args: "", commandBody: "/pair", gatewayClientScopes: ["operator.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); expect(requireText(result)).toContain("prefer gateway.tailscale.mode=serve"); }); 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: ["operator.write", "operator.pairing"], }), ); 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: ["operator.write", "operator.pairing"], }), ); 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", }, }, }, pluginConfig: { publicUrl: undefined, }, }); const result = await command.handler( createCommandContext({ channel: "webchat", args: "", commandBody: "/pair", gatewayClientScopes: ["operator.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); expect(result).toEqual({ text: "Error: Configured gateway.remote.url is invalid." }); }); it.each([ "http://localhost:notaport", "http:gateway.example.test", "ws:gateway.example.test", "http:/localhost:notaport", "ftp:/gateway.example.test", "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: ["operator.write", "operator.pairing"], }), ); expect(pluginApiMocks.issueDeviceBootstrapToken).not.toHaveBeenCalled(); expect(result).toEqual({ text: "Error: Configured publicUrl is invalid." }); }); }); 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] = [ { requestId: "req-1", deviceId: "device-1", displayName: "dev one", platform: "ios", role: "operator", 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"); expect(text).toContain("role=operator"); expect(text).toContain("scopes=operator.admin, operator.read"); expect(text).toContain("ip=198.51.100.2"); }); 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); 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("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)." }); }); });