import { Command } from
"commander" ;
import { afterEach, beforeEach, describe, expect, it, vi } from
"vitest" ;
import { registerDevicesCli } from
"./devices-cli.js" ;
const mocks = vi.hoisted(() => ({
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
writeJson: vi.fn(),
},
callGateway: vi.fn(),
buildGatewayConnectionDetails: vi.fn(() => ({
url:
"ws://127.0.0.1:18789",
urlSource:
"local loopback" ,
message:
"" ,
})),
listDevicePairing: vi.fn(),
approveDevicePairing: vi.fn(),
summarizeDeviceTokens: vi.fn(),
withProgress: vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn()),
}));
const {
runtime,
callGateway,
buildGatewayConnectionDetails,
listDevicePairing,
approveDevicePairing,
summarizeDeviceTokens,
} = mocks;
vi.mock(
"../gateway/call.js" , () => ({
callGateway: mocks.callGateway,
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
}));
vi.mock(
"./progress.js" , () => ({
withProgress: mocks.withProgress,
}));
vi.mock(
"../infra/device-pairing.js" , () => ({
listDevicePairing: mocks.listDevicePairing,
approveDevicePairing: mocks.approveDevicePairing,
summarizeDeviceTokens: mocks.summarizeDeviceTokens,
}));
vi.mock(
"../runtime.js" , () => ({
defaultRuntime: mocks.runtime,
writeRuntimeJson: (
targetRuntime: { log: (...args: unknown[]) =>
void },
value: unknown,
space =
2 ,
) => targetRuntime.log(JSON.stringify(value,
null , space >
0 ? space : undefined)),
}));
async
function runDevicesApprove(argv: string[]) {
await runDevicesCommand([
"approve" , ...argv]);
}
async
function runDevicesCommand(argv: string[]) {
const program =
new Command();
registerDevicesCli(program);
await program.parseAsync([
"devices" , ...argv], { from:
"user" });
}
function readRuntimeCallText(call: unknown[] | undefined): string {
const value = call?.[
0 ];
return typeof value ===
"string" ? value :
"" ;
}
function readRuntimeOutput(): string {
return runtime.log.mock.calls.map((entry) => readRuntimeCallText(entry)).join(
"\n" );
}
function pendingDevice(overrides: Record<string, unknown> = {}) {
return {
requestId:
"req-1" ,
deviceId:
"device-1" ,
displayName:
"Device One" ,
role:
"operator" ,
scopes: [
"operator.admin" ],
ts:
1 ,
...overrides,
};
}
function pairedDevice(overrides: Record<string, unknown> = {}) {
return {
deviceId:
"device-1" ,
displayName:
"Device One" ,
roles: [
"operator" ],
scopes: [
"operator.read" ],
...overrides,
};
}
function mockGatewayPairingList(
pendingOverrides: Record<string, unknown> = {},
pairedOverrides: Record<string, unknown> = {},
) {
callGateway.mockResolvedValueOnce({
pending: [pendingDevice(pendingOverrides)],
paired: [pairedDevice(pairedOverrides)],
});
}
function rejectGatewayForLocalFallback(message =
"gateway closed (1008): pairing required" ) {
callGateway.mockRejectedValueOnce(
new Error(message));
}
function mockLocalPairingFallback(message?: string) {
rejectGatewayForLocalFallback(message);
listDevicePairing.mockResolvedValueOnce({
pending: [{ requestId:
"req-1" , deviceId:
"device-1" , publicKey:
"pk" , ts:
1 }],
paired: [],
});
summarizeDeviceTokens.mockReturnValue(undefined);
}
describe(
"devices cli approve" , () => {
it(
"approves an explicit request id without listing" , async () => {
callGateway.mockResolvedValueOnce({ device: { deviceId:
"device-1" } });
await runDevicesApprove([
"req-123" ]);
expect(callGateway).toHaveBeenCalledTimes(
1 );
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method:
"device.pair.approve" ,
params: { requestId:
"req-123" },
}),
);
});
it(
"prints selected details and exits when implicit approval is used" , async () => {
callGateway.mockResolvedValueOnce({
pending: [
{
requestId:
"req-abc" ,
deviceId:
"device-9" ,
displayName:
"Device Nine" ,
role:
"operator" ,
scopes: [
"operator.admin" ],
remoteIp:
"10.0.0.9" ,
ts:
1000 ,
},
],
paired: [
{
deviceId:
"device-9" ,
displayName:
"Device Nine" ,
roles: [
"operator" ],
scopes: [
"operator.read" ],
},
],
});
await runDevicesApprove([]);
expect(callGateway).toHaveBeenCalledTimes(
1 );
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method:
"device.pair.list" }),
);
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join(
"\n" )
;
expect(logOutput).toContain("req-abc" );
expect(logOutput).toContain("Device Nine" );
expect(logOutput).toContain("Approved: roles: operator; scopes: operator.read" );
expect(logOutput).toContain("Requested scopes exceed the current approval" );
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("openclaw devices approve req-abc" ),
);
expect(runtime.exit).toHaveBeenCalledWith(1 );
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
});
it("sanitizes preview ip output for implicit approval" , async () => {
callGateway.mockResolvedValueOnce({
pending: [
{
requestId: "req-abc" ,
deviceId: "device-9" ,
displayName: "Device Nine" ,
role: "operator" ,
scopes: ["operator.admin" ],
remoteIp: "10.0.0.9\rspoof" ,
ts: 1000 ,
},
],
paired: [
{
deviceId: "device-9" ,
displayName: "Device Nine" ,
roles: ["operator" ],
scopes: ["operator.read" ],
},
],
});
await runDevicesApprove([]);
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n" );
expect(logOutput).not.toContain("\r" );
expect(logOutput).toContain("IP: 10.0.0.9spoof" );
});
it.each([
{
name: "id is omitted" ,
args: [] as string[],
pending: [
{ requestId: "req-1" , ts: 1000 },
{ requestId: "req-2" , ts: 2000 },
],
expectedRequestId: "req-2" ,
},
{
name: "--latest is passed" ,
args: ["req-old" , "--latest" ] as string[],
pending: [
{ requestId: "req-2" , ts: 2000 },
{ requestId: "req-3" , ts: 3000 },
],
expectedRequestId: "req-3" ,
},
])("previews latest pending request when $name" , async ({ args, pending, expectedRequestId }) => {
callGateway.mockResolvedValueOnce({
pending,
});
await runDevicesApprove(args);
expect(callGateway).toHaveBeenNthCalledWith(
1 ,
expect.objectContaining({ method: "device.pair.list" }),
);
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(`openclaw devices approve ${expectedRequestId}`),
);
});
it("falls back to device id when selected pending display name is blank" , async () => {
callGateway.mockResolvedValueOnce({
pending: [
{
requestId: "req-blank" ,
deviceId: "device-9" ,
displayName: " " ,
ts: 1000 ,
},
],
});
await runDevicesApprove([]);
const logOutput = runtime.log.mock.calls.map((c) => readRuntimeCallText(c)).join("\n" );
expect(logOutput).toContain("device-9" );
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining("openclaw devices approve req-blank" ),
);
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
});
it("includes explicit gateway flags in the rerun approval command" , async () => {
callGateway.mockResolvedValueOnce({
pending: [{ requestId: "req-url" , deviceId: "device-9" , ts: 1000 }],
});
await runDevicesApprove([
"--latest" ,
"--url" ,
"ws://gateway.example:18789/openclaw?cluster=qa lab",
"--timeout" ,
"3000" ,
"--token" ,
"secret-token" ,
]);
const errorOutput = runtime.error.mock.calls.map((c) => readRuntimeCallText(c)).join("\n" );
expect(errorOutput).toContain(
"openclaw devices approve req-url --url 'ws://gateway.example:18789/openclaw?cluster=qa lab' --timeout 3000",
);
expect(errorOutput).toContain("Reuse the same --token option when rerunning." );
expect(errorOutput).not.toContain("secret-token" );
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
});
it("returns JSON for implicit approval preview in JSON mode" , async () => {
callGateway.mockResolvedValueOnce({
pending: [{ requestId: "req-json" , deviceId: "device-json" , ts: 1000 }],
paired: [],
});
await runDevicesApprove(["--latest" , "--json" , "--url" , "ws://gateway.example:18789"]);
expect(runtime.log).not.toHaveBeenCalled();
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.writeJson).toHaveBeenCalledWith({
selected: { requestId: "req-json" , deviceId: "device-json" , ts: 1000 },
approvalState: {
kind: "new-pairing" ,
requested: { roles: [], scopes: [] },
approved: null ,
},
approveCommand: "openclaw devices approve req-json --url ws://gateway.example:18789 --json",
requiresAuthFlags: {
token: false ,
password: false ,
},
});
expect(runtime.exit).toHaveBeenCalledWith(1 );
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
});
it("prints an error and exits when no pending requests are available" , async () => {
callGateway.mockResolvedValueOnce({ pending: [] });
await runDevicesApprove([]);
expect(callGateway).toHaveBeenCalledTimes(1 );
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.list" }),
);
expect(runtime.error).toHaveBeenCalledWith("No pending device pairing requests to approve" );
expect(runtime.exit).toHaveBeenCalledWith(1 );
expect(callGateway).not.toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.approve" }),
);
});
});
describe("devices cli remove" , () => {
it("removes a paired device by id" , async () => {
callGateway.mockResolvedValueOnce({ deviceId: "device-1" });
await runDevicesCommand(["remove" , "device-1" ]);
expect(callGateway).toHaveBeenCalledTimes(1 );
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "device.pair.remove" ,
params: { deviceId: "device-1" },
}),
);
});
});
describe("devices cli clear" , () => {
it("requires --yes before clearing" , async () => {
await runDevicesCommand(["clear" ]);
expect(callGateway).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith("Refusing to clear pairing table without --yes" );
expect(runtime.exit).toHaveBeenCalledWith(1 );
});
it("clears paired devices and optionally pending requests" , async () => {
callGateway
.mockResolvedValueOnce({
paired: [{ deviceId: "device-1" }, { deviceId: "device-2" }],
pending: [{ requestId: "req-1" }],
})
.mockResolvedValueOnce({ deviceId: "device-1" })
.mockResolvedValueOnce({ deviceId: "device-2" })
.mockResolvedValueOnce({ requestId: "req-1" , deviceId: "device-1" });
await runDevicesCommand(["clear" , "--yes" , "--pending" ]);
expect(callGateway).toHaveBeenNthCalledWith(
1 ,
expect.objectContaining({ method: "device.pair.list" }),
);
expect(callGateway).toHaveBeenNthCalledWith(
2 ,
expect.objectContaining({ method: "device.pair.remove" , params: { deviceId: "device-1" } }),
);
expect(callGateway).toHaveBeenNthCalledWith(
3 ,
expect.objectContaining({ method: "device.pair.remove" , params: { deviceId: "device-2" } }),
);
expect(callGateway).toHaveBeenNthCalledWith(
4 ,
expect.objectContaining({ method: "device.pair.reject" , params: { requestId: "req-1" } }),
);
});
});
describe("devices cli tokens" , () => {
it.each([
{
label: "rotates a token for a device role" ,
argv: [
"rotate" ,
"--device" ,
"device-1" ,
"--role" ,
"main" ,
"--scope" ,
"messages:send" ,
"--scope" ,
"messages:read" ,
],
expectedCall: {
method: "device.token.rotate" ,
params: {
deviceId: "device-1" ,
role: "main" ,
scopes: ["messages:send" , "messages:read" ],
},
},
},
{
label: "revokes a token for a device role" ,
argv: ["revoke" , "--device" , "device-1" , "--role" , "main" ],
expectedCall: {
method: "device.token.revoke" ,
params: {
deviceId: "device-1" ,
role: "main" ,
},
},
},
])("$label" , async ({ argv, expectedCall }) => {
callGateway.mockResolvedValueOnce({ ok: true });
await runDevicesCommand(argv);
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining(expectedCall));
});
it("rejects blank device or role values" , async () => {
await runDevicesCommand(["rotate" , "--device" , " " , "--role" , "main" ]);
expect(callGateway).not.toHaveBeenCalled();
expect(runtime.error).toHaveBeenCalledWith("--device and --role required" );
expect(runtime.exit).toHaveBeenCalledWith(1 );
});
});
describe("devices cli local fallback" , () => {
const fallbackNotice = "Direct scope access failed; using local fallback." ;
it("falls back to local pairing list when gateway returns pairing required on loopback" , async () => {
mockLocalPairingFallback();
await runDevicesCommand(["list" ]);
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({ method: "device.pair.list" }),
);
expect(listDevicePairing).toHaveBeenCalledTimes(1 );
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
});
it("falls back to local approve when gateway returns pairing required on loopback" , async () => {
rejectGatewayForLocalFallback();
approveDevicePairing.mockResolvedValueOnce({
requestId: "req-latest" ,
device: {
deviceId: "device-1" ,
publicKey: "pk" ,
approvedAtMs: 1 ,
createdAtMs: 1 ,
},
});
summarizeDeviceTokens.mockReturnValue(undefined);
await runDevicesApprove(["req-latest" ]);
expect(approveDevicePairing).toHaveBeenCalledWith("req-latest" , {
callerScopes: ["operator.admin" ],
});
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved" ));
});
it("falls back to local pairing list when gateway returns a scope upgrade message on loopback" , async () => {
mockLocalPairingFallback("scope upgrade pending approval (requestId: req-123)" );
await runDevicesCommand(["list" ]);
expect(listDevicePairing).toHaveBeenCalledTimes(1 );
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice));
});
it("does not use local fallback when an explicit --url is provided" , async () => {
rejectGatewayForLocalFallback();
await expect(
runDevicesCommand(["list" , "--json" , "--url" , "ws://127.0.0.1:18789"]),
).rejects.toThrow("pairing required" );
expect(listDevicePairing).not.toHaveBeenCalled();
});
});
describe("devices cli list" , () => {
it("renders requested versus approved access for pending upgrades" , async () => {
mockGatewayPairingList({ scopes: ["operator.admin" , "operator.read" ] });
await runDevicesCommand(["list" ]);
const output = readRuntimeOutput();
expect(output).toContain("Requested" );
expect(output).toContain("Approved" );
expect(output).toContain("operator.write" );
expect(output).toContain("operator.read" );
expect(output).toContain("scope upgrade" );
});
it("normalizes pending device ids before matching paired approvals" , async () => {
mockGatewayPairingList({ deviceId: " device-1 " });
await runDevicesCommand(["list" ]);
const output = readRuntimeOutput();
expect(output).toContain("scope upgrade" );
expect(output).toContain("operator.read" );
});
it("does not show upgrade context for key-mismatched pending requests" , async () => {
mockGatewayPairingList({ publicKey: "new-key" }, { publicKey: "old-key" });
await runDevicesCommand(["list" ]);
const output = readRuntimeOutput();
expect(output).toContain("new pairing" );
expect(output).not.toContain("scope upgrade" );
expect(output).not.toContain("roles: operator; scopes: operator.read" );
});
it("sanitizes device-controlled terminal output" , async () => {
callGateway.mockResolvedValueOnce({
pending: [
{
requestId: "req-1" ,
deviceId: "device-1" ,
displayName: "Bad\u001b[2J\nName" ,
role: "operator" ,
scopes: ["operator.admin" ],
remoteIp: "10.0.0.9\rspoof" ,
ts: 1 ,
},
],
paired: [
{
deviceId: "device-1" ,
displayName: "Pair\u001b]8;;https://evil.example\u001b\\ed ",
roles: ["operator" ],
scopes: ["operator.read" ],
remoteIp: "10.0.0.1\u007f" ,
},
],
});
await runDevicesCommand(["list" ]);
const output = readRuntimeOutput();
expect(output).not.toContain("\u001b" );
expect(output).not.toContain("\r" );
expect(output).toContain("BadName" );
expect(output).toContain("spoof" );
expect(output).toContain("Paired" );
});
});
beforeEach(() => {
vi.clearAllMocks();
runtime.exit.mockImplementation(() => {});
});
afterEach(() => {
buildGatewayConnectionDetails.mockReturnValue({
url: "ws://127.0.0.1:18789",
urlSource: "local loopback" ,
message: "" ,
});
listDevicePairing.mockResolvedValue({ pending: [], paired: [] });
approveDevicePairing.mockResolvedValue(undefined);
summarizeDeviceTokens.mockReturnValue(undefined);
});
Messung V0.5 in Prozent C=99 H=100 G=99
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-05)
¤
*© Formatika GbR, Deutschland