openclaw/src/gateway/server-tailscale.test.ts
2026-03-16 14:33:52 +02:00

360 lines
9.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const tailscaleState = vi.hoisted(() => ({
enableServe: vi.fn(async (_port: number) => {}),
disableServe: vi.fn(async () => {}),
enableFunnel: vi.fn(async (_port: number) => {}),
disableFunnel: vi.fn(async () => {}),
getHost: vi.fn(async () => "gateway.tailnet.ts.net"),
}));
vi.mock("../infra/tailscale.js", () => ({
enableTailscaleServe: (port: number) => tailscaleState.enableServe(port),
disableTailscaleServe: () => tailscaleState.disableServe(),
enableTailscaleFunnel: (port: number) => tailscaleState.enableFunnel(port),
disableTailscaleFunnel: () => tailscaleState.disableFunnel(),
getTailnetHostname: () => tailscaleState.getHost(),
}));
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
function createOwnerStore() {
let currentOwner: null | {
token: string;
mode: "serve" | "funnel";
port: number;
pid: number;
claimedAt: string;
phase: "active" | "cleaning";
cleanupStartedAt?: string;
alive: boolean;
} = null;
let nextPid = process.pid - 1;
return {
async claim(mode: "serve" | "funnel", port: number) {
const previousOwner = currentOwner;
const owner = {
token: `owner-${++nextPid}`,
mode,
port,
pid: nextPid,
claimedAt: new Date(0).toISOString(),
phase: "active" as const,
alive: true,
};
currentOwner = owner;
return { owner, previousOwner };
},
async replaceIfCurrent(token: string, nextOwner: typeof currentOwner | null) {
if (currentOwner?.token !== token) {
return false;
}
currentOwner = nextOwner;
return true;
},
async runCleanupIfCurrentOwner(token: string, cleanup: () => Promise<void>) {
if (!currentOwner) {
return false;
}
if (currentOwner.token !== token && currentOwner.alive) {
return false;
}
currentOwner = {
...currentOwner,
phase: "cleaning",
cleanupStartedAt: new Date(0).toISOString(),
};
await cleanup();
currentOwner = null;
return true;
},
markCurrentOwnerDead() {
if (currentOwner) {
currentOwner = { ...currentOwner, alive: false };
}
},
};
}
const modeCases = [
{
mode: "serve" as const,
enableMock: tailscaleState.enableServe,
disableMock: tailscaleState.disableServe,
},
{
mode: "funnel" as const,
enableMock: tailscaleState.enableFunnel,
disableMock: tailscaleState.disableFunnel,
},
];
describe.each(modeCases)(
"startGatewayTailscaleExposure ($mode)",
({ mode, enableMock, disableMock }) => {
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("skips stale cleanup after a newer gateway takes ownership", async () => {
const ownerStore = createOwnerStore();
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanupA = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
const cleanupB = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
await cleanupA?.();
expect(disableMock).not.toHaveBeenCalled();
expect(logTailscale.info).toHaveBeenCalledWith(
`${mode} cleanup skipped: not the current owner`,
);
await cleanupB?.();
expect(disableMock).toHaveBeenCalledTimes(1);
});
it("restores the previous live owner after a takeover startup failure", async () => {
const ownerStore = createOwnerStore();
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanupA = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
enableMock.mockRejectedValueOnce(new Error("boom"));
const cleanupB = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
expect(cleanupB).not.toBeNull();
expect(logTailscale.warn).toHaveBeenCalledWith(`${mode} failed: boom`);
await cleanupB?.();
expect(disableMock).not.toHaveBeenCalled();
expect(logTailscale.info).toHaveBeenCalledWith(
`${mode} cleanup skipped: not the current owner`,
);
await cleanupA?.();
expect(disableMock).toHaveBeenCalledTimes(1);
});
it("keeps the failed owner when the previous owner is already gone", async () => {
const ownerStore = createOwnerStore();
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanupA = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
vi.spyOn(process, "kill").mockImplementation(((pid: number) => {
if (pid === process.pid) {
const err = new Error("gone") as NodeJS.ErrnoException;
err.code = "ESRCH";
throw err;
}
return true;
}) as typeof process.kill);
enableMock.mockRejectedValueOnce(new Error("boom"));
const cleanupB = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
expect(cleanupB).not.toBeNull();
expect(logTailscale.warn).toHaveBeenCalledWith(`${mode} failed: boom`);
await cleanupA?.();
expect(disableMock).not.toHaveBeenCalled();
expect(logTailscale.info).toHaveBeenCalledWith(
`${mode} cleanup skipped: not the current owner`,
);
await cleanupB?.();
expect(disableMock).toHaveBeenCalledTimes(1);
});
it("reclaims cleanup from a dead newer owner before exit", async () => {
const ownerStore = createOwnerStore();
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanupA = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore,
});
ownerStore.markCurrentOwnerDead();
await cleanupA?.();
expect(disableMock).toHaveBeenCalledTimes(1);
expect(logTailscale.info).not.toHaveBeenCalledWith(
`${mode} cleanup skipped: not the current owner`,
);
});
it("falls back to unguarded cleanup when the ownership guard cannot claim", async () => {
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore: {
async claim() {
throw new Error("lock dir unavailable");
},
async replaceIfCurrent() {
return false;
},
async runCleanupIfCurrentOwner() {
return false;
},
},
});
expect(cleanup).not.toBeNull();
expect(enableMock).toHaveBeenCalledTimes(1);
expect(logTailscale.warn).toHaveBeenCalledWith(
`${mode} ownership guard unavailable: lock dir unavailable`,
);
await cleanup?.();
expect(disableMock).toHaveBeenCalledTimes(1);
});
it("skips unguarded fallback while a previous cleanup is still in progress", async () => {
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore: {
async claim() {
const err = new Error("busy");
err.name = "TailscaleExposureCleanupInProgressError";
throw err;
},
async replaceIfCurrent() {
return false;
},
async runCleanupIfCurrentOwner() {
return false;
},
},
});
expect(cleanup).toBeNull();
expect(enableMock).not.toHaveBeenCalled();
expect(disableMock).not.toHaveBeenCalled();
expect(logTailscale.warn).toHaveBeenCalledWith(
`${mode} ownership cleanup still in progress; skipping external exposure`,
);
});
it("falls back to a direct reset when guarded cleanup bookkeeping fails", async () => {
const logTailscale = {
info: vi.fn(),
warn: vi.fn(),
};
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: mode,
resetOnExit: true,
port: 18789,
logTailscale,
ownerStore: {
async claim() {
return {
owner: {
token: "owner-1",
mode,
port: 18789,
pid: process.pid,
claimedAt: new Date(0).toISOString(),
phase: "active" as const,
},
previousOwner: null,
};
},
async replaceIfCurrent() {
return true;
},
async runCleanupIfCurrentOwner() {
throw new Error("lock dir unavailable");
},
},
});
await cleanup?.();
expect(disableMock).toHaveBeenCalledTimes(1);
expect(logTailscale.warn).toHaveBeenCalledWith(
`${mode} cleanup failed: lock dir unavailable`,
);
expect(logTailscale.warn).toHaveBeenCalledWith(
`${mode} cleanup guard failed; applied direct reset fallback`,
);
});
},
);