Path: blob/main/src/vs/platform/agentHost/test/electron-browser/sshRemoteAgentHostService.test.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { DeferredPromise } from '../../../../base/common/async.js';7import { Emitter, Event } from '../../../../base/common/event.js';8import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';9import { URI } from '../../../../base/common/uri.js';10import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js';11import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';12import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js';13import { ILogService, NullLogService } from '../../../log/common/log.js';14import { IConfigurationService } from '../../../configuration/common/configuration.js';15import { IInstantiationService } from '../../../instantiation/common/instantiation.js';16import { ISharedProcessService } from '../../../ipc/electron-browser/services.js';17import { IRemoteAgentHostService } from '../../common/remoteAgentHostService.js';18import type { IAgentConnection } from '../../common/agentService.js';19import type {20ISSHAgentHostConfig,21ISSHConnectResult,22ISSHRelayMessage,23ISSHResolvedConfig,24} from '../../common/sshRemoteAgentHost.js';25import { SSHRemoteAgentHostService } from '../../electron-browser/sshRemoteAgentHostServiceImpl.js';2627/**28* In-renderer mock of the shared-process SSH service. Exposes the same29* surface that the renderer accesses through ProxyChannel, plus a small30* test API to drive close events and inspect calls.31*/32class MockSSHMainService {33private readonly _onDidChangeConnections = new Emitter<void>();34readonly onDidChangeConnections = this._onDidChangeConnections.event;3536private readonly _onDidCloseConnection = new Emitter<string>();37readonly onDidCloseConnection = this._onDidCloseConnection.event;3839private readonly _onDidReportConnectProgress = new Emitter<{ connectionKey: string; message: string }>();40readonly onDidReportConnectProgress = this._onDidReportConnectProgress.event;4142private readonly _onDidRelayMessage = new Emitter<ISSHRelayMessage>();43readonly onDidRelayMessage = this._onDidRelayMessage.event;4445private readonly _onDidRelayClose = new Emitter<string>();46readonly onDidRelayClose = this._onDidRelayClose.event;4748readonly disconnectCalls: string[] = [];49private _nextConnectionId = 1;5051connectResult: Partial<ISSHConnectResult> | undefined;5253async connect(config: ISSHAgentHostConfig): Promise<ISSHConnectResult> {54const connectionId = this.connectResult?.connectionId ?? `conn-${this._nextConnectionId++}`;55return {56connectionId,57address: this.connectResult?.address ?? `ssh:${config.host}`,58name: config.name,59connectionToken: 'test-token',60config: { host: config.host, username: config.username, authMethod: config.authMethod, name: config.name, sshConfigHost: config.sshConfigHost },61sshConfigHost: config.sshConfigHost,62};63}6465async reconnect(sshConfigHost: string, name: string): Promise<ISSHConnectResult> {66return {67connectionId: `conn-${this._nextConnectionId++}`,68address: `ssh:${sshConfigHost}`,69name,70connectionToken: 'test-token',71config: { host: sshConfigHost, username: 'u', authMethod: 0 as never, name, sshConfigHost },72sshConfigHost,73};74}7576async relaySend(_connectionId: string, _message: string): Promise<void> { /* no-op */ }7778async disconnect(connectionId: string): Promise<void> {79this.disconnectCalls.push(connectionId);80}8182async listSSHConfigHosts(): Promise<string[]> { return []; }83async ensureUserSSHConfig(): Promise<URI> { return URI.file('/tmp/ssh-config'); }84async listSSHConfigFiles(): Promise<URI[]> { return [URI.file('/tmp/ssh-config')]; }85async resolveSSHConfig(_host: string): Promise<ISSHResolvedConfig> {86return { hostname: '', user: undefined, port: 22, identityFile: [], forwardAgent: false };87}8889dispose(): void {90this._onDidChangeConnections.dispose();91this._onDidCloseConnection.dispose();92this._onDidReportConnectProgress.dispose();93this._onDidRelayMessage.dispose();94this._onDidRelayClose.dispose();95}96}9798/** Adapt a mock service object to the IChannel surface ProxyChannel expects. */99function asChannel(target: object): IChannel {100return {101call: async <T>(method: string, args?: unknown): Promise<T> => {102const fn = (target as Record<string, unknown>)[method];103if (typeof fn !== 'function') {104throw new Error(`MockChannel: no method ${method}`);105}106return (fn as (...a: unknown[]) => Promise<T>).apply(target, (args as unknown[]) ?? []);107},108listen: <T>(event: string): Event<T> => {109const ev = (target as Record<string, unknown>)[event];110if (typeof ev !== 'function') {111throw new Error(`MockChannel: no event ${event}`);112}113return ev as Event<T>;114},115};116}117118/** Captures addManagedConnection calls so tests can inspect transportDisposable. */119class MockRemoteAgentHostService extends Disposable {120readonly added: Array<{ address: string; transport?: IDisposable }> = [];121private readonly _entries = new Map<string, { transport?: IDisposable; client: { dispose?: () => void } }>();122123async addManagedConnection(entry: { name: string; connection: { address?: string; sshConfigHost?: string } }, client: IAgentConnection, transportDisposable?: IDisposable): Promise<unknown> {124const address = entry.connection.address ?? `ssh:${entry.connection.sshConfigHost}`;125this.added.push({ address, transport: transportDisposable });126this._entries.set(address, { client: client as { dispose?: () => void }, transport: transportDisposable });127return { address, name: entry.name, clientId: 'mock', defaultDirectory: undefined, status: 0 };128}129130/** Simulate user clicking "Remove Remote": disposes the per-entry store, which runs the transport disposable. */131removeEntry(address: string): void {132const e = this._entries.get(address);133if (!e) {134return;135}136this._entries.delete(address);137e.client.dispose?.();138e.transport?.dispose();139}140141override dispose(): void {142// Dispose any still-registered entries (mirrors the per-entry store cleanup143// done by the real RemoteAgentHostService when it itself is disposed).144for (const [, e] of this._entries) {145e.client.dispose?.();146e.transport?.dispose();147}148this._entries.clear();149super.dispose();150}151}152153class MockProtocolClient extends Disposable {154readonly clientId = 'mock-protocol-client';155readonly onDidClose = Event.None;156readonly onDidAction = Event.None;157readonly onDidNotification = Event.None;158readonly connectDeferred = new DeferredPromise<void>();159async connect(): Promise<void> { return this.connectDeferred.p; }160registerOwned<T extends IDisposable>(d: T): T { return this._register(d); }161}162163class TestConfigurationService {164readonly onDidChangeConfiguration = Event.None;165getValue(): unknown { return undefined; }166}167168suite('SSHRemoteAgentHostService (renderer)', () => {169170const disposables = new DisposableStore();171let mainService: MockSSHMainService;172let remoteAgentHostService: MockRemoteAgentHostService;173let createdClients: MockProtocolClient[];174let waitForClient: (index: number) => Promise<MockProtocolClient>;175let service: SSHRemoteAgentHostService;176177setup(() => {178mainService = new MockSSHMainService();179disposables.add({ dispose: () => mainService.dispose() });180remoteAgentHostService = disposables.add(new MockRemoteAgentHostService());181createdClients = [];182183const sharedProcessService: Partial<ISharedProcessService> = {184getChannel: () => asChannel(mainService),185};186187const instantiationService = disposables.add(new TestInstantiationService());188instantiationService.stub(ILogService, new NullLogService());189instantiationService.stub(IConfigurationService, new TestConfigurationService() as Partial<IConfigurationService>);190instantiationService.stub(ISharedProcessService, sharedProcessService as ISharedProcessService);191instantiationService.stub(IRemoteAgentHostService, remoteAgentHostService as Partial<IRemoteAgentHostService>);192193const clientWaiters: DeferredPromise<MockProtocolClient>[] = [];194waitForClient = (index: number): Promise<MockProtocolClient> => {195if (createdClients[index]) {196return Promise.resolve(createdClients[index]);197}198return (clientWaiters[index] ??= new DeferredPromise<MockProtocolClient>()).p;199};200201const inner: Partial<IInstantiationService> = {202createInstance: (_ctor: unknown, ...args: unknown[]) => {203const c = new MockProtocolClient();204// The real RemoteAgentHostProtocolClient owns the transport disposable205// it's constructed with; mirror that here so SSHRelayTransport doesn't leak.206const transport = args[1] as IDisposable | undefined;207if (transport) {208c.registerOwned(transport);209}210disposables.add(c);211const index = createdClients.length;212createdClients.push(c);213clientWaiters[index]?.complete(c);214return c;215},216};217instantiationService.stub(IInstantiationService, inner as Partial<IInstantiationService>);218219service = disposables.add(instantiationService.createInstance(SSHRemoteAgentHostService));220});221222teardown(() => disposables.clear());223ensureNoDisposablesAreLeakedInTestSuite();224225const sampleConfig: ISSHAgentHostConfig = {226host: 'remote.example',227username: 'user',228authMethod: 0 as never,229name: 'My Remote',230sshConfigHost: 'remote.example',231};232233/** Wait until the renderer has created its protocol client, then resolve its handshake. */234async function awaitClientThenResolve(index: number): Promise<void> {235const client = await waitForClient(index);236client.connectDeferred.complete();237}238239test('connect registers a managed connection with a transport disposable', async () => {240const connectPromise = service.connect(sampleConfig);241await awaitClientThenResolve(0);242const handle = await connectPromise;243244assert.strictEqual(remoteAgentHostService.added.length, 1);245assert.strictEqual(remoteAgentHostService.added[0].address, 'ssh:remote.example');246assert.ok(remoteAgentHostService.added[0].transport, 'a transport disposable is passed so removal can tear down the SSH tunnel');247assert.strictEqual(service.connections.length, 1);248assert.strictEqual(handle.localAddress, 'ssh:remote.example');249});250251test('removing the entry tears down the SSH tunnel and the renderer-side handle', async () => {252const connectPromise = service.connect(sampleConfig);253await awaitClientThenResolve(0);254await connectPromise;255256assert.strictEqual(mainService.disconnectCalls.length, 0);257assert.strictEqual(service.connections.length, 1);258259// Simulate the user clicking "Remove Remote": IRemoteAgentHostService260// disposes the per-entry store, which runs our transport disposable.261remoteAgentHostService.removeEntry('ssh:remote.example');262263assert.deepStrictEqual(mainService.disconnectCalls, ['conn-1'], 'main-process tunnel is told to disconnect');264assert.strictEqual(service.connections.length, 0, 'renderer-side handle is dropped');265});266267test('connect after removal does not reuse the previous handle', async () => {268// First connect → entry registered, then removed.269const c1 = service.connect(sampleConfig);270await awaitClientThenResolve(0);271await c1;272remoteAgentHostService.removeEntry('ssh:remote.example');273assert.strictEqual(service.connections.length, 0);274275// Second connect → main returns a new connectionId; renderer creates276// a fresh handle and registers a new managed entry.277mainService.connectResult = { connectionId: 'conn-2', address: 'ssh:remote.example' };278const c2 = service.connect(sampleConfig);279await awaitClientThenResolve(1);280await c2;281282assert.strictEqual(service.connections.length, 1);283assert.strictEqual(remoteAgentHostService.added.length, 2, 'each connect produces a fresh managed-connection registration');284});285286test('main-process onDidCloseConnection cleans up renderer handle without double-disconnecting', async () => {287const connectPromise = service.connect(sampleConfig);288await awaitClientThenResolve(0);289await connectPromise;290assert.strictEqual(service.connections.length, 1);291292// Simulate main process closing the connection on its own (e.g. SSH dropped).293// We can't directly fire on the wrapped emitter through the channel because294// ProxyChannel is one-directional; instead we trigger via the mock service295// emitter that the renderer subscribed to.296(mainService as unknown as { _onDidCloseConnection: Emitter<string> })._onDidCloseConnection.fire('conn-1');297298assert.strictEqual(service.connections.length, 0, 'handle dropped on main close');299// Removing the (already-gone) entry shouldn't trigger another disconnect call.300remoteAgentHostService.removeEntry('ssh:remote.example');301// One disconnect from the transport disposable is fine; we just want to make302// sure we're not at risk of issuing a second one against a stale id.303assert.ok(mainService.disconnectCalls.length <= 1, 'no duplicate disconnect against a stale connectionId');304});305});306307308