Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions src/test/suite/views/dataBrowsingController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ suite('DataBrowsingController Test Suite', function () {
let mockConnectionController: {
getActiveDataService: SinonStub;
getMongoClientConnectionOptions: SinonStub;
getActiveConnectionId: SinonStub;
};
let mockServiceProvider: {
find: SinonStub;
Expand Down Expand Up @@ -97,6 +98,7 @@ suite('DataBrowsingController Test Suite', function () {
getMongoClientConnectionOptions: sandbox
.stub()
.returns({ url: 'mongodb://localhost:27017', options: {} }),
getActiveConnectionId: sandbox.stub().returns('test-connection-id'),
};
mockExplorerController = {
refresh: sandbox.stub().returns(true),
Expand All @@ -108,6 +110,9 @@ suite('DataBrowsingController Test Suite', function () {
telemetryService: { track: trackStub } as any,
});
mockPanel = createMockPanel();
// Register the panel as belonging to the current active connection
// so that handleWebviewMessage connection validation passes.
testController._panelConnectionIds.set(mockPanel, 'test-connection-id');
});

afterEach(() => {
Expand Down Expand Up @@ -523,6 +528,150 @@ suite('DataBrowsingController Test Suite', function () {
});
});

suite('handleWebviewMessage connection mismatch (VSCODE-770)', function () {
function switchConnection(): void {
// Simulate switching to a different connection.
mockConnectionController.getActiveConnectionId.returns(
'different-connection-id',
);
}

test('sends getDocumentError when connection changed and getDocuments requested', async function () {
switchConnection();
const options = createMockOptions();

await testController.handleWebviewMessage(
{ command: PreviewMessageType.getDocuments, skip: 0, limit: 10 },
mockPanel,
options,
);

expect(postMessageStub.calledOnce).to.be.true;
const message = postMessageStub.firstCall.args[0];
expect(message.command).to.equal(PreviewMessageType.getDocumentError);
expect(message.error).to.include('no longer active');
});

test('sends updateTotalCountError when connection changed and getTotalCount requested', async function () {
switchConnection();
const options = createMockOptions();

await testController.handleWebviewMessage(
{ command: PreviewMessageType.getTotalCount },
mockPanel,
options,
);

expect(postMessageStub.calledOnce).to.be.true;
const message = postMessageStub.firstCall.args[0];
expect(message.command).to.equal(
PreviewMessageType.updateTotalCountError,
);
expect(message.error).to.include('no longer active');
});

test('does not post any message when connection changed and cancelRequest received', async function () {
switchConnection();
const options = createMockOptions();

await testController.handleWebviewMessage(
{ command: PreviewMessageType.cancelRequest },
mockPanel,
options,
);

expect(postMessageStub.called).to.be.false;
});
Comment thread
tculig marked this conversation as resolved.
Outdated

test('shows vscode error when connection changed and editDocument requested', async function () {
switchConnection();
const options = createMockOptions();
const showErrorStub = sandbox
.stub(vscode.window, 'showErrorMessage')
.resolves();

await testController.handleWebviewMessage(
{
command: PreviewMessageType.editDocument,
documentId: { $oid: '123' },
},
mockPanel,
options,
);

expect(postMessageStub.called).to.be.false;
expect(showErrorStub.calledOnce).to.be.true;
expect(showErrorStub.firstCall.args[0]).to.include('no longer active');
});

test('shows vscode error when connection changed and insertDocument requested', async function () {
switchConnection();
const options = createMockOptions();
const showErrorStub = sandbox
.stub(vscode.window, 'showErrorMessage')
.resolves();

await testController.handleWebviewMessage(
{ command: PreviewMessageType.insertDocument },
mockPanel,
options,
);

expect(postMessageStub.called).to.be.false;
expect(showErrorStub.calledOnce).to.be.true;
expect(showErrorStub.firstCall.args[0]).to.include('no longer active');
});

test('still sends theme colors even when connection changed', async function () {
switchConnection();
const options = createMockOptions();

await testController.handleWebviewMessage(
{ command: PreviewMessageType.getThemeColors },
mockPanel,
options,
);

expect(postMessageStub.calledOnce).to.be.true;
const message = postMessageStub.firstCall.args[0];
expect(message.command).to.equal(PreviewMessageType.updateThemeColors);
});

test('does not fetch documents when connection changed', async function () {
switchConnection();
const options = createMockOptions();
const handleGetDocumentsSpy = sandbox.spy(
testController,
'handleGetDocuments',
);

await testController.handleWebviewMessage(
{ command: PreviewMessageType.getDocuments, skip: 0, limit: 10 },
mockPanel,
options,
);

expect(handleGetDocumentsSpy.called).to.be.false;
});

test('allows requests when panel connection matches active connection', async function () {
// Don't switch — connection still matches.
const options = createMockOptions();
const handleGetDocumentsSpy = sandbox.spy(
testController,
'handleGetDocuments',
);

await testController.handleWebviewMessage(
{ command: PreviewMessageType.getDocuments, skip: 0, limit: 10 },
mockPanel,
options,
);

expect(handleGetDocumentsSpy.calledOnce).to.be.true;
});
});

suite('handleGetDocuments with pagination', function () {
test('posts loadPage message with documents on pagination', async function () {
mockCursor.toArray = sandbox.stub().resolves([
Expand Down
61 changes: 58 additions & 3 deletions src/views/dataBrowsingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,8 @@ export default class DataBrowsingController {
_panelAbortControllers: Map<vscode.WebviewPanel, PanelAbortControllers> =
new Map();

_panelConnectionIds: Map<vscode.WebviewPanel, string | null> = new Map();

constructor({
connectionController,
telemetryService,
Expand All @@ -319,6 +321,7 @@ export default class DataBrowsingController {
controllers.totalCount?.abort();
}
this._panelAbortControllers.clear();
this._panelConnectionIds.clear();
}

private _createAbortController(
Expand Down Expand Up @@ -347,11 +350,61 @@ export default class DataBrowsingController {
}
}

/**
* Checks whether the panel's original connection is still the active one.
* Returns an error message if the connection has changed, or undefined if OK.
*/
private _getConnectionMismatchError(
panel: vscode.WebviewPanel,
): string | undefined {
const originalConnectionId = this._panelConnectionIds.get(panel);
const currentConnectionId =
this._connectionController.getActiveConnectionId();

if (originalConnectionId !== currentConnectionId) {
return 'The connection that this result view was associated with is no longer active. Please re-connect to the cluster and then re-run the query.';
Comment thread
tculig marked this conversation as resolved.
Outdated
}
return undefined;
}

handleWebviewMessage = async (
message: MessageFromWebviewToExtension,
panel: vscode.WebviewPanel,
options: DataBrowsingOptions,
): Promise<void> => {
// Allow theme-related messages regardless of connection state.
if (message.command === PreviewMessageType.getThemeColors) {
this._sendThemeColors(panel);
return;
}

// For all data-related messages, verify the connection is still valid.
const connectionError = this._getConnectionMismatchError(panel);
if (connectionError) {
switch (message.command) {
case PreviewMessageType.getDocuments:
void panel.webview.postMessage({
command: PreviewMessageType.getDocumentError,
error: connectionError,
});
break;
case PreviewMessageType.getTotalCount:
void panel.webview.postMessage({
command: PreviewMessageType.updateTotalCountError,
error: connectionError,
});
break;
case PreviewMessageType.cancelRequest:
// Nothing to cancel if the connection is gone.
break;
default:
// edit, clone, delete, insert — show a modal error.
void vscode.window.showErrorMessage(connectionError);
break;
Comment thread
tculig marked this conversation as resolved.
}
return;
}

switch (message.command) {
case PreviewMessageType.getDocuments:
await this.handleGetDocuments(
Expand All @@ -368,9 +421,6 @@ export default class DataBrowsingController {
case PreviewMessageType.cancelRequest:
this.handleCancelRequest(panel);
return;
case PreviewMessageType.getThemeColors:
this._sendThemeColors(panel);
return;
case PreviewMessageType.editDocument:
await this.handleEditDocument(
options,
Expand Down Expand Up @@ -890,6 +940,7 @@ export default class DataBrowsingController {
options: DataBrowsingOptions,
): void => {
this._cleanupAbortController(disposedPanel);
this._panelConnectionIds.delete(disposedPanel);

const source = options.query ? 'query-results' : 'collection';
this._telemetryService.track(new DataBrowserClosedTelemetryEvent(source));
Expand Down Expand Up @@ -987,6 +1038,10 @@ export default class DataBrowsingController {

panel.onDidDispose(() => this.onWebviewPanelClosed(panel, options));
this._activeWebviewPanels.push(panel);
this._panelConnectionIds.set(
panel,
this._connectionController.getActiveConnectionId(),
);

panel.webview.html = getDataBrowsingContent({
extensionPath,
Expand Down
Loading