Skip to content

Commit 7f03833

Browse files
authored
Fixes issue where refreshed Entra token isn't persisted to the OE node's copy of the connection (#20352)
* checkpoint * consolidation * adding tests * Fixing test mocks * test coverage
1 parent 421a7ad commit 7f03833

File tree

12 files changed

+333
-44
lines changed

12 files changed

+333
-44
lines changed

src/controllers/connectionManager.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,6 +1072,13 @@ export default class ConnectionManager {
10721072
this.accountStore,
10731073
getCloudProviderSettings(account.key.providerId).settings.sqlResource!,
10741074
);
1075+
1076+
connectionInfo.azureAccountToken = profile.azureAccountToken;
1077+
connectionInfo.expiresOn = profile.expiresOn;
1078+
connectionInfo.accountId = profile.accountId;
1079+
connectionInfo.tenantId = profile.tenantId;
1080+
connectionInfo.user = profile.user;
1081+
connectionInfo.email = profile.email;
10751082
} else {
10761083
throw new Error(LocalizedConstants.cannotConnect);
10771084
}

src/controllers/mainController.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,9 @@ export default class MainController implements vscode.Disposable {
731731
nodeUri,
732732
connectionCreds,
733733
);
734-
if (!isConnected) {
734+
if (isConnected) {
735+
node.updateEntraTokenInfo(connectionCreds); // may be updated Entra token after connect() call
736+
} else {
735737
/**
736738
* The connection wasn't successful. Stopping scripting operation.
737739
* Not throwing an error because the user is already notified of
@@ -754,6 +756,8 @@ export default class MainController implements vscode.Disposable {
754756
connectionStrategy: ConnectionStrategy.CopyConnectionFromInfo,
755757
connectionInfo: connectionCreds,
756758
});
759+
760+
node.updateEntraTokenInfo(connectionCreds); // newQuery calls connect() internally, so may be updated Entra token
757761
if (executeScript) {
758762
const preventAutoExecute = vscode.workspace
759763
.getConfiguration()
@@ -1102,7 +1106,10 @@ export default class MainController implements vscode.Disposable {
11021106
connectionUri,
11031107
connectionCreds,
11041108
);
1105-
if (!connectionResult) {
1109+
1110+
if (connectionResult) {
1111+
node.updateEntraTokenInfo(connectionCreds);
1112+
} else {
11061113
return;
11071114
}
11081115
}

src/controllers/sqlDocumentService.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SqlOutputContentProvider } from "../models/sqlOutputContentProvider";
99
import StatusView from "../views/statusView";
1010
import store from "../queryResult/singletonStore";
1111
import SqlToolsServerClient from "../languageservice/serviceclient";
12-
import { getUriKey } from "../utils/utils";
12+
import { removeUndefinedProperties, getUriKey } from "../utils/utils";
1313
import * as Utils from "../models/utils";
1414
import * as Constants from "../constants/constants";
1515
import * as LocalizedConstants from "../constants/locConstants";
@@ -104,30 +104,31 @@ export default class SqlDocumentService implements vscode.Disposable {
104104
return false;
105105
}
106106

107-
let connectionCreds: vscodeMssql.IConnectionInfo | undefined;
108107
let connectionStrategy: ConnectionStrategy;
109108
let nodeType: string | undefined;
109+
let sourceNode: TreeNodeInfo | undefined;
110110

111111
if (node) {
112112
// Case 1: User right-clicked on an OE node and selected "New Query"
113-
connectionCreds = node.connectionProfile;
114113
nodeType = node.nodeType;
115114
connectionStrategy = ConnectionStrategy.CopyConnectionFromInfo;
115+
sourceNode = node;
116116
} else if (this._lastActiveConnectionInfo) {
117117
// Case 2: User triggered "New Query" from command palette and the active document has a connection
118-
connectionCreds = undefined;
119118
nodeType = "previousEditor";
120119
connectionStrategy = ConnectionStrategy.CopyLastActive;
121120
} else if (this.objectExplorerTree.selection?.length === 1) {
122121
// Case 3: User triggered "New Query" from command palette while they have a connected OE node selected
123-
connectionCreds = this.objectExplorerTree.selection[0].connectionProfile;
124-
nodeType = this.objectExplorerTree.selection[0].nodeType;
122+
sourceNode = this.objectExplorerTree.selection[0];
123+
nodeType = sourceNode.nodeType;
125124
connectionStrategy = ConnectionStrategy.CopyConnectionFromInfo;
126125
} else {
127126
// Case 4: User triggered "New Query" from command palette and there's no reasonable context
128127
connectionStrategy = ConnectionStrategy.PromptForConnection;
129128
}
130129

130+
const connectionCreds = sourceNode?.connectionProfile;
131+
131132
if (connectionCreds) {
132133
await this._connectionMgr.handlePasswordBasedCredentials(connectionCreds);
133134
}
@@ -138,6 +139,11 @@ export default class SqlDocumentService implements vscode.Disposable {
138139
connectionInfo: connectionCreds,
139140
});
140141

142+
if (sourceNode && connectionCreds) {
143+
// newQuery may refresh the Entra token, so update the OE node's connection profile
144+
sourceNode.updateEntraTokenInfo(connectionCreds);
145+
}
146+
141147
const newEditorUri = getUriKey(newEditor.document.uri);
142148

143149
const connectionResult = this._connectionMgr.getConnectionInfo(newEditorUri);
@@ -388,6 +394,17 @@ export default class SqlDocumentService implements vscode.Disposable {
388394
connectionConfig.connectionInfo,
389395
);
390396
}
397+
398+
if (options.connectionInfo && connectionConfig.connectionInfo) {
399+
const tokenUpdates = removeUndefinedProperties({
400+
azureAccountToken: connectionConfig.connectionInfo.azureAccountToken,
401+
expiresOn: connectionConfig.connectionInfo.expiresOn,
402+
});
403+
404+
if (Object.keys(tokenUpdates).length > 0) {
405+
Object.assign(options.connectionInfo, tokenUpdates);
406+
}
407+
}
391408
}
392409
}
393410

src/objectExplorer/nodes/treeNodeInfo.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as Constants from "../../constants/constants";
1111
import { ITreeNodeInfo, ObjectMetadata } from "vscode-mssql";
1212
import { IConnectionProfile } from "../../models/interfaces";
1313
import { generateGuid } from "../../models/utils";
14+
import { removeUndefinedProperties } from "../../utils/utils";
1415

1516
export class TreeNodeInfo extends vscode.TreeItem implements ITreeNodeInfo {
1617
private _nodePath: string;
@@ -234,10 +235,34 @@ export class TreeNodeInfo extends vscode.TreeItem implements ITreeNodeInfo {
234235
public set loadingLabel(value: string) {
235236
this._loadingLabel = value;
236237
}
238+
237239
public updateConnectionProfile(value: IConnectionProfile): void {
238240
this._connectionProfile = value;
239241
}
240242

243+
public updateEntraTokenInfo(updatedCredentials: vscodeMssql.IConnectionInfo): void {
244+
if (!updatedCredentials) {
245+
return;
246+
}
247+
248+
const updatedEntraTokenInfo = removeUndefinedProperties({
249+
azureAccountToken: updatedCredentials.azureAccountToken,
250+
expiresOn: updatedCredentials.expiresOn,
251+
});
252+
253+
if (Object.keys(updatedEntraTokenInfo).length === 0) {
254+
// no refreshed token info to persist
255+
return;
256+
}
257+
258+
const updatedProfile: IConnectionProfile = {
259+
...this.connectionProfile,
260+
...updatedEntraTokenInfo,
261+
};
262+
263+
this.updateConnectionProfile(updatedProfile);
264+
}
265+
241266
protected updateMetadata(value: ObjectMetadata): void {
242267
this._metadata = value;
243268
}
Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
/*---------------------------------------------------------------------------------------------
2-
* Copyright (c) Microsoft Corporation. All rights reserved.
3-
* Licensed under the MIT License. See License.txt in the project root for license information.
4-
*--------------------------------------------------------------------------------------------*/
5-
6-
import { FormContextProps } from "../../../sharedInterfaces/form";
7-
import {
8-
IPublishForm,
9-
PublishDialogFormItemSpec,
10-
PublishDialogState,
11-
} from "../../../sharedInterfaces/publishDialog";
12-
13-
/**
14-
* Extended context type used across all publish project components.
15-
* Combines the base form context with publish-specific actions.
16-
*/
17-
export interface PublishFormContext
18-
extends FormContextProps<IPublishForm, PublishDialogState, PublishDialogFormItemSpec> {
19-
publishNow: () => void;
20-
generatePublishScript: () => void;
21-
selectPublishProfile: () => void;
22-
savePublishProfile: (profileName: string) => void;
23-
}
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { FormContextProps } from "../../../sharedInterfaces/form";
7+
import {
8+
IPublishForm,
9+
PublishDialogFormItemSpec,
10+
PublishDialogState,
11+
} from "../../../sharedInterfaces/publishDialog";
12+
13+
/**
14+
* Extended context type used across all publish project components.
15+
* Combines the base form context with publish-specific actions.
16+
*/
17+
export interface PublishFormContext
18+
extends FormContextProps<IPublishForm, PublishDialogState, PublishDialogFormItemSpec> {
19+
publishNow: () => void;
20+
generatePublishScript: () => void;
21+
selectPublishProfile: () => void;
22+
savePublishProfile: (profileName: string) => void;
23+
}

src/schemaDesigner/schemaDesignerWebviewManager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export class SchemaDesignerWebviewManager {
6464
await mainController.connectionManager.createConnectionDetails(connectionInfo);
6565

6666
await mainController.connectionManager.confirmEntraTokenValidity(connectionInfo);
67+
treeNode.updateConnectionProfile(connectionInfo);
6768

6869
connectionString = await mainController.connectionManager.getConnectionString(
6970
connectionDetails,

src/tableDesigner/tableDesignerWebviewController.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ export class TableDesignerWebviewController extends ReactWebviewPanelController<
157157
);
158158

159159
await this._connectionManager.confirmEntraTokenValidity(connectionInfo);
160+
this._targetNode.updateConnectionProfile(connectionInfo);
160161
const accessToken = connectionInfo.azureAccountToken
161162
? connectionInfo.azureAccountToken
162163
: undefined;

src/utils/utils.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ export function parseEnum<T extends Record<string, string | number>>(
143143
return undefined;
144144
}
145145

146+
/**
147+
* Removes all properties with undefined values from the given object. Null values are kept.
148+
* @returns a Partial of the original object type with only defined (including null) properties.
149+
*/
150+
export function removeUndefinedProperties<T extends object>(source: T): Partial<T> {
151+
if (!source) {
152+
return {};
153+
}
154+
155+
const entries = Object.entries(source).filter(([_key, value]) => value !== undefined);
156+
return Object.fromEntries(entries) as Partial<T>;
157+
}
158+
146159
/**
147160
* Checks if any required fields are missing values in a form.
148161
* Used to determine if form submission buttons should be disabled.

test/unit/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
- Do not edit application/source files unless the refactor demands it. Confirm before editing files outside of /test/unit, and justify why you need to make those changes.
66
- Use Sinon, not TypeMoq. If easily possible, replace TypeMoq mocks/stubs/helpers with Sinon equivalents.
77
- Use a Sinon sandbox (setup/teardown with sinon.createSandbox()); keep helper closures (e.g., createServer) inside setup where the
8-
sandbox is created.
8+
sandbox is created. Similarly, let the teardown handle all the stub restores wherever possible; avoid manual restore() calls in tests unless the test design needs the stub behavior changed.
99
- Default to chai.expect; when checking Sinon interactions, use sinon-chai.
1010
- Avoid Object.defineProperty hacks and (if possible) fake/partial plain objects; use sandbox.createStubInstance(type) and sandbox.stub(obj, 'prop').value(...).
1111
- Add shared Sinon helpers to test/unit/utils.ts when they’ll be reused.

test/unit/sqlDocumentService.test.ts

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import * as vscode from "vscode";
67
import * as sinon from "sinon";
78
import sinonChai from "sinon-chai";
8-
import * as vscode from "vscode";
99
import { expect } from "chai";
1010
import * as chai from "chai";
1111
import * as Constants from "../../src/constants/constants";
1212
import * as LocalizedConstants from "../../src/constants/locConstants";
1313
import MainController from "../../src/controllers/mainController";
14-
import ConnectionManager from "../../src/controllers/connectionManager";
14+
import ConnectionManager, { ConnectionInfo } from "../../src/controllers/connectionManager";
1515
import SqlDocumentService, { ConnectionStrategy } from "../../src/controllers/sqlDocumentService";
16-
import * as Telemetry from "../../src/telemetry/telemetry";
1716
import SqlToolsServerClient from "../../src/languageservice/serviceclient";
18-
import { IConnectionInfo } from "vscode-mssql";
17+
import { IConnectionInfo, IServerInfo } from "vscode-mssql";
18+
import { TreeNodeInfo } from "../../src/objectExplorer/nodes/treeNodeInfo";
19+
import { IConnectionProfile } from "../../src/models/interfaces";
20+
import { ConnectionStore } from "../../src/models/connectionStore";
21+
import { stubTelemetry } from "./utils";
1922

2023
chai.use(sinonChai);
2124

@@ -101,6 +104,7 @@ suite("SqlDocumentService Tests", () => {
101104
});
102105

103106
test("handleNewQueryCommand should create a new query and update recents", async () => {
107+
stubTelemetry(sandbox);
104108
const editor: vscode.TextEditor = {
105109
document: { uri: "test_uri" },
106110
} as any;
@@ -111,19 +115,17 @@ suite("SqlDocumentService Tests", () => {
111115
};
112116
connectionManager.getServerInfo.returns(undefined as any);
113117
connectionManager.handlePasswordBasedCredentials.resolves();
114-
const sendActionStub = sandbox.stub(Telemetry, "sendActionEvent");
115118

116-
const node: any = { connectionProfile: {}, nodeType: "Server" };
119+
const node: TreeNodeInfo = sandbox.createStubInstance(TreeNodeInfo);
120+
sandbox.stub(node, "connectionProfile").get(() => ({}) as IConnectionProfile);
121+
sandbox.stub(node, "nodeType").get(() => "Server");
122+
117123
await sqlDocumentService.handleNewQueryCommand(node, undefined);
118124

119125
expect(newQueryStub).to.have.been.calledOnce;
120126
expect((connectionManager as any).connectionStore.removeRecentlyUsed).to.have.been
121127
.calledOnce;
122128
expect(connectionManager.handlePasswordBasedCredentials).to.have.been.calledOnce;
123-
expect(sendActionStub).to.have.been.calledOnce;
124-
125-
newQueryStub.restore();
126-
sendActionStub.restore();
127129
});
128130

129131
test("handleNewQueryCommand should not create a new connection if new query fails", async () => {
@@ -168,9 +170,14 @@ suite("SqlDocumentService Tests", () => {
168170
});
169171

170172
test("handleNewQueryCommand uses OE selection when exactly one node is selected", async () => {
171-
const nodeConnection = { server: "oeServer" } as any;
173+
const nodeConnection = { server: "oeServer" } as IConnectionProfile;
174+
175+
const selectedNode: TreeNodeInfo = sandbox.createStubInstance(TreeNodeInfo);
176+
sandbox.stub(selectedNode, "connectionProfile").get(() => nodeConnection);
177+
sandbox.stub(selectedNode, "nodeType").get(() => "Server");
178+
172179
mainController.objectExplorerTree = {
173-
selection: [{ connectionProfile: nodeConnection, nodeType: "Database" }],
180+
selection: [selectedNode],
174181
} as any;
175182
connectionManager.handlePasswordBasedCredentials.resolves();
176183
connectionManager.connectionStore = {
@@ -192,6 +199,59 @@ suite("SqlDocumentService Tests", () => {
192199
newQueryStub.restore();
193200
});
194201

202+
test("handleNewQueryCommand refreshes Entra token info on source node", async () => {
203+
stubTelemetry(sandbox);
204+
205+
const oldToken = {
206+
azureAccountToken: "oldToken",
207+
expiresOn: Date.now() / 1000 - 60, // 60 seconds in the past; not that the test actually requires this to be expired
208+
};
209+
210+
const newToken = {
211+
azureAccountToken: "refreshedToken",
212+
expiresOn: oldToken.expiresOn + 600 + 60, // 10 minutes in the future (plus making up for the past offset)
213+
};
214+
215+
const nodeConnection = {
216+
server: "server",
217+
...oldToken,
218+
} as IConnectionProfile;
219+
220+
const node = {
221+
connectionProfile: nodeConnection,
222+
nodeType: "Server",
223+
updateEntraTokenInfo: sandbox.stub(),
224+
} as unknown as TreeNodeInfo;
225+
226+
connectionManager.handlePasswordBasedCredentials.resolves();
227+
228+
const connectionStoreStub = sandbox.createStubInstance(ConnectionStore);
229+
230+
connectionManager.connectionStore = connectionStoreStub;
231+
connectionManager.getServerInfo.returns({} as IServerInfo);
232+
connectionManager.getConnectionInfo.returns({} as ConnectionInfo);
233+
234+
const editor: vscode.TextEditor = {
235+
document: { uri: vscode.Uri.parse("untitled:tokenTest") },
236+
} as vscode.TextEditor;
237+
238+
sandbox.stub(sqlDocumentService, "newQuery").callsFake(async (opts) => {
239+
expect(opts.connectionInfo).to.equal(nodeConnection);
240+
Object.assign(opts.connectionInfo, newToken);
241+
242+
return editor;
243+
});
244+
245+
expect(nodeConnection.azureAccountToken).to.equal(oldToken.azureAccountToken);
246+
expect(nodeConnection.expiresOn).to.equal(oldToken.expiresOn);
247+
248+
await sqlDocumentService.handleNewQueryCommand(node, undefined);
249+
250+
expect(node.updateEntraTokenInfo).to.have.been.calledOnceWith(nodeConnection);
251+
expect(nodeConnection.azureAccountToken).to.equal(newToken.azureAccountToken);
252+
expect(nodeConnection.expiresOn).to.equal(newToken.expiresOn);
253+
});
254+
195255
test("handleNewQueryCommand prompts for connection when no context", async () => {
196256
// clear last active and OE selection
197257
sqlDocumentService["_lastActiveConnectionInfo"] = undefined;

0 commit comments

Comments
 (0)