import { Command } from "commander"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; const callGateway = vi.fn(); const buildGatewayConnectionDetails = vi.fn(() => ({ url: "ws://127.0.0.1:18789", urlSource: "local loopback", message: "", })); const listDevicePairing = vi.fn(); const approveDevicePairing = vi.fn(); const summarizeDeviceTokens = vi.fn(); const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise) => await fn()); const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn(), }; vi.mock("../gateway/call.js", () => ({ callGateway, buildGatewayConnectionDetails, })); vi.mock("./progress.js", () => ({ withProgress, })); vi.mock("../infra/device-pairing.js", () => ({ listDevicePairing, approveDevicePairing, summarizeDeviceTokens, })); vi.mock("../runtime.js", () => ({ defaultRuntime: runtime, })); let registerDevicesCli: typeof import("./devices-cli.js").registerDevicesCli; beforeAll(async () => { ({ registerDevicesCli } = await import("./devices-cli.js")); }); 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" }); } 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.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", }, ])("uses latest pending request when $name", async ({ args, pending, expectedRequestId }) => { callGateway .mockResolvedValueOnce({ pending, }) .mockResolvedValueOnce({ device: { deviceId: "device-2" } }); await runDevicesApprove(args); expect(callGateway).toHaveBeenNthCalledWith( 1, expect.objectContaining({ method: "device.pair.list" }), ); expect(callGateway).toHaveBeenNthCalledWith( 2, expect.objectContaining({ method: "device.pair.approve", params: { requestId: expectedRequestId }, }), ); }); 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 () => { callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); listDevicePairing.mockResolvedValueOnce({ pending: [{ requestId: "req-1", deviceId: "device-1", publicKey: "pk", ts: 1 }], paired: [], }); summarizeDeviceTokens.mockReturnValue(undefined); 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 () => { callGateway .mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")) .mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); listDevicePairing.mockResolvedValueOnce({ pending: [{ requestId: "req-latest", deviceId: "device-1", publicKey: "pk", ts: 2 }], paired: [], }); approveDevicePairing.mockResolvedValueOnce({ requestId: "req-latest", device: { deviceId: "device-1", publicKey: "pk", approvedAtMs: 1, createdAtMs: 1, }, }); summarizeDeviceTokens.mockReturnValue(undefined); await runDevicesApprove(["--latest"]); expect(approveDevicePairing).toHaveBeenCalledWith("req-latest"); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining(fallbackNotice)); expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Approved")); }); it("does not use local fallback when an explicit --url is provided", async () => { callGateway.mockRejectedValueOnce(new Error("gateway closed (1008): pairing required")); await expect( runDevicesCommand(["list", "--json", "--url", "ws://127.0.0.1:18789"]), ).rejects.toThrow("pairing required"); expect(listDevicePairing).not.toHaveBeenCalled(); }); }); afterEach(() => { callGateway.mockClear(); buildGatewayConnectionDetails.mockClear(); buildGatewayConnectionDetails.mockReturnValue({ url: "ws://127.0.0.1:18789", urlSource: "local loopback", message: "", }); listDevicePairing.mockClear(); listDevicePairing.mockResolvedValue({ pending: [], paired: [] }); approveDevicePairing.mockClear(); approveDevicePairing.mockResolvedValue(undefined); summarizeDeviceTokens.mockClear(); summarizeDeviceTokens.mockReturnValue(undefined); withProgress.mockClear(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); });