fix(FN-2525): harden remote access auth and tunnel regression coverage
- Add regression tests across CLI, core, dashboard, and engine for remote access auth, settings parity, and serve/TUI callback wiring - Expand dashboard route and modal coverage for remote settings/auth flows including node environment behaviors - Redact provider-switch failure details in tunnel process manager to avoid leaking sensitive provider diagnostics - Update route registration and engine lifecycle tests to lock in remote-access behavior under real execution paths
This commit is contained in:
@@ -486,6 +486,75 @@ describe("ProjectEngine remote lifecycle restore policy", () => {
|
||||
startSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("persists shutdown lifecycle markers and deterministically restores on next engine start", async () => {
|
||||
const restoreSettings = {
|
||||
...baseSettings,
|
||||
remoteAccess: {
|
||||
...baseRemoteAccess,
|
||||
activeProvider: "cloudflare" as const,
|
||||
lifecycle: {
|
||||
...baseRemoteAccess.lifecycle,
|
||||
rememberLastRunning: true,
|
||||
wasRunningOnShutdown: false,
|
||||
lastRunningProvider: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
const mockStore = createMockStore(restoreSettings);
|
||||
mocks.currentStore = mockStore.store;
|
||||
|
||||
const startSpy = vi.spyOn(TunnelProcessManager.prototype, "start").mockResolvedValue(undefined);
|
||||
const stopSpy = vi.spyOn(TunnelProcessManager.prototype, "stop").mockResolvedValue(undefined);
|
||||
const getStatusSpy = vi.spyOn(TunnelProcessManager.prototype, "getStatus")
|
||||
.mockReturnValueOnce({
|
||||
provider: "cloudflare",
|
||||
state: "running",
|
||||
pid: 4321,
|
||||
startedAt: "2026-04-26T12:00:00.000Z",
|
||||
stoppedAt: null,
|
||||
url: "https://remote.example.com",
|
||||
lastError: null,
|
||||
})
|
||||
.mockReturnValue({
|
||||
provider: null,
|
||||
state: "stopped",
|
||||
pid: null,
|
||||
startedAt: null,
|
||||
stoppedAt: "2026-04-26T12:05:00.000Z",
|
||||
url: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
const firstEngine = createEngine();
|
||||
await firstEngine.start();
|
||||
await firstEngine.stop();
|
||||
|
||||
const persistedSettings = mockStore.getCurrentSettings() as {
|
||||
remoteAccess?: { lifecycle?: { wasRunningOnShutdown?: boolean; lastRunningProvider?: string | null } };
|
||||
};
|
||||
expect(persistedSettings.remoteAccess?.lifecycle).toMatchObject({
|
||||
wasRunningOnShutdown: true,
|
||||
lastRunningProvider: "cloudflare",
|
||||
});
|
||||
|
||||
const secondEngine = createEngine();
|
||||
await secondEngine.start();
|
||||
|
||||
expect(startSpy).toHaveBeenCalled();
|
||||
expect(secondEngine.getRemoteTunnelRestoreDiagnostics()).toMatchObject({
|
||||
outcome: "applied",
|
||||
reason: "restore_started",
|
||||
provider: "cloudflare",
|
||||
});
|
||||
|
||||
await secondEngine.stop();
|
||||
expect(stopSpy).toHaveBeenCalled();
|
||||
|
||||
startSpy.mockRestore();
|
||||
stopSpy.mockRestore();
|
||||
getStatusSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("reconciles stale persisted running marker to avoid restore loops", async () => {
|
||||
const restoreSettings = {
|
||||
...baseSettings,
|
||||
|
||||
@@ -107,6 +107,44 @@ describe("TunnelProcessManager", () => {
|
||||
expect(allLogs).not.toContain("secret-token");
|
||||
});
|
||||
|
||||
it("transitions start→running and stop→stopped, with idempotent repeated stop", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
|
||||
|
||||
await manager.start("cloudflare", cloudflareConfig());
|
||||
const child = [...children.values()][0];
|
||||
child.emitStdout("Tunnel ready https://demo.trycloudflare.com");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getStatus().state).toBe("running");
|
||||
});
|
||||
|
||||
await manager.stop();
|
||||
expect(manager.getStatus().state).toBe("stopped");
|
||||
|
||||
// Idempotent: repeated stop keeps manager in a deterministic stopped state.
|
||||
await manager.stop();
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
provider: null,
|
||||
state: "stopped",
|
||||
pid: null,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
expect(states).toContain("starting");
|
||||
expect(states).toContain("running");
|
||||
expect(states).toContain("stopping");
|
||||
expect(states).toContain("stopped");
|
||||
});
|
||||
|
||||
it("falls back to SIGKILL when graceful stop times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -146,7 +184,47 @@ describe("TunnelProcessManager", () => {
|
||||
expect(manager.getStatus().state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("switchProvider stops active provider before emitting switch_failed on target start failure", async () => {
|
||||
it("switchProvider stops active provider before starting target provider", async () => {
|
||||
const order: string[] = [];
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: () => {
|
||||
order.push("spawn");
|
||||
const child = new FakeChildProcess(++pid);
|
||||
children.set(child.pid, child);
|
||||
return child as never;
|
||||
},
|
||||
});
|
||||
|
||||
manager.subscribeStatus((snapshot) => order.push(`state:${snapshot.state}`));
|
||||
|
||||
await manager.start("tailscale", {
|
||||
provider: "tailscale",
|
||||
executablePath: "tailscale",
|
||||
args: ["serve", "status"],
|
||||
});
|
||||
const initialChild = [...children.values()][0];
|
||||
initialChild.emitStdout("Serve started https://machine.ts.net");
|
||||
await vi.waitFor(() => expect(manager.getStatus().state).toBe("running"));
|
||||
|
||||
const switchPromise = manager.switchProvider("cloudflare", cloudflareConfig());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processKillSpy).toHaveBeenCalledWith(expect.any(Number), "SIGTERM");
|
||||
});
|
||||
|
||||
const cloudflareChild = [...children.values()].at(-1);
|
||||
cloudflareChild?.emitStdout("Connected https://demo.trycloudflare.com");
|
||||
await switchPromise;
|
||||
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
state: "running",
|
||||
provider: "cloudflare",
|
||||
url: "https://demo.trycloudflare.com",
|
||||
});
|
||||
expect(order.indexOf("state:stopping")).toBeLessThan(order.lastIndexOf("spawn"));
|
||||
});
|
||||
|
||||
it("switchProvider failure is rollback-safe and never leaks raw token values", async () => {
|
||||
const manager = new TunnelProcessManager({
|
||||
spawnImpl: vi
|
||||
.fn()
|
||||
@@ -156,12 +234,14 @@ describe("TunnelProcessManager", () => {
|
||||
return child as never;
|
||||
})
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("cloudflare launcher boom");
|
||||
throw new Error("cloudflare launcher boom token=secret-token");
|
||||
}),
|
||||
});
|
||||
|
||||
const states: string[] = [];
|
||||
const logs: string[] = [];
|
||||
manager.subscribeStatus((snapshot) => states.push(snapshot.state));
|
||||
manager.subscribeLogs((entry) => logs.push(entry.message));
|
||||
|
||||
await manager.start("tailscale", {
|
||||
provider: "tailscale",
|
||||
@@ -182,5 +262,8 @@ describe("TunnelProcessManager", () => {
|
||||
expect(finalStatus.provider).toBe("cloudflare");
|
||||
expect(states).toContain("stopping");
|
||||
expect(states).toContain("stopped");
|
||||
|
||||
const logText = logs.join("\n");
|
||||
expect(logText).not.toContain("secret-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,6 +171,7 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
await this.startInternal(target, config);
|
||||
} catch (error) {
|
||||
const stateError = toStateError("switch_failed", error);
|
||||
const redactedMessage = this.redactForProviderConfig(target, config, stateError.message);
|
||||
this.updateStatus({
|
||||
provider: target,
|
||||
state: "failed",
|
||||
@@ -178,14 +179,27 @@ export class TunnelProcessManager extends EventEmitter implements TunnelManager
|
||||
startedAt: null,
|
||||
stoppedAt: nowIso(),
|
||||
url: null,
|
||||
lastError: stateError,
|
||||
lastError: {
|
||||
...stateError,
|
||||
message: redactedMessage,
|
||||
},
|
||||
});
|
||||
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${stateError.message}`);
|
||||
this.emitLog("error", "manager", `Provider switch failed (${previousProvider ?? "none"} -> ${target}): ${redactedMessage}`);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private redactForProviderConfig(provider: TunnelProvider, config: TunnelProviderConfig, message: string): string {
|
||||
try {
|
||||
const adapter = getTunnelProviderAdapter(provider);
|
||||
const command = adapter.buildCommand(config);
|
||||
return redactTunnelText(message, command.sensitiveValues);
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
private async runExclusive(operation: () => Promise<void>): Promise<void> {
|
||||
const next = this.operationChain.then(operation);
|
||||
this.operationChain = next.catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user