diff --git a/.github/workflows/actions/test-and-build/action.yaml b/.github/workflows/actions/test-and-build/action.yaml index 07f558515..ee400fdd7 100644 --- a/.github/workflows/actions/test-and-build/action.yaml +++ b/.github/workflows/actions/test-and-build/action.yaml @@ -69,7 +69,15 @@ runs: if: ${{ runner.os != 'Windows' }} shell: bash + - name: Set BROWSER_AUTH_COMMAND + run: | + BROWSER_AUTH_COMMAND=$(echo "$(which node) $(pwd)/src/test/fixture/curl.js") + echo "BROWSER_AUTH_COMMAND=$BROWSER_AUTH_COMMAND" >> $GITHUB_ENV + shell: bash + - name: Run Tests + env: + BROWSER_AUTH_COMMAND: ${{ env.BROWSER_AUTH_COMMAND }} run: | npm run test shell: bash diff --git a/package-lock.json b/package-lock.json index 4b76db90d..d3a00dfb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.22.5", + "@mongodb-js/oidc-mock-provider": "^0.6.9", "@mongodb-js/oidc-plugin": "^0.3.0", "@mongodb-js/prettier-config-compass": "^1.0.0", "@mongodb-js/sbom-tools": "^0.5.4", @@ -97,6 +98,7 @@ "mocha-multi": "^1.1.7", "mongodb-client-encryption": "^6.0.0", "mongodb-runner": "^5.4.5", + "node-fetch": "^2.7.0", "node-loader": "^0.6.0", "npm-run-all": "^4.1.5", "ora": "^5.4.1", @@ -4814,6 +4816,59 @@ "tar": "^6.1.15" } }, + "node_modules/@mongodb-js/oidc-mock-provider": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-mock-provider/-/oidc-mock-provider-0.6.9.tgz", + "integrity": "sha512-4D9y7w7k0f7z6OkFJ8Ux5UhMG7Tg287CC1KmpW43BMzMx5gPXhostYK+OtpZNBlOoB9yrlHLusLKtpqQywMaog==", + "dev": true, + "dependencies": { + "yargs": "17.7.2" + }, + "bin": { + "oidc-mock-provider": "bin/oidc-mock-provider.js" + } + }, + "node_modules/@mongodb-js/oidc-mock-provider/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mongodb-js/oidc-mock-provider/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mongodb-js/oidc-mock-provider/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/@mongodb-js/oidc-plugin": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-0.3.0.tgz", @@ -17567,9 +17622,9 @@ "devOptional": true }, "node_modules/node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -27804,6 +27859,49 @@ "tar": "^6.1.15" } }, + "@mongodb-js/oidc-mock-provider": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-mock-provider/-/oidc-mock-provider-0.6.9.tgz", + "integrity": "sha512-4D9y7w7k0f7z6OkFJ8Ux5UhMG7Tg287CC1KmpW43BMzMx5gPXhostYK+OtpZNBlOoB9yrlHLusLKtpqQywMaog==", + "dev": true, + "requires": { + "yargs": "17.7.2" + }, + "dependencies": { + "cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + } + }, + "yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true + } + } + }, "@mongodb-js/oidc-plugin": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@mongodb-js/oidc-plugin/-/oidc-plugin-0.3.0.tgz", @@ -37707,9 +37805,9 @@ "devOptional": true }, "node-fetch": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", - "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "requires": { "whatwg-url": "^5.0.0" }, diff --git a/package.json b/package.json index c1e0e500b..4b731621f 100644 --- a/package.json +++ b/package.json @@ -658,15 +658,18 @@ }, { "command": "mdb.startStreamProcessor", - "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem" + "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem", + "group": "6@1" }, { "command": "mdb.stopStreamProcessor", - "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem" + "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem", + "group": "6@2" }, { "command": "mdb.dropStreamProcessor", - "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem" + "when": "view == mongoDBConnectionExplorer && viewItem == streamProcessorTreeItem", + "group": "6@3" } ], "editor/title": [ @@ -1042,6 +1045,11 @@ "type": "boolean", "default": false, "description": "The default behavior is to generate a single ObjectId and insert it on all cursors. Set to true to generate a unique ObjectId per cursor instead." + }, + "mdb.browserCommandForOIDCAuth": { + "type": "string", + "default": "", + "description": "Specify a shell command that is run to start the browser for authenticating with the OIDC identity provider for the server connection. Leave this empty for default browser." } } } @@ -1084,6 +1092,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.22.5", + "@mongodb-js/oidc-mock-provider": "^0.6.9", "@mongodb-js/oidc-plugin": "^0.3.0", "@mongodb-js/prettier-config-compass": "^1.0.0", "@mongodb-js/sbom-tools": "^0.5.4", @@ -1135,6 +1144,7 @@ "mocha-multi": "^1.1.7", "mongodb-client-encryption": "^6.0.0", "mongodb-runner": "^5.4.5", + "node-fetch": "^2.7.0", "node-loader": "^0.6.0", "npm-run-all": "^4.1.5", "ora": "^5.4.1", diff --git a/src/connectionController.ts b/src/connectionController.ts index d4ced33ca..819a3e3c4 100644 --- a/src/connectionController.ts +++ b/src/connectionController.ts @@ -90,6 +90,8 @@ export default class ConnectionController { private _currentConnectionId: null | string = null; _connectionAttempt: null | ConnectionAttempt = null; + _connectionStringInputCancellationToken: null | vscode.CancellationTokenSource = + null; private _connectingConnectionId: null | string = null; private _disconnecting = false; @@ -144,33 +146,44 @@ export default class ConnectionController { log.info('connectWithURI command called'); + const cancellationToken = new vscode.CancellationTokenSource(); + this._connectionStringInputCancellationToken = cancellationToken; + try { - connectionString = await vscode.window.showInputBox({ - value: '', - ignoreFocusOut: true, - placeHolder: - 'e.g. mongodb+srv://username:password@cluster0.mongodb.net/admin', - prompt: 'Enter your connection string (SRV or standard)', - validateInput: (uri: string) => { - if ( - !uri.startsWith('mongodb://') && - !uri.startsWith('mongodb+srv://') - ) { - return 'MongoDB connection strings begin with "mongodb://" or "mongodb+srv://"'; - } + connectionString = await vscode.window.showInputBox( + { + value: '', + ignoreFocusOut: true, + placeHolder: + 'e.g. mongodb+srv://username:password@cluster0.mongodb.net/admin', + prompt: 'Enter your connection string (SRV or standard)', + validateInput: (uri: string) => { + if ( + !uri.startsWith('mongodb://') && + !uri.startsWith('mongodb+srv://') + ) { + return 'MongoDB connection strings begin with "mongodb://" or "mongodb+srv://"'; + } - try { - // eslint-disable-next-line no-new - new ConnectionString(uri); - } catch (error) { - return formatError(error).message; - } + try { + // eslint-disable-next-line no-new + new ConnectionString(uri); + } catch (error) { + return formatError(error).message; + } - return null; + return null; + }, }, - }); + cancellationToken.token + ); } catch (e) { return false; + } finally { + if (this._connectionStringInputCancellationToken === cancellationToken) { + this._connectionStringInputCancellationToken.dispose(); + this._connectionStringInputCancellationToken = null; + } } if (!connectionString) { @@ -255,6 +268,7 @@ export default class ConnectionController { return this._connect(connection.id, connectionType); } + // eslint-disable-next-line complexity async _connect( connectionId: string, connectionType: ConnectionTypes @@ -321,22 +335,27 @@ export default class ConnectionController { browserCommandForOIDCAuth: undefined, // We overwrite this below. }, }); + const browserAuthCommand = vscode.workspace + .getConfiguration('mdb') + .get('browserCommandForOIDCAuth'); dataService = await connectionAttempt.connect({ ...connectionOptions, oidc: { ...cloneDeep(connectionOptions.oidc), - openBrowser: async ({ signal, url }) => { - try { - await openLink(url); - } catch (err) { - if (signal.aborted) return; - // If opening the link fails we default to regular link opening. - await vscode.commands.executeCommand( - 'vscode.open', - vscode.Uri.parse(url) - ); - } - }, + openBrowser: browserAuthCommand + ? { command: browserAuthCommand } + : async ({ signal, url }) => { + try { + await openLink(url); + } catch (err) { + if (signal.aborted) return; + // If opening the link fails we default to regular link opening. + await vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse(url) + ); + } + }, }, }); @@ -410,6 +429,7 @@ export default class ConnectionController { ); if (removeConfirmationResponse !== 'Confirm') { + await this.disconnect(); throw new Error('Reauthentication declined by user'); } } @@ -733,6 +753,10 @@ export default class ConnectionController { this.eventEmitter.removeListener(eventType, listener); } + closeConnectionStringInput() { + this._connectionStringInputCancellationToken?.cancel(); + } + isConnecting(): boolean { return !!this._connectionAttempt; } diff --git a/src/test/fixture/curl.js b/src/test/fixture/curl.js new file mode 100644 index 000000000..d7108c327 --- /dev/null +++ b/src/test/fixture/curl.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +/* eslint-disable */ +'use strict'; +const fetch = require('node-fetch'); + +// fetch() an URL and ignore the response body +(async function () { + (await fetch(process.argv[2])).body?.resume(); +})().catch((err) => { + process.nextTick(() => { + throw err; + }); +}); diff --git a/src/test/suite/connectionController.test.ts b/src/test/suite/connectionController.test.ts index f15af3676..d3523734a 100644 --- a/src/test/suite/connectionController.test.ts +++ b/src/test/suite/connectionController.test.ts @@ -655,6 +655,27 @@ suite('Connection Controller Test Suite', function () { assert.strictEqual(connectTimeoutMS, '5000'); }); + test('close connection string input calls to cancel the cancellation token', function (done) { + const inputBoxResolvesStub = sandbox.stub(); + inputBoxResolvesStub.callsFake(() => { + try { + const cancellationToken = inputBoxResolvesStub.firstCall.args[1]; + assert.strictEqual(cancellationToken.isCancellationRequested, false); + + testConnectionController.closeConnectionStringInput(); + + assert.strictEqual(cancellationToken.isCancellationRequested, true); + } catch (err) { + done(err); + } + + done(); + }); + sandbox.replace(vscode.window, 'showInputBox', inputBoxResolvesStub); + + void testConnectionController.connectWithURI(); + }); + test('ConnectionQuickPicks workspace connections list is displayed in the alphanumerical case insensitive order', async () => { await vscode.workspace .getConfiguration('mdb.connectionSaving') diff --git a/src/test/suite/oidc.test.ts b/src/test/suite/oidc.test.ts new file mode 100644 index 000000000..fc6a1450c --- /dev/null +++ b/src/test/suite/oidc.test.ts @@ -0,0 +1,436 @@ +import os from 'os'; +import path from 'path'; +import chai, { expect } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import fs from 'fs/promises'; +import sinon from 'sinon'; +import type { SinonStub } from 'sinon'; +import * as vscode from 'vscode'; +import { createHash } from 'crypto'; +import { before, after, afterEach, beforeEach } from 'mocha'; +import EventEmitter, { once } from 'events'; +import { ExtensionContextStub } from './stubs'; +import { StorageController } from '../../storage'; +import { TelemetryService } from '../../telemetry'; +import ConnectionController from '../../connectionController'; +import { StatusView } from '../../views'; + +import { MongoCluster } from 'mongodb-runner'; +import type { MongoClusterOptions } from 'mongodb-runner'; +import { OIDCMockProvider } from '@mongodb-js/oidc-mock-provider'; +import type { OIDCMockProviderConfig } from '@mongodb-js/oidc-mock-provider'; +import { ConnectionString } from 'mongodb-connection-string-url'; + +import launchMongoShell from '../../commands/launchMongoShell'; +import { getFullRange } from './suggestTestHelpers'; + +chai.use(chaiAsPromised); + +function hash(input: string): string { + return createHash('sha256').update(input).digest('hex').slice(0, 12); +} + +// Need to be provided via CI env because we can't get a hold for node.js exec +// path in our tests - they run inside a vscode process +const browserShellCommand = process.env.BROWSER_AUTH_COMMAND; + +const UNIQUE_TASK_ID = + process.env.GITHUB_RUN_ID && process.env.GITHUB_RUN_NUMBER + ? `${process.env.GITHUB_RUN_ID}-${process.env.GITHUB_RUN_NUMBER}` + : ''; +const defaultClusterOptions: MongoClusterOptions = { + topology: 'standalone', + tmpDir: path.join(os.tmpdir(), `vscode-tests-${hash(UNIQUE_TASK_ID)}-data`), + logDir: process.env.MONGODB_RUNNER_LOGDIR, + version: process.env.MONGODB_VERSION, +}; + +const DEFAULT_TOKEN_PAYLOAD = { + expires_in: 3600, + payload: { + // Define the user information stored inside the access tokens + groups: ['testgroup'], + sub: 'testuser', + aud: 'resource-server-audience-value', + }, +}; + +suite('OIDC Tests', function () { + this.timeout(50000); + + const extensionContextStub = new ExtensionContextStub(); + const testStorageController = new StorageController(extensionContextStub); + const testTelemetryService = new TelemetryService( + testStorageController, + extensionContextStub + ); + const testConnectionController = new ConnectionController({ + statusView: new StatusView(extensionContextStub), + storageController: testStorageController, + telemetryService: testTelemetryService, + }); + let showInformationMessageStub: SinonStub; + const sandbox = sinon.createSandbox(); + + // OIDC related variables + let getTokenPayload: typeof oidcMockProviderConfig.getTokenPayload = () => + DEFAULT_TOKEN_PAYLOAD; + let overrideRequestHandler: typeof oidcMockProviderConfig.overrideRequestHandler; + let oidcMockProviderConfig: OIDCMockProviderConfig; + let oidcMockProvider: OIDCMockProvider; + let oidcMockProviderEndpointAccesses: Record; + + let tmpdir: string; + let cluster: MongoCluster; + let connectionString: string; + + let createTerminalStub: SinonStub; + let sendTextStub: SinonStub; + + before(async function () { + if (process.platform !== 'linux') { + // OIDC is only supported on Linux in the 7.0+ enterprise server. + return this.skip(); + } + + oidcMockProviderEndpointAccesses = {}; + oidcMockProviderConfig = { + getTokenPayload(metadata: Parameters[0]) { + return getTokenPayload(metadata); + }, + overrideRequestHandler(url, req, res) { + const { pathname } = new URL(url); + oidcMockProviderEndpointAccesses[pathname] ??= 0; + oidcMockProviderEndpointAccesses[pathname]++; + return overrideRequestHandler?.(url, req, res); + }, + }; + oidcMockProvider = await OIDCMockProvider.create(oidcMockProviderConfig); + + tmpdir = path.join(os.tmpdir(), `vscode-oidc-${Date.now().toString(32)}`); + await fs.mkdir(path.join(tmpdir, 'db'), { recursive: true }); + const serverOidcConfig = { + issuer: oidcMockProvider.issuer, + clientId: 'testServer', + requestScopes: ['mongodbGroups'], + authorizationClaim: 'groups', + audience: 'resource-server-audience-value', + authNamePrefix: 'dev', + }; + + cluster = await MongoCluster.start({ + ...defaultClusterOptions, + version: '7.0.x', + downloadOptions: { enterprise: true }, + args: [ + '--setParameter', + 'authenticationMechanisms=SCRAM-SHA-256,MONGODB-OIDC', + // enableTestCommands allows using http:// issuers such as http://localhost + '--setParameter', + 'enableTestCommands=true', + '--setParameter', + `oidcIdentityProviders=${JSON.stringify([serverOidcConfig])}`, + ], + }); + + const cs = new ConnectionString(cluster.connectionString); + cs.searchParams.set('authMechanism', 'MONGODB-OIDC'); + + connectionString = cs.toString(); + }); + + after(async function () { + if (process.platform !== 'linux') { + return; + } + + await oidcMockProvider?.close(); + await cluster?.close(); + }); + + beforeEach(async function () { + sandbox.stub(testTelemetryService, 'trackNewConnection'); + showInformationMessageStub = sandbox.stub( + vscode.window, + 'showInformationMessage' + ); + + // This is required to follow through the redirect while establishing + // connection + await vscode.workspace + .getConfiguration('mdb') + .update('browserCommandForOIDCAuth', browserShellCommand); + + createTerminalStub = sandbox.stub(vscode.window, 'createTerminal'); + sendTextStub = sandbox.stub(); + createTerminalStub.returns({ + sendText: sendTextStub, + show: () => {}, + }); + }); + + afterEach(async function () { + // Reset our mock extension's state. + extensionContextStub._workspaceState = {}; + extensionContextStub._globalState = {}; + + await testConnectionController.disconnect(); + testConnectionController.clearAllConnections(); + + sandbox.restore(); + }); + + test('can successfully connect with a connection string', async function () { + const succesfullyConnected = + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ); + expect(succesfullyConnected).to.be.true; + + await launchMongoShell(testConnectionController); + expect(createTerminalStub).to.be.called; + + const terminalOptions: vscode.TerminalOptions = + createTerminalStub.firstCall.args[0]; + const terminalConnectionString = terminalOptions.env?.MDB_CONNECTION_STRING; + + if (!terminalConnectionString) { + expect.fail('Terminal connection string not found'); + } + const terminalCsWithoutAppName = new ConnectionString( + terminalConnectionString + ); + terminalCsWithoutAppName.searchParams.delete('appname'); + + expect(terminalCsWithoutAppName.toString()).to.equal(connectionString); + + const shellCommandText = sendTextStub.firstCall.args[0]; + expect(shellCommandText).to.equal('mongosh $MDB_CONNECTION_STRING;'); + + // Required for shell to share the OIDC state + expect(terminalOptions.env?.MONGOSH_OIDC_PARENT_HANDLE).to.not.be.undefined; + }); + + test('it persists tokens for further attempt if the settings is set to true', async function () { + await vscode.workspace + .getConfiguration('mdb') + .update('persistOIDCTokens', true); + let tokenFetchCalls = 0; + getTokenPayload = () => { + tokenFetchCalls++; + return DEFAULT_TOKEN_PAYLOAD; + }; + + expect( + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ) + ).to.be.true; + + const connectionId = testConnectionController.getActiveConnectionId(); + if (!connectionId) { + expect.fail('Connection id not found for active connection'); + } + + await testConnectionController.disconnect(); + + expect( + await testConnectionController.connectWithConnectionId(connectionId) + ).to.be.true; + expect(tokenFetchCalls).to.equal(1); + }); + + test('it will not persist tokens for further attempt if the settings is set to false', async function () { + await vscode.workspace + .getConfiguration('mdb') + .update('persistOIDCTokens', false); + let tokenFetchCalls = 0; + getTokenPayload = () => { + tokenFetchCalls++; + return DEFAULT_TOKEN_PAYLOAD; + }; + + expect( + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ) + ).to.be.true; + + const connectionId = testConnectionController.getActiveConnectionId(); + if (!connectionId) { + expect.fail('Connection id not found for active connection'); + } + + await testConnectionController.disconnect(); + + expect( + await testConnectionController.connectWithConnectionId(connectionId) + ).to.be.true; + expect(tokenFetchCalls).to.equal(2); + }); + + test('can cancel a connection attempt and then successfully connect', async function () { + const emitter = new EventEmitter(); + const secondConnectionEstablished = once( + emitter, + 'secondConnectionEstablished' + ); + overrideRequestHandler = async (url) => { + if (new URL(url).pathname === '/authorize') { + emitter.emit('authorizeEndpointCalled'); + // This does effectively mean that our 'fake browser' + // will never get a response from the authorization endpoint + // during the first connection attempt, and that therefore + // the local HTTP server will never have its redirect endpoint + // accessed. + await secondConnectionEstablished; + } + }; + + testConnectionController + .addNewConnectionStringAndConnect(connectionString) + .catch(() => { + // ignored + }); + + await once(emitter, 'authorizeEndpointCalled'); + overrideRequestHandler = () => {}; + const connected = + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ); + emitter.emit('secondConnectionEstablished'); + expect(connected).to.be.true; + }); + + test('can successfully re-authenticate', async function () { + showInformationMessageStub.resolves('Confirm'); + const originalReAuthHandler = + testConnectionController._reauthenticationHandler.bind( + testConnectionController + ); + let reAuthCalled = false; + let resolveReAuthPromise: (value?: unknown) => void; + const reAuthPromise = new Promise((resolve) => { + resolveReAuthPromise = resolve; + }); + sandbox + .stub(testConnectionController, '_reauthenticationHandler') + .callsFake(async () => { + reAuthCalled = true; + resolveReAuthPromise(); + await originalReAuthHandler(); + }); + let tokenFetchCalls = 0; + let afterReauth = false; + getTokenPayload = () => { + tokenFetchCalls++; + return { + ...DEFAULT_TOKEN_PAYLOAD, + ...(afterReauth ? {} : { expires_in: 1 }), + }; + }; + + expect( + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ) + ).to.be.true; + afterReauth = true; + + // Trigger a command on data service for reauthentication + while (reAuthCalled === false) { + await testConnectionController.getActiveDataService()?.count('x.y', {}); + } + + // Wait for reauthentication promise to resolve + await reAuthPromise; + + expect(tokenFetchCalls).to.equal(2); + expect(testConnectionController.isCurrentlyConnected()).to.be.true; + }); + + test('can decline re-authentication if wanted', async function () { + showInformationMessageStub.resolves('Declined'); + const originalReAuthHandler = + testConnectionController._reauthenticationHandler.bind( + testConnectionController + ); + let reAuthCalled = false; + let resolveReAuthPromise: (value?: unknown) => void; + const reAuthPromise = new Promise((resolve) => { + resolveReAuthPromise = resolve; + }); + sandbox + .stub(testConnectionController, '_reauthenticationHandler') + .callsFake(async () => { + reAuthCalled = true; + resolveReAuthPromise(); + await originalReAuthHandler(); + }); + let tokenFetchCalls = 0; + let afterReauth = false; + getTokenPayload = () => { + tokenFetchCalls++; + return { + ...DEFAULT_TOKEN_PAYLOAD, + ...(afterReauth ? {} : { expires_in: 1 }), + }; + }; + + expect( + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ) + ).to.be.true; + afterReauth = true; + + // Trigger a command on data service for reauthentication + while (reAuthCalled === false) { + await testConnectionController + .getActiveDataService() + ?.count('x.y', {}) + .catch((error) => { + expect(error.message).to.equal('Reauthentication declined by user'); + }); + } + + await reAuthPromise; + + // Because we declined the auth in showInformationMessage above + expect(tokenFetchCalls).to.equal(1); + expect(testConnectionController.isCurrentlyConnected()).to.be.false; + }); + + test('shares the oidc state also with the playgrounds', async function () { + let tokenFetchCalls = 0; + getTokenPayload = () => { + tokenFetchCalls++; + return DEFAULT_TOKEN_PAYLOAD; + }; + + expect( + await testConnectionController.addNewConnectionStringAndConnect( + connectionString + ) + ).to.be.true; + + await vscode.commands.executeCommand('mdb.createPlayground'); + + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('Window active text editor is undefined'); + } + + const testDocumentUri = editor.document.uri; + const edit = new vscode.WorkspaceEdit(); + edit.replace( + testDocumentUri, + getFullRange(editor.document), + "use('random'); db.randomColl.find({}).count();" + ); + await vscode.workspace.applyEdit(edit); + await vscode.commands.executeCommand('mdb.runPlayground'); + expect(tokenFetchCalls).to.equal(1); + }); +}); diff --git a/src/test/suite/views/webview-app/overview-page.test.tsx b/src/test/suite/views/webview-app/overview-page.test.tsx index 2985f3876..441a21e76 100644 --- a/src/test/suite/views/webview-app/overview-page.test.tsx +++ b/src/test/suite/views/webview-app/overview-page.test.tsx @@ -41,9 +41,15 @@ describe('OverviewPage test suite', function () { render(); expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; + const postMessageSpy = Sinon.spy(vscode, 'postMessage'); + expect(postMessageSpy).to.not.be.called; screen.getByText('Open form').click(); expect(screen.getByTestId(connectionFormTestId)).to.exist; + const message = postMessageSpy.firstCall.args[0]; + expect(message).to.deep.equal({ + command: MESSAGE_TYPES.CONNECTION_FORM_OPENED, + }); screen.getByLabelText('Close modal').click(); expect(screen.queryByTestId(connectionFormTestId)).to.not.exist; diff --git a/src/views/webview-app/extension-app-message-constants.ts b/src/views/webview-app/extension-app-message-constants.ts index 63aacab2c..31b1c017b 100644 --- a/src/views/webview-app/extension-app-message-constants.ts +++ b/src/views/webview-app/extension-app-message-constants.ts @@ -15,9 +15,10 @@ export enum MESSAGE_TYPES { CONNECT = 'CONNECT', CANCEL_CONNECT = 'CANCEL_CONNECT', CONNECT_RESULT = 'CONNECT_RESULT', + CONNECTION_FORM_OPENED = 'CONNECTION_FORM_OPENED', + CONNECTION_STATUS_MESSAGE = 'CONNECTION_STATUS_MESSAGE', OPEN_EDIT_CONNECTION = 'OPEN_EDIT_CONNECTION', EDIT_AND_CONNECT_CONNECTION = 'EDIT_AND_CONNECT_CONNECTION', - CONNECTION_STATUS_MESSAGE = 'CONNECTION_STATUS_MESSAGE', EXTENSION_LINK_CLICKED = 'EXTENSION_LINK_CLICKED', CREATE_NEW_PLAYGROUND = 'CREATE_NEW_PLAYGROUND', GET_CONNECTION_STATUS = 'GET_CONNECTION_STATUS', @@ -35,6 +36,10 @@ export interface CreateNewPlaygroundMessage extends BasicWebviewMessage { command: MESSAGE_TYPES.CREATE_NEW_PLAYGROUND; } +export interface ConnectionFormOpenedMessage extends BasicWebviewMessage { + command: MESSAGE_TYPES.CONNECTION_FORM_OPENED; +} + export interface ConnectionStatusMessage extends BasicWebviewMessage { command: MESSAGE_TYPES.CONNECTION_STATUS_MESSAGE; connectionStatus: CONNECTION_STATUS; @@ -107,6 +112,7 @@ export interface ThemeChangedMessage extends BasicWebviewMessage { export type MESSAGE_FROM_WEBVIEW_TO_EXTENSION = | ConnectMessage | CancelConnectMessage + | ConnectionFormOpenedMessage | CreateNewPlaygroundMessage | GetConnectionStatusMessage | LinkClickedMessage diff --git a/src/views/webview-app/use-connection-form.ts b/src/views/webview-app/use-connection-form.ts index 00769ae17..d629da6c9 100644 --- a/src/views/webview-app/use-connection-form.ts +++ b/src/views/webview-app/use-connection-form.ts @@ -5,6 +5,7 @@ import type { ConnectionOptions } from 'mongodb-data-service'; import { sendConnectToExtension, sendCancelConnectToExtension, + sendFormOpenedToExtension, } from './vscode-api'; import { MESSAGE_TYPES } from './extension-app-message-constants'; import type { MESSAGE_FROM_EXTENSION_TO_WEBVIEW } from './extension-app-message-constants'; @@ -78,6 +79,7 @@ export default function useConnectionForm() { // Reset the connection info. setInitialConnectionInfo(createNewConnectionInfo()); setConnectionFormOpened(true); + sendFormOpenedToExtension(); }, closeConnectionForm: () => { setConnectionFormOpened(false); diff --git a/src/views/webview-app/vscode-api.ts b/src/views/webview-app/vscode-api.ts index a1bca9f9f..ae0c19443 100644 --- a/src/views/webview-app/vscode-api.ts +++ b/src/views/webview-app/vscode-api.ts @@ -39,6 +39,14 @@ export const sendCancelConnectToExtension = () => { }); }; +// When the form is opened we want to close the connection string +// input if it's open, so we message the extension. +export const sendFormOpenedToExtension = () => { + vscode.postMessage({ + command: MESSAGE_TYPES.CONNECTION_FORM_OPENED, + }); +}; + export const renameActiveConnection = () => { vscode.postMessage({ command: MESSAGE_TYPES.RENAME_ACTIVE_CONNECTION, diff --git a/src/views/webviewController.ts b/src/views/webviewController.ts index d067e9b54..e2501634f 100644 --- a/src/views/webviewController.ts +++ b/src/views/webviewController.ts @@ -169,6 +169,11 @@ export default class WebviewController { EXTENSION_COMMANDS.MDB_CREATE_PLAYGROUND_FROM_OVERVIEW_PAGE ); return; + case MESSAGE_TYPES.CONNECTION_FORM_OPENED: + // If the connection string input is open we want to close it + // when the user opens the form. + this._connectionController.closeConnectionStringInput(); + return; case MESSAGE_TYPES.GET_CONNECTION_STATUS: void panel.webview.postMessage({ command: MESSAGE_TYPES.CONNECTION_STATUS_MESSAGE,