diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index cb8d0be4e..5fb6ff06d 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from "vitest"; -import { ensureGoInstalled, ensureTailscaledInstalled, getTailnetHostname } from "./tailscale.js"; +import { + ensureGoInstalled, + ensureTailscaledInstalled, + getTailnetHostname, + enableTailscaleServe, + disableTailscaleServe, + enableTailscaleFunnel, + disableTailscaleFunnel, + ensureFunnel +} from "./tailscale.js"; describe("tailscale helpers", () => { it("parses DNS name from tailscale status", async () => { @@ -48,4 +57,75 @@ describe("tailscale helpers", () => { await ensureTailscaledInstalled(exec as never, prompt, runtime); expect(exec).toHaveBeenCalledWith("brew", ["install", "tailscale"]); }); + + it("enableTailscaleServe uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await enableTailscaleServe(3000, exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "--bg", "--yes", "3000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleServe uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await disableTailscaleServe(exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "serve", "reset"]), + expect.any(Object) + ); + }); + + it("enableTailscaleFunnel uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await enableTailscaleFunnel(4000, exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--bg", "--yes", "4000"]), + expect.any(Object) + ); + }); + + it("disableTailscaleFunnel uses sudo", async () => { + const exec = vi.fn().mockResolvedValue({ stdout: "" }); + await disableTailscaleFunnel(exec as never); + expect(exec).toHaveBeenCalledWith( + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "reset"]), + expect.any(Object) + ); + }); + + it("ensureFunnel uses sudo for enabling", async () => { + // Mock exec: first call is status (not sudo), second call is enable (sudo) + const exec = vi.fn() + .mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status + .mockResolvedValueOnce({ stdout: "" }); // enable + + const runtime = { + error: vi.fn(), + log: vi.fn(), + exit: vi.fn() as unknown as (code: number) => never, + }; + const prompt = vi.fn(); + + await ensureFunnel(8080, exec as never, runtime, prompt); + + // First call: check status (no sudo) + expect(exec).toHaveBeenNthCalledWith( + 1, + "tailscale", + expect.arrayContaining(["funnel", "status", "--json"]) + ); + + // Second call: enable (sudo) + expect(exec).toHaveBeenNthCalledWith( + 2, + "sudo", + expect.arrayContaining(["-n", "tailscale", "funnel", "--yes", "--bg", "8080"]), + expect.any(Object) + ); + }); }); diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 58d7b3f93..ca2faf8d5 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -237,7 +237,7 @@ export async function ensureFunnel( } logVerbose(`Enabling funnel on port ${port}…`); - const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], { + const { stdout } = await exec("sudo", ["-n", tailscaleBin, "funnel", "--yes", "--bg", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -288,7 +288,7 @@ export async function ensureFunnel( export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { + await exec("sudo", ["-n", tailscaleBin, "serve", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -296,7 +296,7 @@ export async function enableTailscaleServe(port: number, exec: typeof runExec = export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["serve", "reset"], { + await exec("sudo", ["-n", tailscaleBin, "serve", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -304,7 +304,7 @@ export async function disableTailscaleServe(exec: typeof runExec = runExec) { export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { + await exec("sudo", ["-n", tailscaleBin, "funnel", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); @@ -312,7 +312,7 @@ export async function enableTailscaleFunnel(port: number, exec: typeof runExec = export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); - await exec(tailscaleBin, ["funnel", "reset"], { + await exec("sudo", ["-n", tailscaleBin, "funnel", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, });