server/cli-pair.test.ts
Rama main · réplica de lectura del repositorio original.
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runPair, type CliOptions } from "./cli.ts";
import { removeTempDir } from "./testing/cleanup.ts";
const mocks = vi.hoisted(() => ({
readCliStartup: vi.fn(),
createTunnelAccount: vi.fn(), describeTunnelAccount: vi.fn(), tailscaleStatus: vi.fn(),
ui: { log: vi.fn(), choose: vi.fn(), ask: vi.fn(), secret: vi.fn(), confirm: vi.fn() },
}));
vi.mock("./cli-setup.ts", () => ({ readCliStartup: mocks.readCliStartup }));
vi.mock("./cli-prompts.ts", async (original) => ({
...await original<typeof import("./cli-prompts.ts")>(), defaultSetupIo: () => mocks.ui,
}));
vi.mock("./tailscale.ts", () => ({
tailscaleStatus: mocks.tailscaleStatus,
tailscaleServe: vi.fn(), clearLeftoverTailscaleServe: vi.fn(), explainTailscaleFailure: vi.fn(),
}));
vi.mock("./tunnel.ts", () => ({
createTunnelAccount: mocks.createTunnelAccount, describeTunnelAccount: mocks.describeTunnelAccount,
cleanupTunnelOrigin: vi.fn(), createTunnelOrigin: vi.fn(), describeTunnelState: vi.fn(),
ensureCloudflared: vi.fn(), guardianEntry: vi.fn(), startTunnel: vi.fn(), tunnelAccess: vi.fn(),
}));
const advertisedOrigin = "https://current.example.test";
const explicitOrigin = "https://override.example.test";
const workspaceId = "4e406646-1030-4613-8878-e227ab722ffc";
const code = "ABCD-EFGH-JKLM";
const ttyDescriptors = [process.stdin, process.stdout].map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
let dataDir: string;
let options: CliOptions;
let publicUrl: string | null;
let remoteWorkspaceId: string;
const fetchMock = vi.fn<typeof fetch>();
beforeEach(() => {
vi.resetAllMocks();
// The shared test setup supplies a throwaway HOME before module imports.
dataDir = mkdtempSync(join(process.env.HOME!, "cli-pair-"));
vi.stubEnv("OMB_DATA_DIR", dataDir);
options = { command: "pair", port: 18451, dataDir, tailscale: false, tunnel: false, client: false, pair: true, json: false };
publicUrl = advertisedOrigin;
remoteWorkspaceId = workspaceId;
for (const stream of [process.stdin, process.stdout]) Object.defineProperty(stream, "isTTY", { value: true, configurable: true });
mocks.ui.choose.mockResolvedValue(0);
mocks.createTunnelAccount.mockReturnValue({ credentials: { read: () => ({}) } });
mocks.describeTunnelAccount.mockReturnValue({ address: "https://old-tunnel.example.test", email: "fixture@example.test" });
mocks.tailscaleStatus.mockResolvedValue({ status: { dnsName: "old-tailnet.example.test" } });
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
fetchMock.mockImplementation(async (input, init) => {
const url = String(input);
const local = `http://127.0.0.1:${options.port}`;
if (url === `${local}/api/health`) return Response.json({ app: "openmausbot", pid: 12345 });
if (url === `${local}/api/auth/pairing`) {
if (init?.method === "POST") return Response.json({ code, expiresAt: Date.now() + 300_000, url: `${advertisedOrigin}/pair#code=${code}` });
return Response.json({ pairings: [], publicUrl });
}
if (url === `${local}/.well-known/openmausbot/environment`) return Response.json({ environmentId: workspaceId });
if ([advertisedOrigin, explicitOrigin].some((origin) => url === `${origin}/.well-known/openmausbot/environment`)) {
return Response.json({ environmentId: remoteWorkspaceId });
}
// No real network fallback is allowed, including stale saved addresses.
throw new Error(`Unexpected fixture request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
vi.restoreAllMocks();
for (const [index, stream] of [process.stdin, process.stdout].entries()) {
const descriptor = ttyDescriptors[index];
if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
else Reflect.deleteProperty(stream, "isTTY");
}
await removeTempDir(dataDir);
});
const printed = () => vi.mocked(console.log).mock.calls.map(([line]) => String(line)).join("\n");
describe("openmausbot pair", () => {
it("never asks anything: one code, the running server's own link, a QR, and the phone command", async () => {
expect(await runPair(options)).toBe(0);
expect(mocks.ui.choose).not.toHaveBeenCalled();
expect(mocks.readCliStartup).not.toHaveBeenCalled();
expect(mocks.tailscaleStatus).not.toHaveBeenCalled();
const posted = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
expect(JSON.parse(String(posted![1]!.body))).toEqual({});
expect(printed()).toMatch(new RegExp(`Pairing code {3}${code} {3}works once, until .+ · everything`));
expect(printed()).toContain(`Link ${advertisedOrigin}/pair#code=${code}`);
expect(printed()).toMatch(/[▀▄█]/);
expect(printed()).toContain("For a phone (chat and approvals only) openmausbot pair --phone");
expect(printed()).not.toContain("doesn't reach");
});
it("an explicit --public-url wins over the advertised address", async () => {
expect(await runPair({ ...options, publicUrl: explicitOrigin })).toBe(0);
expect(printed()).toContain(`Link ${explicitOrigin}/pair#code=${code}`);
});
it("--phone mints a chat-only code", async () => {
expect(await runPair({ ...options, phone: "ios" })).toBe(0);
const posted = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
expect(JSON.parse(String(posted![1]!.body))).toEqual({ scopes: ["client"] });
expect(printed()).toContain("· chat and approvals");
expect(printed()).not.toContain("For a phone");
});
it("still prints the code when the address answers as another server, and says what to fix", async () => {
remoteWorkspaceId = "another-workspace";
expect(await runPair(options)).toBe(0);
expect(printed()).toContain(`Link ${advertisedOrigin}/pair#code=${code}`);
expect(printed()).toContain(`✗ ${advertisedOrigin} answers as a different server.`);
expect(printed()).toContain("The code works once the address answers.");
});
it("with no way in: the browser on this machine, and the command that lists the ways in", async () => {
publicUrl = null;
fetchMock.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/health")) return Response.json({ app: "openmausbot", pid: 12345 });
if (url.endsWith("/api/auth/pairing")) return init?.method === "POST" ? Response.json({ code, expiresAt: Date.now() + 600_000, url: null }) : Response.json({ pairings: [], publicUrl: null });
throw new Error(`Unexpected fixture request: ${url}`);
});
expect(await runPair(options)).toBe(0);
expect(printed()).toContain(`http://127.0.0.1:${options.port}/pair#code=${code}`);
expect(printed()).toContain("pick a way in first: openmausbot serve --help");
expect(printed()).not.toMatch(/[▀▄█]/);
});
it("says how to start a server when none runs", async () => {
fetchMock.mockImplementation(async () => { throw new Error("offline"); });
expect(await runPair(options)).toBe(1);
expect(console.error).toHaveBeenCalledWith(expect.stringContaining("No MausBot server is running here. Start it: openmausbot serve"));
});
});
describe("the phone-app link `openmausbot pair --phone android` scans", () => {
const credential = `omb_pair_${"b".repeat(43)}`;
const serverName = "Miguel's computer";
const pairingResponse = (fields: Record<string, unknown>) => {
fetchMock.mockImplementation(async (input, init) => {
const url = String(input);
if (url === `http://127.0.0.1:${options.port}/api/health`) return Response.json({ app: "openmausbot", pid: 12345 });
if (url === `http://127.0.0.1:${options.port}/api/auth/pairing`) {
return init?.method === "POST" ? Response.json({ code, expiresAt: Date.now() + 300_000, credential, serverName, ...fields }) : Response.json({ pairings: [], publicUrl: null });
}
throw new Error(`Unexpected fixture request: ${url}`);
});
};
it("builds the invite for --public-url from the server's credential and name, and never prints it as text", async () => {
pairingResponse({ url: null, inviteUrl: null });
expect(await runPair({ ...options, phone: "android", publicUrl: explicitOrigin })).toBe(0);
expect(printed()).not.toContain("openmausbot://");
expect(printed()).not.toContain(credential);
expect(printed()).toContain("scan this with the MausBot app");
});
it("keeps a proxy path in the web link", async () => {
pairingResponse({ url: `https://proxy.example.test/omb/pair#code=${code}`, inviteUrl: null });
expect(await runPair({ ...options, label: "Pixel" })).toBe(0);
expect(printed()).toContain(`https://proxy.example.test/omb/pair#code=${code}`);
});
});
describe("pair --ttl", () => {
it("asks the server to keep the code open that long and says until when", async () => {
const day = Date.now() + 86_400_000;
fetchMock.mockImplementation(async (input, init) => {
const url = String(input);
if (url.endsWith("/api/health")) return Response.json({ app: "openmausbot", pid: 12345 });
if (url.endsWith("/api/auth/pairing")) return init?.method === "POST" ? Response.json({ code, expiresAt: day, url: null }) : Response.json({ pairings: [], publicUrl: null });
throw new Error(`Unexpected fixture request: ${url}`);
});
expect(await runPair({ ...options, pairTtlMs: 86_400_000 })).toBe(0);
const posted = fetchMock.mock.calls.find(([, init]) => init?.method === "POST");
expect(JSON.parse(String(posted![1]!.body))).toEqual({ ttlMs: 86_400_000 });
expect(printed()).toMatch(/until tomorrow /);
});
});