import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" ;
import { captureEnv } from "../test-utils/env.js" ;
import * as tailscale from "./tailscale.js" ;
const {
ensureGoInstalled,
ensureTailscaledInstalled,
getTailnetHostname,
getTestTailscaleBinaryOverride,
enableTailscaleServe,
disableTailscaleServe,
ensureFunnel,
} = tailscale;
const tailscaleBin = expect.stringMatching(/tailscale$/i);
function createRuntimeWithExitError() {
return {
error: vi.fn(),
log: vi.fn(),
exit: ((code: number) => {
throw new Error(`exit ${code}`);
}) as (code: number) => never,
};
}
function expectServeFallbackCommand(params: { callArgs: string[]; sudoArgs: string[] }) {
return [
[tailscaleBin, expect.arrayContaining(params.callArgs)],
["sudo" , expect.arrayContaining(["-n" , tailscaleBin, ...params.sudoArgs])],
];
}
describe("tailscale helpers" , () => {
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_TEST_TAILSCALE_BINARY" , "NODE_ENV" , "VITEST" ]);
process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "tailscale" ;
process.env.VITEST ??= "true" ;
});
afterEach(() => {
envSnapshot.restore();
vi.restoreAllMocks();
});
it("parses DNS name from tailscale status" , async () => {
const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({
Self: { DNSName: "host.tailnet.ts.net." , TailscaleIPs: ["100.1.1.1" ] },
}),
});
const host = await getTailnetHostname(exec);
expect(host).toBe("host.tailnet.ts.net" );
});
it("falls back to IP when DNS missing" , async () => {
const exec = vi.fn().mockResolvedValue({
stdout: JSON.stringify({ Self: { TailscaleIPs: ["100.2.2.2" ] } }),
});
const host = await getTailnetHostname(exec);
expect(host).toBe("100.2.2.2" );
});
it("parses noisy JSON output from tailscale status" , async () => {
const exec = vi.fn().mockResolvedValue({
stdout:
'warning: stale state\n{"Self":{"DNSName":"noisy.tailnet.ts.net.","TailscaleIPs":["100.9.9.9"]}}\n' ,
});
const host = await getTailnetHostname(exec);
expect(host).toBe("noisy.tailnet.ts.net" );
});
it("allows the test binary override in explicit test environments" , () => {
process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "/tmp/test-tailscale" ;
process.env.NODE_ENV = "test" ;
delete process.env.VITEST;
expect(getTestTailscaleBinaryOverride()).toBe("/tmp/test-tailscale" );
});
it("ignores the test binary override outside test environments" , () => {
process.env.OPENCLAW_TEST_TAILSCALE_BINARY = "/tmp/attacker-tailscale" ;
process.env.NODE_ENV = "production" ;
delete process.env.VITEST;
expect(getTestTailscaleBinaryOverride()).toBeNull();
});
it.each([
{
name: "ensureGoInstalled installs when missing and user agrees" ,
fn: ensureGoInstalled,
missingError: new Error("no go" ),
installCommand: ["brew" , ["install" , "go" ]] as const ,
promptResult: true ,
},
{
name: "ensureTailscaledInstalled installs when missing and user agrees" ,
fn: ensureTailscaledInstalled,
missingError: new Error("missing" ),
installCommand: ["brew" , ["install" , "tailscale" ]] as const ,
promptResult: true ,
},
])("$name" , async ({ fn, missingError, installCommand, promptResult }) => {
const exec = vi.fn().mockRejectedValueOnce(missingError).mockResolvedValue({});
const prompt = vi.fn().mockResolvedValue(promptResult);
const runtime = createRuntimeWithExitError();
await fn(exec as never, prompt, runtime);
expect(exec).toHaveBeenCalledWith(installCommand[0 ], installCommand[1 ]);
});
it.each([
{
name: "ensureGoInstalled exits when missing and user declines install" ,
fn: ensureGoInstalled,
missingError: new Error("no go" ),
errorMessage: "Go is required to build tailscaled from source. Aborting." ,
},
{
name: "ensureTailscaledInstalled exits when missing and user declines install" ,
fn: ensureTailscaledInstalled,
missingError: new Error("missing" ),
errorMessage: "tailscaled is required for user-space funnel. Aborting." ,
},
])("$name" , async ({ fn, missingError, errorMessage }) => {
const exec = vi.fn().mockRejectedValueOnce(missingError);
const prompt = vi.fn().mockResolvedValue(false );
const runtime = createRuntimeWithExitError();
await expect(fn(exec as never, prompt, runtime)).rejects.toThrow("exit 1" );
expect(runtime.error).toHaveBeenCalledWith(errorMessage);
expect(exec).toHaveBeenCalledTimes(1 );
});
it("enableTailscaleServe attempts normal first, then sudo" , async () => {
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("permission denied" ))
.mockResolvedValueOnce({ stdout: "" });
await enableTailscaleServe(3000 , exec as never);
const [firstCall, secondCall] = expectServeFallbackCommand({
callArgs: ["serve" , "--bg" , "--yes" , "3000" ],
sudoArgs: ["serve" , "--bg" , "--yes" , "3000" ],
});
expect(exec).toHaveBeenNthCalledWith(1 , firstCall[0 ], firstCall[1 ], expect.any(Object));
expect(exec).toHaveBeenNthCalledWith(2 , secondCall[0 ], secondCall[1 ], expect.any(Object));
});
it("enableTailscaleServe does NOT use sudo if first attempt succeeds" , async () => {
const exec = vi.fn().mockResolvedValue({ stdout: "" });
await enableTailscaleServe(3000 , exec as never);
expect(exec).toHaveBeenCalledTimes(1 );
expect(exec).toHaveBeenCalledWith(
tailscaleBin,
expect.arrayContaining(["serve" , "--bg" , "--yes" , "3000" ]),
expect.any(Object),
);
});
it("disableTailscaleServe uses fallback" , async () => {
const exec = vi
.fn()
.mockRejectedValueOnce(new Error("permission denied" ))
.mockResolvedValueOnce({ stdout: "" });
await disableTailscaleServe(exec as never);
expect(exec).toHaveBeenCalledTimes(2 );
expect(exec).toHaveBeenNthCalledWith(
2 ,
"sudo" ,
expect.arrayContaining(["-n" , tailscaleBin, "serve" , "reset" ]),
expect.any(Object),
);
});
it("ensureFunnel uses fallback for enabling" , async () => {
const exec = vi
.fn()
.mockResolvedValueOnce({ stdout: JSON.stringify({ BackendState: "Running" }) }) // status
.mockRejectedValueOnce(new Error("permission denied" )) // enable normal
.mockResolvedValueOnce({ stdout: "" }); // enable sudo
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);
expect(exec).toHaveBeenNthCalledWith(
1 ,
tailscaleBin,
expect.arrayContaining(["funnel" , "status" , "--json" ]),
);
expect(exec).toHaveBeenNthCalledWith(
2 ,
tailscaleBin,
expect.arrayContaining(["funnel" , "--yes" , "--bg" , "8080" ]),
expect.any(Object),
);
expect(exec).toHaveBeenNthCalledWith(
3 ,
"sudo" ,
expect.arrayContaining(["-n" , tailscaleBin, "funnel" , "--yes" , "--bg" , "8080" ]),
expect.any(Object),
);
});
it("enableTailscaleServe skips sudo on non-permission errors" , async () => {
const exec = vi.fn().mockRejectedValueOnce(new Error("boom" ));
await expect(enableTailscaleServe(3000 , exec as never)).rejects.toThrow("boom" );
expect(exec).toHaveBeenCalledTimes(1 );
});
it("enableTailscaleServe rethrows original error if sudo fails" , async () => {
const originalError = Object.assign(new Error("permission denied" ), {
stderr: "permission denied" ,
});
const exec = vi
.fn()
.mockRejectedValueOnce(originalError)
.mockRejectedValueOnce(new Error("sudo: a password is required" ));
await expect(enableTailscaleServe(3000 , exec as never)).rejects.toBe(originalError);
expect(exec).toHaveBeenCalledTimes(2 );
});
});
Messung V0.5 in Prozent C=99 H=100 G=99
¤ Dauer der Verarbeitung: 0.13 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland