* 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.
219 lines
9.9 KiB
TypeScript
219 lines
9.9 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
|
|
/**
|
|
* Phase 2: Daemon Command Tests
|
|
*
|
|
* Tests daemon commands with an isolated PASEO_HOME.
|
|
*
|
|
* Tests:
|
|
* - daemon --help shows subcommands
|
|
* - daemon pair does not create a relay offer without explicit consent
|
|
* - daemon status reports stopped when daemon not running
|
|
* - daemon status --json outputs valid JSON
|
|
* - daemon stop handles daemon not running gracefully
|
|
* - daemon restart starts the daemon and can be cleaned up
|
|
* - daemon status probes the live relay state over local IPC
|
|
*/
|
|
|
|
import assert from "node:assert";
|
|
import { mkdtemp, readFile, rm, unlink, writeFile } from "fs/promises";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
|
|
|
console.log("=== Daemon Commands ===\n");
|
|
|
|
// Keep restart off default 6767 to avoid collisions with any existing daemon.
|
|
const port = 10000 + Math.floor(Math.random() * 50000);
|
|
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
|
|
|
|
function daemonCommand(args: string[]) {
|
|
return runLocalPaseo(["daemon", ...args], { PASEO_HOME: paseoHome });
|
|
}
|
|
|
|
try {
|
|
// Test 1: daemon --help shows subcommands
|
|
{
|
|
console.log("Test 1: daemon --help shows subcommands");
|
|
const result = await runLocalPaseo(["daemon", "--help"]);
|
|
assert.strictEqual(result.exitCode, 0, "daemon --help should exit 0");
|
|
assert(result.stdout.includes("start"), "help should mention start");
|
|
assert(result.stdout.includes("status"), "help should mention status");
|
|
assert(result.stdout.includes("stop"), "help should mention stop");
|
|
assert(result.stdout.includes("restart"), "help should mention restart");
|
|
assert(result.stdout.includes("pair"), "help should mention pair");
|
|
console.log("✓ daemon --help shows subcommands\n");
|
|
}
|
|
|
|
// Test 2: non-interactive pairing keeps relay disabled by default
|
|
{
|
|
console.log("Test 2: daemon pair requires explicit relay consent");
|
|
const result = await daemonCommand(["pair"]);
|
|
assert.strictEqual(result.exitCode, 1, "daemon pair should require relay consent");
|
|
assert(
|
|
result.stderr.includes("Relay pairing is disabled"),
|
|
"output should explain that relay pairing is disabled",
|
|
);
|
|
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
|
|
console.log("✓ daemon pair requires explicit relay consent\n");
|
|
}
|
|
|
|
// Test 3: daemon status reports stopped when daemon not running
|
|
{
|
|
console.log("Test 3: daemon status reports stopped when not running");
|
|
const result = await daemonCommand(["status"]);
|
|
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
|
|
const output = result.stdout.toLowerCase();
|
|
assert(output.includes("local daemon"), "status table should include Local Daemon row");
|
|
assert(output.includes("stopped"), "status should report stopped");
|
|
console.log("✓ daemon status reports stopped when not running\n");
|
|
}
|
|
|
|
// Test 4: daemon pair --json exposes the machine-readable disabled state
|
|
{
|
|
console.log("Test 4: daemon pair --json reports relay disabled");
|
|
const result = await daemonCommand(["pair", "--json"]);
|
|
assert.strictEqual(result.exitCode, 1, "daemon pair --json should require relay consent");
|
|
const errorLine = result.stderr.split("\n").find((line) => line.startsWith("{"));
|
|
assert(errorLine, "stderr should include a structured error");
|
|
const error = JSON.parse(errorLine);
|
|
assert.strictEqual(error.code, "RELAY_DISABLED", "error should identify relay state");
|
|
assert(!result.stdout.includes("#offer="), "output should not include a pairing offer");
|
|
console.log("✓ daemon pair --json reports relay disabled\n");
|
|
}
|
|
|
|
// Test 5: daemon status --json outputs valid JSON
|
|
{
|
|
console.log("Test 5: daemon status --json outputs JSON");
|
|
const result = await daemonCommand(["status", "--json"]);
|
|
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
|
|
const status = JSON.parse(result.stdout);
|
|
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
|
|
assert.strictEqual(status.localDaemon, "stopped", "json status should report stopped");
|
|
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
|
|
assert.strictEqual(
|
|
status.hostname,
|
|
null,
|
|
"json status should include hostname when unavailable",
|
|
);
|
|
console.log("✓ daemon status --json outputs valid JSON\n");
|
|
}
|
|
|
|
// Test 6: daemon stop handles daemon not running gracefully
|
|
{
|
|
console.log("Test 6: daemon stop handles daemon not running");
|
|
const result = await daemonCommand(["stop"]);
|
|
// Stop should succeed even if daemon is not running (idempotent).
|
|
assert.strictEqual(result.exitCode, 0, "stop should succeed when daemon not running");
|
|
const output = result.stdout + result.stderr;
|
|
const mentionsNotRunning =
|
|
output.toLowerCase().includes("not running") ||
|
|
output.toLowerCase().includes("was not running");
|
|
assert(mentionsNotRunning, "output should mention daemon was not running");
|
|
console.log("✓ daemon stop succeeds gracefully when daemon not running\n");
|
|
}
|
|
|
|
// Test 7: daemon restart starts daemon and can be stopped
|
|
{
|
|
console.log("Test 7: daemon restart starts daemon and can be stopped");
|
|
const result = await daemonCommand(["restart", "--port", String(port)]);
|
|
assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped");
|
|
assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart");
|
|
|
|
const cleanup = await daemonCommand(["stop", "--force"]);
|
|
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
|
|
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
|
|
}
|
|
|
|
// Test 8: status uses the running daemon as relay authority over local IPC
|
|
{
|
|
console.log("Test 8: daemon status probes live relay state over local IPC");
|
|
const listen =
|
|
process.platform === "win32"
|
|
? `\\\\.\\pipe\\paseo-status-${process.pid}-${Date.now()}`
|
|
: join(paseoHome, "status.sock");
|
|
const start = await daemonCommand(["start", "--listen", listen, "--relay"]);
|
|
assert.strictEqual(start.exitCode, 0, `IPC daemon should start: ${start.stderr}`);
|
|
|
|
const configPath = join(paseoHome, "config.json");
|
|
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
config.daemon = { ...config.daemon, listen };
|
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
const pidPath = join(paseoHome, "paseo.pid");
|
|
const pidContents = await readFile(pidPath, "utf-8");
|
|
await unlink(pidPath);
|
|
const status = await daemonCommand(["status", "--json"]);
|
|
const pairing = await daemonCommand(["pair", "--json"]);
|
|
await writeFile(pidPath, pidContents, "utf-8");
|
|
assert.strictEqual(status.exitCode, 0, `IPC daemon status should succeed: ${status.stderr}`);
|
|
const payload = JSON.parse(status.stdout);
|
|
assert.strictEqual(payload.connectedDaemon, "reachable", "IPC daemon should be reachable");
|
|
assert.strictEqual(payload.localDaemon, "stopped", "missing PID should report stopped locally");
|
|
assert.notStrictEqual(payload.relay, "disabled", "status should use live relay state");
|
|
assert.strictEqual(pairing.exitCode, 0, `IPC daemon pairing should succeed: ${pairing.stderr}`);
|
|
const pairingPayload = JSON.parse(pairing.stdout);
|
|
assert.strictEqual(pairingPayload.relayEnabled, true, "pairing should use live relay state");
|
|
assert.match(pairingPayload.url, /#offer=/, "pairing should use the live daemon offer");
|
|
|
|
const foreignHome = await mkdtemp(join(tmpdir(), "paseo-test-foreign-home-"));
|
|
try {
|
|
await writeFile(
|
|
join(foreignHome, "config.json"),
|
|
`${JSON.stringify({ daemon: { listen } }, null, 2)}\n`,
|
|
"utf-8",
|
|
);
|
|
const foreignPairing = await runLocalPaseo(
|
|
["daemon", "pair", "--home", foreignHome, "--json"],
|
|
{ PASEO_HOME: foreignHome },
|
|
);
|
|
assert.notStrictEqual(
|
|
foreignPairing.exitCode,
|
|
0,
|
|
"pairing should reject a daemon owned by another home",
|
|
);
|
|
assert(
|
|
foreignPairing.stderr.includes("different Paseo home"),
|
|
"pairing should explain the daemon identity mismatch",
|
|
);
|
|
assert(!foreignPairing.stdout.includes("#offer="), "pairing should not expose another offer");
|
|
} finally {
|
|
await rm(foreignHome, { recursive: true, force: true });
|
|
}
|
|
|
|
const cleanup = await daemonCommand(["stop", "--force"]);
|
|
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after IPC status");
|
|
console.log("✓ daemon status probes live relay state over local IPC\n");
|
|
}
|
|
|
|
// Test 9: --relay accepts an already-enabled persisted relay while stopped
|
|
{
|
|
console.log("Test 9: daemon pair --relay accepts persisted relay while stopped");
|
|
const configPath = join(paseoHome, "config.json");
|
|
const config = JSON.parse(await readFile(configPath, "utf-8"));
|
|
config.daemon = {
|
|
...config.daemon,
|
|
relay: { ...config.daemon?.relay, enabled: true },
|
|
};
|
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
|
|
const pairing = await daemonCommand(["pair", "--relay", "--json"]);
|
|
|
|
assert.strictEqual(
|
|
pairing.exitCode,
|
|
0,
|
|
`persisted relay pairing should succeed while stopped: ${pairing.stderr}`,
|
|
);
|
|
const payload = JSON.parse(pairing.stdout);
|
|
assert.strictEqual(payload.relayEnabled, true, "pairing should preserve persisted relay state");
|
|
assert.match(payload.url, /#offer=/, "pairing should include the offline offer");
|
|
console.log("✓ daemon pair --relay accepts persisted relay while stopped\n");
|
|
}
|
|
} finally {
|
|
// Best-effort daemon cleanup in case assertions fail before explicit stop.
|
|
await daemonCommand(["stop", "--force"]);
|
|
// Clean up temp directory
|
|
await rm(paseoHome, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log("=== All daemon tests passed ===");
|