From aaa04d3cfa6a39fba61d407be6d7dbee477c8315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Kr=C3=BCger?= Date: Tue, 20 Jan 2026 15:19:17 +0100 Subject: [PATCH] vscode uri handler now asks for signing in to another account instead of showing a "subscription not found" error. fixes #2686 --- extension.bundle.ts | 1 + l10n/bundle.l10n.json | 6 +- src/vscodeUriHandler.ts | 151 +++++++++++++++++++++++++++----- test/vscodeUriHandler.test.ts | 160 ++++++++++++++++++++++++++++++++++ 4 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 test/vscodeUriHandler.test.ts diff --git a/extension.bundle.ts b/extension.bundle.ts index e958e81ec..a581f0394 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -49,5 +49,6 @@ export { randomUtils } from './src/utils/randomUtils'; export { rejectOnTimeout, valueOnTimeout } from './src/utils/timeout'; export { IDisposable, getDocumentTreeItemLabel } from './src/utils/vscodeUtils'; export { wrapError } from './src/utils/wrapError'; +export { globalUriHandler } from './src/vscodeUriHandler'; // NOTE: The auto-fix action "source.organizeImports" does weird things with this file, but there doesn't seem to be a way to disable it on a per-file basis so we'll just let it happen diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index 465521223..1b3980d50 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -587,6 +587,7 @@ "Select resource group \"{0}\"": "Select resource group \"{0}\"", "Select sort direction": "Select sort direction", "Select subscription": "Select subscription", + "Select subscriptions": "Select subscriptions", "Select Subscriptions...": "Select Subscriptions...", "Select the Azure Cosmos DB Emulator Type…": "Select the Azure Cosmos DB Emulator Type…", "Select the error you would like to report": "Select the error you would like to report", @@ -601,6 +602,7 @@ "Setting up credentials for server \"{0}\"…": "Setting up credentials for server \"{0}\"…", "Show history of previous queries": "Show history of previous queries", "Showing Results": "Showing Results", + "Sign in": "Sign in", "Sign in to Azure...": "Sign in to Azure...", "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.": "Signing out programmatically is not supported. You must sign out by selecting the account in the Accounts menu and choosing Sign Out.", "Skip for now": "Skip for now", @@ -650,6 +652,7 @@ "The \"MongoDB Connections\" functionality has moved to the \"DocumentDB for VS Code\" extension.\n\nIf you had connections saved here in the past, they have been migrated to the new \"Connections View\".\n\nClick to install the new \"DocumentDB for VS Code\" extension.": "The \"MongoDB Connections\" functionality has moved to the \"DocumentDB for VS Code\" extension.\n\nIf you had connections saved here in the past, they have been migrated to the new \"Connections View\".\n\nClick to install the new \"DocumentDB for VS Code\" extension.", "The Azure Cosmos DB emulator for MongoDB is only supported on Windows, Linux and MacOS (Intel).": "The Azure Cosmos DB emulator for MongoDB is only supported on Windows, Linux and MacOS (Intel).", "The Azure Cosmos DB emulator is only supported on Windows, Linux and MacOS (Intel).": "The Azure Cosmos DB emulator is only supported on Windows, Linux and MacOS (Intel).", + "The Azure subscription that contains this Cosmos DB resource is not accessible with your current Azure sign-in or selected subscriptions. Sign in with the correct account or update your subscription selection, then try again.": "The Azure subscription that contains this Cosmos DB resource is not accessible with your current Azure sign-in or selected subscriptions. Sign in with the correct account or update your subscription selection, then try again.", "The collection \"{0}\" already exists in the database \"{1}\".": "The collection \"{0}\" already exists in the database \"{1}\".", "The collection \"{containerId}\" has been deleted.": "The collection \"{containerId}\" has been deleted.", "The connection \"{connectionName}\" already exists. Do you want to update it?": "The connection \"{connectionName}\" already exists. Do you want to update it?", @@ -696,6 +699,7 @@ "third partition key e.g., /address/zipCode": "third partition key e.g., /address/zipCode", "This attached account does not have permissions to create a database.": "This attached account does not have permissions to create a database.", "This cannot be undone.": "This cannot be undone.", + "This Cosmos DB resource isn't available with your current Azure sign-in. Sign in with the correct account or select the correct subscriptions and try again.": "This Cosmos DB resource isn't available with your current Azure sign-in. Sign in with the correct account or select the correct subscriptions and try again.", "This extension does not support Azure Cosmos DB for": "This extension does not support Azure Cosmos DB for", "This field is not set": "This field is not set", "This functionality requires installing the Azure Account extension.": "This functionality requires installing the Azure Account extension.", @@ -741,7 +745,7 @@ "Unable to execute query due to missing node data. Please connect to a Cosmos DB container node.": "Unable to execute query due to missing node data. Please connect to a Cosmos DB container node.", "Unable to extract account name from connection string": "Unable to extract account name from connection string", "Unable to find database \"{0}\" and collection \"{1}\" in resource \"{2}\". Please ensure the resource exists and try again.": "Unable to find database \"{0}\" and collection \"{1}\" in resource \"{2}\". Please ensure the resource exists and try again.", - "Unable to find resource \"{0}\". Please ensure the resource exists and try again.": "Unable to find resource \"{0}\". Please ensure the resource exists and try again.", + "Unable to find resource \"{0}\". Please ensure the following:\n- You are signed in with the correct Azure account.\n- The subscription is selected.\n- The resource exists.": "Unable to find resource \"{0}\". Please ensure the following:\n- You are signed in with the correct Azure account.\n- The subscription is selected.\n- The resource exists.", "Unable to get query plan due to missing node data. Please connect to a Cosmos DB container node.": "Unable to get query plan due to missing node data. Please connect to a Cosmos DB container node.", "Unable to parse execution result": "Unable to parse execution result", "Unable to parse syntax near line {line}, col {column}: {message}": "Unable to parse syntax near line {line}, col {column}: {message}", diff --git a/src/vscodeUriHandler.ts b/src/vscodeUriHandler.ts index 16cd86526..3113c07dc 100644 --- a/src/vscodeUriHandler.ts +++ b/src/vscodeUriHandler.ts @@ -7,6 +7,7 @@ import { parseAzureResourceId, type ParsedAzureResourceId } from '@microsoft/vsc import { callWithTelemetryAndErrorHandling, nonNullValue, + parseError, UserCancelledError, type IActionContext, } from '@microsoft/vscode-azext-utils'; @@ -73,6 +74,9 @@ export async function globalUriHandler(uri: vscode.Uri): Promise { }, ); } catch (error) { + if (error instanceof UserCancelledError) { + throw error; + } const errMsg = error instanceof Error ? error.message : String(error); throw new Error(l10n.t('Failed to process URI: {0}', errMsg)); } @@ -290,39 +294,51 @@ async function revealAzureResourceInExplorer( container?: string, ): Promise { await vscode.commands.executeCommand('azureResourceGroups.focus'); - await ext.rgApiV2.resources.revealAzureResource(resourceId.rawId, { - select: true, - focus: true, - expand: true, - }); + await revealAzureResourceWithAccountPrompt(context, resourceId.rawId); let fulId = resourceId.rawId; if (database && container) { fulId = `${resourceId.rawId}${database ? `/${database}${container ? `/${container}` : ''}` : ''}`; - await ext.rgApiV2.resources.revealAzureResource(fulId, { - select: true, - focus: true, - expand: true, - }); + await revealAzureResourceWithAccountPrompt(context, fulId); } + const tryFindRevealedResource = async (): Promise => { + const revealedId = await ext.rgApiV2.resources.getSelectedAzureNode(); + if (revealedId) { + const strippedId = removeAzureTenantPrefix(revealedId); + return await branchDataProvider.findNodeById(strippedId); + } + + return await branchDataProvider.findNodeById(fulId); + }; + let resource: TreeElement | undefined; const branchDataProvider = resourceId.provider === 'Microsoft.DocumentDB/mongoClusters' ? ext.mongoVCoreBranchDataProvider : ext.cosmosDBBranchDataProvider; - const revealedId = await ext.rgApiV2.resources.getSelectedAzureNode(); - if (revealedId) { - const strippedId = removeAzureTenantPrefix(revealedId); - resource = await branchDataProvider.findNodeById(strippedId); - } else { - resource = await branchDataProvider.findNodeById(fulId); - } + resource = await tryFindRevealedResource(); if (!resource) { - throw new Error( - l10n.t('Unable to find resource "{0}". Please ensure the resource exists and try again.', resourceId.rawId), - ); + // If reveal succeeded but we can't locate the node, it's commonly because the user is signed into + // a different Azure account or hasn't selected the correct subscriptions. + await promptToFixAzureAccountAccess(context); + + // Retry after the user has potentially changed their Azure sign-in/subscription selection. + await revealAzureResourceWithAccountPrompt(context, resourceId.rawId); + if (database && container) { + await revealAzureResourceWithAccountPrompt(context, fulId); + } + + resource = await tryFindRevealedResource(); + if (!resource) { + throw new Error( + l10n.t( + 'Unable to find resource "{0}". Please ensure the following:\n- You are signed in with the correct Azure account.\n- The subscription is selected.\n- The resource exists.', + resourceId.rawId, + ), + ); + } } context.telemetry.properties.experience = isTreeElementWithExperience(resource) @@ -343,6 +359,101 @@ async function revealAzureResourceInExplorer( return resource; } +async function promptToFixAzureAccountAccess(context: IActionContext): Promise { + const signIn: vscode.MessageItem = { title: l10n.t('Sign in') }; + const selectSubscriptions: vscode.MessageItem = { title: l10n.t('Select subscriptions') }; + + const choice = await context.ui.showWarningMessage( + l10n.t( + "This Cosmos DB resource isn't available with your current Azure sign-in. Sign in with the correct account or select the correct subscriptions and try again.", + ), + { modal: true, stepName: 'azureAccountMismatch' }, + signIn, + selectSubscriptions, + ); + + if (!choice) { + throw new UserCancelledError('azureAccountMismatch'); + } + + if (choice.title === signIn.title) { + await vscode.commands.executeCommand('azure-account.login'); + } else if (choice.title === selectSubscriptions.title) { + await vscode.commands.executeCommand('azure-account.selectSubscriptions'); + } +} + +async function revealAzureResourceWithAccountPrompt(context: IActionContext, azureResourceId: string): Promise { + try { + await ext.rgApiV2.resources.revealAzureResource(azureResourceId, { + select: true, + focus: true, + expand: true, + }); + } catch (error) { + const parsed = parseError(error); + if (!isSubscriptionNotFoundError(parsed.message)) { + throw error; + } + + const signIn: vscode.MessageItem = { title: l10n.t('Sign in') }; + const selectSubscriptions: vscode.MessageItem = { title: l10n.t('Select subscriptions') }; + + const choice = await context.ui.showWarningMessage( + l10n.t( + 'The Azure subscription that contains this Cosmos DB resource is not accessible with your current Azure sign-in or selected subscriptions. Sign in with the correct account or update your subscription selection, then try again.', + ), + { modal: true, stepName: 'subscriptionNotFound' }, + signIn, + selectSubscriptions, + ); + + if (!choice) { + throw new UserCancelledError('subscriptionNotFound'); + } + + if (choice.title === signIn.title) { + await vscode.commands.executeCommand('azure-account.login'); + } else if (choice.title === selectSubscriptions.title) { + await vscode.commands.executeCommand('azure-account.selectSubscriptions'); + } + + // Retry once after the user action. + await ext.rgApiV2.resources.revealAzureResource(azureResourceId, { + select: true, + focus: true, + expand: true, + }); + } +} + +function isSubscriptionNotFoundError(message: string): boolean { + const msg = message.toLowerCase(); + // NOTE: This logic intentionally inspects the error message text instead of using a richer error + // type or code because the originating errors come from the Azure Resources / Azure Account + // extensions (via `ext.rgApiV2.resources.revealAzureResource`) and do not expose a stable, + // programmatic identifier for "subscription not found" conditions. + // + // Typical upstream messages that we have seen include variants such as: + // - "The subscription '...' could not be found." + // - "The subscription '...' does not exist." + // - "No subscriptions found." + // + // This helper is only used to decide when to show a specific "subscription not available with + // your current Azure sign-in" prompt and then retry once after the user signs in or selects + // subscriptions. If none of these patterns match, we rethrow the original error so that it is + // surfaced normally. To keep this heuristic conservative and avoid false positives, we require + // the presence of the word "subscription" together with a small set of "not found" phrases, or + // the exact "no subscriptions found" wording. If the Azure Resources / Azure Account layers + // change their error messages, this function may need to be updated accordingly. + return ( + (msg.includes('subscription') && msg.includes('not found')) || + (msg.includes('subscription') && msg.includes('could not be found')) || + (msg.includes('subscription') && msg.includes('does not exist')) || + msg.includes('no subscriptions found') + ); +} + /** * Creates and attaches a database connection to the workspace. * diff --git a/test/vscodeUriHandler.test.ts b/test/vscodeUriHandler.test.ts new file mode 100644 index 000000000..200989b22 --- /dev/null +++ b/test/vscodeUriHandler.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import * as vscode from 'vscode'; +import { ext, globalUriHandler, registerOnActionStartHandler, UserCancelledError } from '../extension.bundle'; +import { runWithInputs } from './TestUserInput'; + +suite('vscodeUriHandler - Open in VS Code', () => { + test('prompts to sign in when subscription is not found and retries reveal', async () => { + const originalExecuteCommand = vscode.commands.executeCommand; + + let revealCallCount = 0; + let loginCalled = false; + + try { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + (async (command: string) => { + if (command === 'azure-account.login') { + loginCalled = true; + } + return undefined; + }) as typeof vscode.commands.executeCommand; + + // Stub Azure Resources API + branch provider + ext.rgApiV2 = { + resources: { + revealAzureResource: async () => { + revealCallCount++; + if (revealCallCount === 1) { + throw new Error('Subscription not found'); + } + }, + getSelectedAzureNode: async () => undefined, + }, + } as unknown as typeof ext.rgApiV2; + + ext.cosmosDBBranchDataProvider = { + findNodeById: async (id: string) => ({ id }) as unknown, + } as unknown as typeof ext.cosmosDBBranchDataProvider; + + const resourceId = + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myacct'; + + const uri = vscode.Uri.parse( + `vscode://ms-azuretools.vscode-cosmosdb/?resourceId=${encodeURIComponent(resourceId)}`, + ); + + await runWithInputs('handleExternalUri', ['Sign in'], registerOnActionStartHandler, async () => { + await globalUriHandler(uri); + }); + + assert.equal(loginCalled, true); + assert.equal(revealCallCount, 2); + } finally { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + originalExecuteCommand; + } + }); + + test('treats dismissing the prompt as cancellation (no wrapped error)', async () => { + const originalExecuteCommand = vscode.commands.executeCommand; + + try { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + (async () => undefined) as typeof vscode.commands.executeCommand; + + ext.rgApiV2 = { + resources: { + revealAzureResource: async () => { + throw new Error('Subscription not found'); + }, + getSelectedAzureNode: async () => undefined, + }, + } as unknown as typeof ext.rgApiV2; + + ext.cosmosDBBranchDataProvider = { + findNodeById: async (id: string) => ({ id }) as unknown, + } as unknown as typeof ext.cosmosDBBranchDataProvider; + + const resourceId = + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myacct'; + const uri = vscode.Uri.parse( + `vscode://ms-azuretools.vscode-cosmosdb/?resourceId=${encodeURIComponent(resourceId)}`, + ); + + // Simulate the user dismissing the prompt by having `context.ui.showWarningMessage` cancel. + // The test input infrastructure can't return "undefined" from `showWarningMessage`, + // so we simulate cancel by throwing `UserCancelledError`. + ext.rgApiV2 = { + resources: { + revealAzureResource: async () => { + throw new UserCancelledError('subscriptionNotFound'); + }, + getSelectedAzureNode: async () => undefined, + }, + } as unknown as typeof ext.rgApiV2; + + await globalUriHandler(uri); + } finally { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + originalExecuteCommand; + } + }); + + test('prompts to sign in when resource is not found after reveal (account mismatch) and retries', async () => { + const originalExecuteCommand = vscode.commands.executeCommand; + + let loginCalled = false; + let revealCallCount = 0; + let findCallCount = 0; + + try { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + (async (command: string) => { + if (command === 'azure-account.login') { + loginCalled = true; + } + return undefined; + }) as typeof vscode.commands.executeCommand; + + ext.rgApiV2 = { + resources: { + revealAzureResource: async () => { + revealCallCount++; + }, + getSelectedAzureNode: async () => undefined, + }, + } as unknown as typeof ext.rgApiV2; + + ext.cosmosDBBranchDataProvider = { + findNodeById: async () => { + findCallCount++; + // First lookup fails (simulating wrong account/subscription), second succeeds after sign-in. + return findCallCount === 1 ? undefined : ({ id: 'found' } as unknown); + }, + } as unknown as typeof ext.cosmosDBBranchDataProvider; + + const resourceId = + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/myacct'; + + const uri = vscode.Uri.parse( + `vscode://ms-azuretools.vscode-cosmosdb/?resourceId=${encodeURIComponent(resourceId)}`, + ); + + await runWithInputs('handleExternalUri', ['Sign in'], registerOnActionStartHandler, async () => { + await globalUriHandler(uri); + }); + + assert.equal(loginCalled, true); + // reveal happens at least once, and then again after the prompt. + assert.ok(revealCallCount >= 2); + } finally { + (vscode.commands as unknown as { executeCommand: typeof vscode.commands.executeCommand }).executeCommand = + originalExecuteCommand; + } + }); +});