Make relay opt-in when pairing devices (#2706)
* feat: make relay opt-in for device pairing * fix(relay): keep pairing state authoritative * fix(relay): refresh pairing state across transports * fix(onboard): honor explicit relay opt-out * fix(relay): surface transport startup failures * fix(pairing): gate offers for older daemons * test(cli): rely on real pairing transport coverage * fix(cli): report live relay without a pid file * fix(relay): synchronize config during daemon startup * fix(relay): preserve mutability for ignored overrides * fix(cli): use reachable daemon for pairing * fix(app): redirect invalid pair device routes * fix(pairing): support remote host device pairing Keep Pair Device host-relative through the existing settings and daemon RPC flow. Route CLI output through an injected adapter so workflow tests do not mutate process globals. * fix(pairing): validate pairing destinations Expose the host-relative Pair Device flow on every supported app platform. Reject a reachable daemon whose identity does not match the requested Paseo home before reading or mutating its pairing state. * fix(pairing): render QR codes across platforms Generate SVG text without DOM or canvas and render it through the existing cross-platform SVG stack. * fix(cli): pair from persisted relay state Allow explicit relay pairing while the daemon is stopped when relay is already enabled in persisted configuration.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { runPairCommand, type PairCommandOutput, type PairingOffer } from "./pair.js";
|
||||
|
||||
const disabledOffer: PairingOffer = { relayEnabled: false, url: null, qr: null };
|
||||
const enabledOffer: PairingOffer = {
|
||||
relayEnabled: true,
|
||||
url: "https://app.paseo.sh/#offer=test",
|
||||
qr: null,
|
||||
};
|
||||
|
||||
interface RecordedPairCommandOutput extends PairCommandOutput {
|
||||
stdout: string[];
|
||||
stderr: string[];
|
||||
successes: string[];
|
||||
exitCode: number | undefined;
|
||||
}
|
||||
|
||||
function createRecordedOutput(): RecordedPairCommandOutput {
|
||||
return {
|
||||
columns: 80,
|
||||
stdout: [],
|
||||
stderr: [],
|
||||
successes: [],
|
||||
exitCode: undefined,
|
||||
writeStdout(message) {
|
||||
this.stdout.push(message);
|
||||
},
|
||||
writeStderr(message) {
|
||||
this.stderr.push(message);
|
||||
},
|
||||
setExitCode(code) {
|
||||
this.exitCode = code;
|
||||
},
|
||||
success(message) {
|
||||
this.successes.push(message);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("daemon pair workflow", () => {
|
||||
test("interactive decline prints direct guidance and creates no pairing output", async () => {
|
||||
const resolveOffer = vi.fn(async () => disabledOffer);
|
||||
const confirmRelay = vi.fn(async () => false);
|
||||
const printDirectGuidance = vi.fn();
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{},
|
||||
{ resolveOffer, confirmRelay, printDirectGuidance, isInteractive: () => true, output },
|
||||
);
|
||||
|
||||
expect(confirmRelay).toHaveBeenCalledOnce();
|
||||
expect(printDirectGuidance).toHaveBeenCalledOnce();
|
||||
expect(output.stderr.join("")).toContain("No pairing QR was created");
|
||||
expect(output.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("interactive consent enables relay and prints the refreshed offer", async () => {
|
||||
const resolveOffer = vi
|
||||
.fn<(options: { paseoHome: string; enableRelay?: boolean }) => Promise<PairingOffer>>()
|
||||
.mockResolvedValueOnce(disabledOffer)
|
||||
.mockResolvedValueOnce(enabledOffer);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{},
|
||||
{
|
||||
resolveOffer,
|
||||
confirmRelay: async () => true,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => true,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveOffer).toHaveBeenNthCalledWith(2, expect.objectContaining({ enableRelay: true }));
|
||||
expect(output.stdout.join("")).toContain(enabledOffer.url ?? "");
|
||||
expect(output.successes).toEqual(["Relay enabled"]);
|
||||
});
|
||||
|
||||
test("JSON mode never prompts and returns a structured relay-disabled error", async () => {
|
||||
const confirmRelay = vi.fn(async () => true);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{ json: true },
|
||||
{
|
||||
resolveOffer: async () => disabledOffer,
|
||||
confirmRelay,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => true,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(confirmRelay).not.toHaveBeenCalled();
|
||||
expect(output.stderr.join("")).toContain('"code":"RELAY_DISABLED"');
|
||||
expect(output.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
test("explicit relay opts in without prompting", async () => {
|
||||
const resolveOffer = vi.fn(async () => enabledOffer);
|
||||
const confirmRelay = vi.fn(async () => false);
|
||||
const output = createRecordedOutput();
|
||||
|
||||
await runPairCommand(
|
||||
{ relay: true, json: true },
|
||||
{
|
||||
resolveOffer,
|
||||
confirmRelay,
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => false,
|
||||
output,
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveOffer).toHaveBeenCalledWith(expect.objectContaining({ enableRelay: true }));
|
||||
expect(confirmRelay).not.toHaveBeenCalled();
|
||||
expect(output.exitCode).toBeUndefined();
|
||||
});
|
||||
|
||||
test("surfaces launch-override rejection", async () => {
|
||||
await expect(
|
||||
runPairCommand(
|
||||
{ relay: true },
|
||||
{
|
||||
resolveOffer: async () => {
|
||||
throw new Error("Relay is controlled by a daemon launch override");
|
||||
},
|
||||
confirmRelay: vi.fn(),
|
||||
printDirectGuidance: vi.fn(),
|
||||
isInteractive: () => false,
|
||||
output: createRecordedOutput(),
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("launch override");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user