mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 07:04:01 -06:00
810 lines
35 KiB
TypeScript
810 lines
35 KiB
TypeScript
// Device Pair tests cover index plugin behavior.
|
|
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 { afterAll, 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),
|
|
resolveTailscaleServeGatewayUrlsWithRunner: vi.fn(async () => []),
|
|
resolvePreferredOpenClawTmpDir: vi.fn(() => path.join(os.tmpdir(), "openclaw-device-pair-tests")),
|
|
writeQrPngTempFile: vi.fn(async (dataValue: 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", () => ({
|
|
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),
|
|
formatPendingRequests: vi.fn(() => "No pending device pairing requests."),
|
|
handleNotifyCommand: vi.fn(async () => ({ text: "notify" })),
|
|
}));
|
|
|
|
import {
|
|
approveDevicePairing,
|
|
listDevicePairing,
|
|
resolveAdvertisedLanHost,
|
|
resolveGatewayBindUrl,
|
|
resolveTailnetHostWithRunner,
|
|
resolveTailscaleServeGatewayUrlsWithRunner,
|
|
} from "./api.js";
|
|
import registerDevicePair from "./index.js";
|
|
|
|
type ListedPendingPairingRequest = Awaited<ReturnType<typeof listDevicePairing>>["pending"][number];
|
|
type ApproveDevicePairingResolved = Awaited<ReturnType<typeof approveDevicePairing>>;
|
|
type ApprovedPairingResult = Extract<
|
|
NonNullable<ApproveDevicePairingResolved>,
|
|
{ status: "approved" }
|
|
>;
|
|
type RegisterPairOptions = {
|
|
config?: OpenClawPluginApi["config"];
|
|
runtime?: OpenClawPluginApi["runtime"];
|
|
pluginConfig?: Record<string, unknown>;
|
|
};
|
|
|
|
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" } },
|
|
},
|
|
pluginConfig: {
|
|
publicUrl: "wss://gateway.example.test",
|
|
...params.pluginConfig,
|
|
},
|
|
runtime: (params.runtime ?? {}) as OpenClawPluginApi["runtime"],
|
|
registerCommand: params.registerCommand,
|
|
});
|
|
}
|
|
|
|
function registerPairCommand(params: RegisterPairOptions = {}): 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 createCommandContext(params: Partial<PluginCommandContext> = {}): 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<PluginCommandContext>, options: RegisterPairOptions = {}) {
|
|
return await registerPairCommand(options).handler(createCommandContext(context));
|
|
}
|
|
|
|
async function runDefaultSetup(
|
|
options: RegisterPairOptions = {},
|
|
context: Partial<PluginCommandContext> = {},
|
|
) {
|
|
return await runPair(
|
|
{
|
|
channel: "webchat",
|
|
args: "",
|
|
commandBody: "/pair",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
...context,
|
|
},
|
|
options,
|
|
);
|
|
}
|
|
|
|
async function expectSetupRejected(
|
|
options: RegisterPairOptions,
|
|
expectedText: string,
|
|
exact = false,
|
|
): Promise<void> {
|
|
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");
|
|
}
|
|
return result.text;
|
|
}
|
|
|
|
function requireMediaUrl(opts: { mediaUrl?: string }): string {
|
|
if (!opts.mediaUrl) {
|
|
throw new Error("pair command did not send a media URL");
|
|
}
|
|
return opts.mediaUrl;
|
|
}
|
|
|
|
async function expectPathMissing(targetPath: string): Promise<void> {
|
|
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<PluginCommandContext>;
|
|
untouched: unknown;
|
|
text: string;
|
|
}): Promise<void> {
|
|
const result = await runPair(params.context);
|
|
expect(params.untouched).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ text: params.text });
|
|
}
|
|
|
|
function createChannelRuntime(
|
|
channel: string,
|
|
sendMessage: (...args: unknown[]) => Promise<unknown>,
|
|
): OpenClawPluginApi["runtime"] {
|
|
return {
|
|
channel: {
|
|
outbound: {
|
|
loadAdapter: async (channelId: string) =>
|
|
channelId === channel
|
|
? {
|
|
sendText: async ({ to, text, ...opts }: Record<string, unknown>) =>
|
|
await sendMessage(to, text, opts),
|
|
sendMedia: async ({ to, text, ...opts }: Record<string, unknown>) =>
|
|
await sendMessage(to, text, opts),
|
|
}
|
|
: undefined,
|
|
},
|
|
},
|
|
} as unknown as OpenClawPluginApi["runtime"];
|
|
}
|
|
|
|
function makePendingPairingRequest(): ListedPendingPairingRequest {
|
|
return {
|
|
requestId: "req-1",
|
|
deviceId: "victim-phone",
|
|
publicKey: "victim-public-key",
|
|
displayName: "Victim Phone",
|
|
platform: "ios",
|
|
ts: Date.now(),
|
|
};
|
|
}
|
|
|
|
function makeApprovedPairingResult(): ApprovedPairingResult {
|
|
return {
|
|
status: "approved",
|
|
requestId: "req-1",
|
|
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",
|
|
};
|
|
}
|
|
|
|
function mockPendingPairingList() {
|
|
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
|
pending: [makePendingPairingRequest()],
|
|
paired: [],
|
|
});
|
|
}
|
|
|
|
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 });
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("./api.js");
|
|
vi.doUnmock("./notify.js");
|
|
vi.resetModules();
|
|
});
|
|
|
|
describe("device-pair /pair qr", () => {
|
|
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"] }),
|
|
);
|
|
const payload = result as {
|
|
text?: string;
|
|
mediaUrl?: string;
|
|
channelData?: Record<string, unknown>;
|
|
sensitiveMedia?: boolean;
|
|
};
|
|
const text = requireText(result);
|
|
|
|
expect(pluginApiMocks.renderQrPngDataUrl).toHaveBeenCalledTimes(1);
|
|
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({
|
|
setupCode: expect.any(String),
|
|
expiresAtMs: expect.any(Number),
|
|
});
|
|
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.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 });
|
|
pluginApiMocks.renderQrPngDataUrl.mockRejectedValueOnce(new Error("render failed"));
|
|
|
|
const text = requireText(
|
|
await runPair({ channel: "webchat", gatewayClientScopes: INTERNAL_SETUP_SCOPES }),
|
|
);
|
|
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`
|
|
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, sendOpts) => {
|
|
if (sendOpts?.mediaUrl) {
|
|
sentPng = await fs.readFile(sendOpts.mediaUrl, "utf8");
|
|
}
|
|
return { messageId: "1" };
|
|
});
|
|
const result = await runPair(
|
|
{ channel, ...context, gatewayClientScopes: INTERNAL_SETUP_SCOPES },
|
|
{ runtime: createChannelRuntime(channel, sendMessage) },
|
|
);
|
|
const text = requireText(result);
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
const [actualTarget, caption, sendOpts] = sendMessage.mock.calls[0] as [
|
|
string,
|
|
string,
|
|
{ mediaUrl?: string; mediaLocalRoots?: string[]; accountId?: string } & Record<
|
|
string,
|
|
unknown
|
|
>,
|
|
];
|
|
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(sendOpts);
|
|
expect(mediaUrl).toMatch(/pair-qr\.png$/);
|
|
expect(sendOpts).toEqual({
|
|
cfg: { gateway: { auth: { mode: "token", token: "gateway-token" } } },
|
|
mediaUrl,
|
|
mediaLocalRoots: [path.dirname(mediaUrl)],
|
|
...opts,
|
|
});
|
|
expect(sentPng).toBe("fakepng");
|
|
await expectPathMissing(mediaUrl);
|
|
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 text = requireText(
|
|
await runPair(
|
|
{
|
|
channel: "discord",
|
|
senderId: "123",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
},
|
|
{ runtime: createChannelRuntime("discord", sendMessage) },
|
|
),
|
|
);
|
|
|
|
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 text = requireText(
|
|
await runPair({
|
|
channel: "msteams",
|
|
senderId: "8:orgid:123",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
}),
|
|
);
|
|
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.each(["toString", "constructor", "__proto__"])(
|
|
"requires QR channel sender %s to be an own entry",
|
|
async (channel) => {
|
|
const loadAdapter = vi.fn(async () => undefined);
|
|
const text = requireText(
|
|
await runPair(
|
|
{
|
|
channel,
|
|
senderId: "prototype-channel",
|
|
gatewayClientScopes: INTERNAL_SETUP_SCOPES,
|
|
},
|
|
{
|
|
runtime: {
|
|
channel: { outbound: { loadAdapter } },
|
|
} as unknown as OpenClawPluginApi["runtime"],
|
|
},
|
|
),
|
|
);
|
|
expect(pluginApiMocks.writeQrPngTempFile).not.toHaveBeenCalled();
|
|
expect(loadAdapter).not.toHaveBeenCalled();
|
|
expect(pluginApiMocks.revokeDeviceBootstrapToken).not.toHaveBeenCalled();
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain("QR image delivery is not available on this channel");
|
|
expect(text).toContain("Setup code:");
|
|
},
|
|
);
|
|
|
|
it("supports invalidating unused setup codes", async () => {
|
|
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.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", () => {
|
|
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 text = requireText(
|
|
await runPair({
|
|
channel: "telegram",
|
|
args: "",
|
|
commandBody: "/pair",
|
|
gatewayClientScopes: undefined,
|
|
senderIsOwner: true,
|
|
}),
|
|
);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledWith(FULL_SETUP_REQUEST);
|
|
expect(text).toContain("Pairing setup code generated.");
|
|
});
|
|
|
|
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(expectedText);
|
|
});
|
|
|
|
it.each([
|
|
"ws://[fc00::1]:18789",
|
|
"ws://[fd7a:115c:a1e0::1]:18789",
|
|
"ws://[fe80::1]:18789",
|
|
"ws://[febf::1]:18789",
|
|
])("allows IPv6 ULA and link-local cleartext setup url %s", async (publicUrl) => {
|
|
const text = requireText(await runDefaultSetup({ pluginConfig: { publicUrl } }));
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain(`Gateway: ${publicUrl}`);
|
|
});
|
|
|
|
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(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 () => {
|
|
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("10.211.55.3");
|
|
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
|
|
url: `ws://${params.pickLanHost()}:18789`,
|
|
source: "gateway.bind=lan",
|
|
}));
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } },
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(resolveAdvertisedLanHost).toHaveBeenCalledTimes(1);
|
|
expect(pluginApiMocks.issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
|
|
expect(text).toContain("Gateway: ws://10.211.55.3:18789");
|
|
});
|
|
|
|
it("includes a Tailscale Serve fallback for LAN bind-derived setup urls", async () => {
|
|
vi.mocked(resolveAdvertisedLanHost).mockResolvedValueOnce("192.168.139.3");
|
|
vi.mocked(resolveGatewayBindUrl).mockImplementationOnce((params) => ({
|
|
url: `ws://${params.pickLanHost()}:18789`,
|
|
source: "gateway.bind=lan",
|
|
}));
|
|
vi.mocked(resolveTailscaleServeGatewayUrlsWithRunner).mockResolvedValueOnce([
|
|
"wss://clawmac.tail.ts.net:8443",
|
|
]);
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: { bind: "lan", auth: { mode: "token", token: "gateway-token" } },
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
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 () => {
|
|
vi.mocked(resolveGatewayBindUrl).mockReturnValueOnce({
|
|
url: "ws://192.168.139.3:18789",
|
|
source: "gateway.bind=custom",
|
|
});
|
|
const text = requireText(
|
|
await runDefaultSetup({
|
|
config: {
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "192.168.139.3",
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
}),
|
|
);
|
|
expect(resolveTailscaleServeGatewayUrlsWithRunner).not.toHaveBeenCalled();
|
|
expect(text).toContain("Gateway: ws://192.168.139.3:18789");
|
|
expect(text).not.toContain("Fallback:");
|
|
});
|
|
|
|
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);
|
|
},
|
|
);
|
|
|
|
it("rejects public cleartext setup urls before issuing setup codes", async () => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl: "ws://gateway.example.test:18789" } },
|
|
SECURE_URL_REQUIRED,
|
|
);
|
|
});
|
|
|
|
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",
|
|
});
|
|
await expectSetupRejected(
|
|
{
|
|
config: {
|
|
gateway: {
|
|
bind: "tailnet",
|
|
auth: { mode: "token", token: "gateway-token" },
|
|
},
|
|
},
|
|
pluginConfig: { publicUrl: undefined },
|
|
},
|
|
"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) => {
|
|
await expectSetupRejected({ pluginConfig: { publicUrl } }, SECURE_URL_REQUIRED);
|
|
},
|
|
);
|
|
|
|
it("rejects invalid bare publicUrl host ports", async () => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl: "localhost:notaport" } },
|
|
"Error: Configured publicUrl is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("rejects invalid gateway.remote.url before falling back to bind-derived setup urls", async () => {
|
|
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 },
|
|
},
|
|
"Error: Configured gateway.remote.url is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
|
|
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) => {
|
|
await expectSetupRejected(
|
|
{ pluginConfig: { publicUrl } },
|
|
"Error: Configured publicUrl is invalid.",
|
|
true,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("device-pair notify pending formatting", () => {
|
|
it("includes role and scopes for pending requests", async () => {
|
|
const { formatPendingRequests } =
|
|
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
|
const text = formatPendingRequests([
|
|
{
|
|
requestId: "req-1",
|
|
deviceId: "device-1",
|
|
displayName: "dev one",
|
|
platform: "ios",
|
|
role: "operator",
|
|
scopes: ["operator.admin", "operator.read"],
|
|
remoteIp: "198.51.100.2",
|
|
},
|
|
]);
|
|
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<typeof import("./notify.ts")>("./notify.ts");
|
|
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", () => {
|
|
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",
|
|
});
|
|
if (expectedCall) {
|
|
expect(vi.mocked(approveDevicePairing)).toHaveBeenCalledWith("req-1", {
|
|
callerScopes: expectedCall,
|
|
});
|
|
} else {
|
|
expect(vi.mocked(approveDevicePairing)).not.toHaveBeenCalled();
|
|
}
|
|
expect(result).toEqual({ text: expectedText });
|
|
});
|
|
});
|