server/cli-phone-setup.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliOptions } from "./cli.ts";
import { SetupCancelled, type SetupIo } from "./cli-prompts.ts";
import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup, type PhoneSetupDependencies } from "./cli-phone-setup.ts";
const options: CliOptions = {
command: "start", port: 18799, dataDir: "/fixture/phone-home", tailscale: false,
tunnel: false, pair: false, client: false, json: false,
};
function prompts(script: { choices?: Array<number | Error>; confirms?: boolean[]; answers?: Array<string | Error> } = {}) {
const choices = [...script.choices ?? []];
const confirms = [...script.confirms ?? []];
const answers = [...script.answers ?? []];
const lines: string[] = [];
const take = <T>(values: Array<T | Error>, question: string): T => {
if (!values.length) throw new Error(`Unexpected fixture prompt: ${question}`);
const next = values.shift()!;
if (next instanceof Error) throw next;
return next;
};
const io: SetupIo = {
log: vi.fn((line: string) => { lines.push(line); }),
choose: vi.fn(async (question: string, choicesOffered: readonly string[]) => {
lines.push(question, ...choicesOffered);
return take(choices, question);
}),
ask: vi.fn(async (question: string) => take(answers, question)),
secret: vi.fn(async () => { throw new Error("The fixture must inject account sign-in"); }),
confirm: vi.fn(async (question: string) => { lines.push(question); return take(confirms, question); }),
};
return { io, lines, consumed: () => expect([choices, confirms, answers]).toEqual([[], [], []]) };
}
function dependencies() {
return {
accountReady: vi.fn<NonNullable<PhoneSetupDependencies["accountReady"]>>().mockReturnValue(false),
login: vi.fn<PhoneSetupDependencies["login"]>().mockResolvedValue(0),
};
}
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn(() => { throw new Error("Phone setup fixtures must not use the network"); }));
});
afterEach(() => vi.unstubAllGlobals());
describe("phone origin validation", () => {
it.each([
["https://maus.example", "https://maus.example"],
[" https://MAUS.example:443/ ", "https://maus.example"],
["https://mini.tail1234.ts.net:8443", "https://mini.tail1234.ts.net:8443"],
["https://[2001:db8::1]:8443/", "https://[2001:db8::1]:8443"],
])("normalizes an HTTPS origin without claiming reachability: %s", (input, expected) => {
expect(normalizePhoneOrigin(input)).toBe(expected);
});
it.each([
"", "not a url", "http://maus.example", "https:maus.example", "https:///maus.example", "https://@maus.example",
"https://localhost", "https://localhost.",
"https://mini.localhost", "https://127.0.0.1:8799", "https://127.1", "https://2130706433",
"https://0.0.0.0", "https://[::]", "https://[::1]", "https://[::ffff:127.0.0.1]",
"https://[::ffff:0.0.0.0]", "https://user:password@maus.example", "https://maus.example/pair",
"https://maus.example/?token=secret", "https://maus.example/#code=ABCD-EFGH-JKLM",
"https://maus.example/?", "https://maus.example/#", "https://maus.\nexample",
"https://maus.example\\private", "openmausbot://pair?token=secret",
])("rejects a local, credential-bearing or non-origin input: %s", (input) => {
expect(normalizePhoneOrigin(input)).toBeNull();
});
});
describe("optional phone setup", () => {
it("defaults to skipping without account access or changes", async () => {
const deps = dependencies();
const ui = prompts({ choices: [0] });
const result = await runPhoneSetup(options, ui.io, deps);
expect(result).toEqual({ options });
expect(result.options).toBe(options);
expect(ui.io.choose).toHaveBeenCalledWith("Use OpenMausBot on your phone?", expect.any(Array), 0);
expect(deps.accountReady).not.toHaveBeenCalled();
expect(deps.login).not.toHaveBeenCalled();
ui.consumed();
});
it("requires explicit public exposure/download consent before managed sign-in", async () => {
const deps = dependencies();
const ui = prompts({ choices: [1, 0], confirms: [true] });
const original = { ...options };
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({
phone: "ios", options: { ...options, tunnel: true, publicUrl: undefined, client: true, pair: true },
});
expect(options).toEqual(original);
expect(deps.login).toHaveBeenCalledWith(options, ui.io);
expect(ui.io.confirm).toHaveBeenCalledWith(expect.stringContaining("public endpoint"), false);
expect(vi.mocked(ui.io.confirm).mock.invocationCallOrder[0]).toBeLessThan(deps.accountReady.mock.invocationCallOrder[0]!);
expect(ui.lines.join("\n")).toContain("basic server identity are public");
expect(ui.lines.join("\n")).toContain("download the Cloudflare connector");
ui.consumed();
});
it("declining exposure returns to routes and may skip without sign-in", async () => {
const deps = dependencies();
const ui = prompts({ choices: [2, 0, 4], confirms: [false] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({ options });
expect(deps.accountReady).not.toHaveBeenCalled();
expect(deps.login).not.toHaveBeenCalled();
ui.consumed();
});
it("reuses an existing account after consent without another email", async () => {
const deps = dependencies();
deps.accountReady.mockReturnValue(true);
const ui = prompts({ choices: [2, 0], confirms: [true] });
expect((await runPhoneSetup(options, ui.io, deps)).phone).toBe("android");
expect(deps.login).not.toHaveBeenCalled();
expect(ui.lines.join("\n")).toContain("saved OpenMausBot account");
ui.consumed();
});
it.each([1, new Error("secret-token-never-print")])("does not select managed access after failed sign-in: %s", async (failure) => {
const deps = dependencies();
if (failure instanceof Error) deps.login.mockRejectedValue(failure);
else deps.login.mockResolvedValue(failure);
const ui = prompts({ choices: [1, 0], confirms: [true] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({ options });
expect(ui.lines.join("\n")).not.toContain("secret-token-never-print");
expect(ui.lines.join("\n")).toContain("provider setup is still saved");
ui.consumed();
});
it("does not attempt sign-in if saved-account inspection fails", async () => {
const deps = dependencies();
deps.accountReady.mockRejectedValue(new Error("credential-document-secret"));
const ui = prompts({ choices: [1, 0], confirms: [true] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({ options });
expect(deps.login).not.toHaveBeenCalled();
expect(ui.lines.join("\n")).not.toContain("credential-document-secret");
});
it("supports existing Tailscale only after consent, without installing it or signing in", async () => {
const deps = dependencies();
const ui = prompts({ choices: [2, 1], confirms: [true] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({
phone: "android", options: { ...options, tailscale: true, publicUrl: undefined, client: true, pair: true },
});
expect(ui.io.confirm).toHaveBeenCalledWith(expect.stringContaining("tailnet"), false);
expect(deps.login).not.toHaveBeenCalled();
expect(ui.lines.join("\n")).toContain("already be installed");
ui.consumed();
});
it("rejects a pasted secret without echoing it, then accepts an advanced HTTPS origin", async () => {
const deps = dependencies();
const ui = prompts({ choices: [2, 2, 2], answers: ["https://user:secret@example.com/#code=credential", "https://maus.example/"] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({
phone: "android", options: { ...options, publicUrl: "https://maus.example", client: true, pair: true },
});
expect(ui.lines.join("\n")).not.toContain("user:secret");
expect(ui.lines.join("\n")).not.toContain("code=credential");
expect(deps.login).not.toHaveBeenCalled();
ui.consumed();
});
it("offers back and skip without enabling any route", async () => {
const deps = dependencies();
const ui = prompts({ choices: [1, 2, 3, 0], answers: [""] });
expect(await runPhoneSetup(options, ui.io, deps)).toEqual({ options });
expect(deps.login).not.toHaveBeenCalled();
ui.consumed();
});
it.each([
{ publicUrl: "https://maus.example/" }, { tailscale: true }, { tunnel: true },
])("reuses explicit existing route options without a nested chooser: %j", async (route) => {
const deps = dependencies();
deps.accountReady.mockReturnValue(true);
const ui = prompts({ choices: [1] });
const input = { ...options, ...route };
const result = await runPhoneSetup(input, ui.io, deps);
expect(result.phone).toBe("ios");
expect(result.options.client).toBe(true);
expect(ui.io.choose).toHaveBeenCalledTimes(1);
expect(ui.io.confirm).not.toHaveBeenCalled();
expect(deps.login).not.toHaveBeenCalled();
ui.consumed();
});
it("does not mistake a localhost publicUrl for phone connectivity", async () => {
const deps = dependencies();
const ui = prompts({ choices: [1, 4] });
const input = { ...options, publicUrl: "http://127.0.0.1:18799" };
expect(await runPhoneSetup(input, ui.io, deps)).toEqual({ options: input });
expect(ui.lines.join("\n")).toContain("localhost or LAN-IP link will not connect");
ui.consumed();
});
it("propagates Ctrl-C before route selection without changing options", async () => {
const deps = dependencies();
const ui = prompts({ choices: [new SetupCancelled()] });
await expect(runPhoneSetup(options, ui.io, deps)).rejects.toBeInstanceOf(SetupCancelled);
expect(deps.login).not.toHaveBeenCalled();
expect(options.tunnel).toBe(false);
});
it("propagates Ctrl-C during account sign-in without selecting or saving a route", async () => {
const deps = dependencies();
deps.login.mockRejectedValue(new SetupCancelled());
const ui = prompts({ choices: [1, 0], confirms: [true] });
await expect(runPhoneSetup(options, ui.io, deps)).rejects.toBeInstanceOf(SetupCancelled);
expect(options.tunnel).toBe(false);
});
});
describe("phone pairing instructions", () => {
it.each(["ios", "android"] as const)("refuses phone instructions for an unready %s route", (phone) => {
const text = phonePairingInstructions(phone, { origin: "https://maus.example", ready: false }).join("\n");
expect(text).toContain("not ready");
expect(text).not.toContain("https://maus.example");
expect(text).not.toContain("already connected");
});
it.each(["https://localhost", "https://maus.example/#code=secret", null])("never repeats a local or credential-bearing origin: %s", (origin) => {
const text = phonePairingInstructions("ios", { origin, ready: true }).join("\n");
expect(text).toContain("not ready");
expect(text).not.toContain("secret");
expect(text).not.toContain("localhost");
});
it("describes iOS browser/native options without claiming an app-store release", () => {
const text = phonePairingInstructions("ios", { origin: "https://maus.example", ready: true }).join("\n");
expect(text).toContain("Safari");
expect(text).toContain("already have");
expect(text).toContain("https://maus.example/pair");
expect(text).toContain("does not mean the phone is paired");
expect(text).toContain("not settings or pairing administration");
expect(text).not.toMatch(/apps\.apple|testflight|play\.google/);
});
it("sends an Android phone to the app first, and still offers the browser", () => {
const text = phonePairingInstructions("android", { origin: "https://maus.example", ready: true }).join("\n");
expect(text).toContain("open the OpenMausBot app and scan the QR with its pairing scanner");
expect(text).toContain("Camera will not open it in a browser");
expect(text).toContain("https://maus.example/pair");
expect(text).not.toContain("not the Android native pairing scanner");
expect(text).toContain("five minutes");
});
});