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:
Fusion
2026-04-26 06:35:46 -07:00
committed by gsxdsm
parent 559e908865
commit f09346a9da
13 changed files with 815 additions and 19 deletions

View File

@@ -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,

View File

@@ -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");
});
});

View File

@@ -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);