From 2676d41c723b47ac4b84ebf76e80da79c163f63a Mon Sep 17 00:00:00 2001 From: Lauren Nathan Date: Thu, 28 May 2026 16:10:17 -0400 Subject: [PATCH 1/4] removed paid azure provisioning option; added max vcore option --- extensions/mssql/l10n/bundle.l10n.json | 2 ++ .../src/connectionconfig/azureHelpers.ts | 7 +++-- .../mssql/src/constants/locConstants.ts | 2 ++ .../src/deployment/azureSqlDatabaseHelpers.ts | 14 ++++++++++ .../src/sharedInterfaces/azureSqlDatabase.ts | 1 + .../azureSqlDatabaseFormPage.tsx | 28 ++++++++++--------- localization/xliff/vscode-mssql.xlf | 6 ++++ 7 files changed, 45 insertions(+), 15 deletions(-) diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 1f6193a871..75635e55c3 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -2756,6 +2756,8 @@ "This server only supports SQL Authentication.": "This server only supports SQL Authentication.", "Use SQL Authentication with a valid username and password.": "Use SQL Authentication with a valid username and password.", "Unable to determine the server authentication type.": "Unable to determine the server authentication type.", + "Max vCores": "Max vCores", + "Select Max vCores": "Select Max vCores", "Enter Database Name": "Enter Database Name", "Database Description": "Database Description", "Enter Database Description": "Enter Database Description", diff --git a/extensions/mssql/src/connectionconfig/azureHelpers.ts b/extensions/mssql/src/connectionconfig/azureHelpers.ts index 4cd994bfeb..d00d8f9d0a 100644 --- a/extensions/mssql/src/connectionconfig/azureHelpers.ts +++ b/extensions/mssql/src/connectionconfig/azureHelpers.ts @@ -407,6 +407,7 @@ export class VsCodeAzureHelper { }; freeLimitExhaustionBehavior?: KnownFreeLimitExhaustionBehavior; useFreeLimit?: boolean; + maxVcores?: string; }, ): Promise { const sql = new SqlManagementClient(subscription.credential, subscription.subscriptionId, { @@ -415,13 +416,15 @@ export class VsCodeAzureHelper { const server = await sql.servers.get(resourceGroupName, serverName); + const skuName = options.maxVcores ? `GP_S_Gen5_${options.maxVcores}` : "GP_S_Gen5"; + const freeOfferOptions = options.useFreeLimit ? { sku: { - name: "GP_S_Gen5", + name: skuName, tier: "GeneralPurpose", family: "Gen5", - capacity: 2, + capacity: options.maxVcores ? Number(options.maxVcores) : 2, }, autoPauseDelay: 60, minCapacity: 0.5, diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index 327dc79484..3788dbf795 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -1515,6 +1515,8 @@ export class AzureSqlDatabase { public static serverAuthTypeUnknown = l10n.t( "Unable to determine the server authentication type.", ); + public static maxVcores = l10n.t("Max vCores"); + public static selectMaxVcores = l10n.t("Select Max vCores"); } export class FabricProvisioning { diff --git a/extensions/mssql/src/deployment/azureSqlDatabaseHelpers.ts b/extensions/mssql/src/deployment/azureSqlDatabaseHelpers.ts index 11f970daa6..d7edea9b07 100644 --- a/extensions/mssql/src/deployment/azureSqlDatabaseHelpers.ts +++ b/extensions/mssql/src/deployment/azureSqlDatabaseHelpers.ts @@ -212,6 +212,7 @@ export async function initializeAzureSqlDatabaseState( maintenanceConfig: "", dataSource: "", enableAlwaysEncrypted: false, + maxVcores: "2", }; deploymentController.state.deploymentTypeState = state; @@ -359,6 +360,7 @@ export function registerAzureSqlDatabaseReducers( : undefined, freeLimitExhaustionBehavior: azureSqlState.formState.freeLimitBehavior, useFreeLimit: true, + maxVcores: azureSqlState.formState.maxVcores, }, ); @@ -1321,5 +1323,17 @@ function setAzureSqlDatabaseFormComponents( isAdvancedOption: true, componentWidth: "350px", }), + maxVcores: createFormItem({ + propertyName: "maxVcores", + label: AzureSqlDatabase.maxVcores, + type: FormItemType.Dropdown, + options: [ + { displayName: "1", value: "1" }, + { displayName: "2", value: "2" }, + { displayName: "4", value: "4" }, + ], + isAdvancedOption: true, + placeholder: AzureSqlDatabase.selectMaxVcores, + }), }; } diff --git a/extensions/mssql/src/sharedInterfaces/azureSqlDatabase.ts b/extensions/mssql/src/sharedInterfaces/azureSqlDatabase.ts index 23ee8c0b78..ad138eb5b2 100644 --- a/extensions/mssql/src/sharedInterfaces/azureSqlDatabase.ts +++ b/extensions/mssql/src/sharedInterfaces/azureSqlDatabase.ts @@ -100,6 +100,7 @@ export interface AzureSqlDatabaseFormState { collation: string; maintenanceConfig: string; enableAlwaysEncrypted: boolean; + maxVcores: string; } export interface AzureSqlDatabaseFormItemSpec diff --git a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx index 1291e3b751..c08d2ed90e 100644 --- a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx +++ b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx @@ -8,26 +8,26 @@ import { Button, Card, Link, - Label, + // Label, makeStyles, - Radio, - RadioGroup, + // Radio, + // RadioGroup, Spinner, Text, } from "@fluentui/react-components"; import { - ArrowRight12Regular, + // ArrowRight12Regular, ErrorCircleRegular, GiftRegular, LockClosedRegular, - WarningFilled, + // WarningFilled, } from "@fluentui/react-icons"; import { FormField } from "../../../common/forms/form.component"; import { AzureSqlDatabaseContextProps, AzureSqlDatabaseFormItemSpec, AzureSqlDatabaseFormState, - AzureSqlDatabaseLinks, + // AzureSqlDatabaseLinks, AzureSqlDatabaseState, AZURE_SQL_DB_COMPONENT_ORDER, } from "../../../../sharedInterfaces/azureSqlDatabase"; @@ -148,7 +148,7 @@ const useStyles = makeStyles({ }); import { TagEntry } from "./azureSqlDatabaseDeploymentWizard"; -import { KnownFreeLimitExhaustionBehavior } from "@azure/arm-sql"; +// import { KnownFreeLimitExhaustionBehavior } from "@azure/arm-sql"; interface AzureSqlDatabaseFormPageProps { onValidated?: () => void; @@ -185,15 +185,15 @@ export const AzureSqlDatabaseFormPage: React.FC = ); const hostIp = useAzureSqlDatabaseDeploymentSelector((s) => s.publicIp); - const [localFreeLimitBehavior, setLocalFreeLimitBehavior] = useState( - String(formState.freeLimitBehavior), - ); + // const [localFreeLimitBehavior, setLocalFreeLimitBehavior] = useState( + // String(formState.freeLimitBehavior), + // ); const [isAdvancedDrawerOpen, setIsAdvancedDrawerOpen] = useState(false); const prevFormValidationLoadState = useRef(formValidationLoadState); - useEffect(() => { - setLocalFreeLimitBehavior(String(formState.freeLimitBehavior)); - }, [formState.freeLimitBehavior]); + // useEffect(() => { + // setLocalFreeLimitBehavior(String(formState.freeLimitBehavior)); + // }, [formState.freeLimitBehavior]); useEffect(() => { const changed = prevFormValidationLoadState.current !== formValidationLoadState; @@ -413,6 +413,7 @@ export const AzureSqlDatabaseFormPage: React.FC = {renderFormField("savePassword")} )} + {/*
); From 70dd4435d95f3eda9092c7cf587347d5f0551aab Mon Sep 17 00:00:00 2001 From: Lauren Nathan Date: Thu, 28 May 2026 17:04:39 -0400 Subject: [PATCH 3/4] removed commented code --- .../azureSqlDatabaseFormPage.tsx | 116 +----------------- .../azureSqlDatabaseProvisioningPage.tsx | 32 ----- 2 files changed, 2 insertions(+), 146 deletions(-) diff --git a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx index c08d2ed90e..55d0849e44 100644 --- a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx +++ b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseFormPage.tsx @@ -4,30 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { - Button, - Card, - Link, - // Label, - makeStyles, - // Radio, - // RadioGroup, - Spinner, - Text, -} from "@fluentui/react-components"; -import { - // ArrowRight12Regular, - ErrorCircleRegular, - GiftRegular, - LockClosedRegular, - // WarningFilled, -} from "@fluentui/react-icons"; +import { Button, Card, Link, makeStyles, Spinner, Text } from "@fluentui/react-components"; +import { ErrorCircleRegular, GiftRegular, LockClosedRegular } from "@fluentui/react-icons"; import { FormField } from "../../../common/forms/form.component"; import { AzureSqlDatabaseContextProps, AzureSqlDatabaseFormItemSpec, AzureSqlDatabaseFormState, - // AzureSqlDatabaseLinks, AzureSqlDatabaseState, AZURE_SQL_DB_COMPONENT_ORDER, } from "../../../../sharedInterfaces/azureSqlDatabase"; @@ -148,7 +131,6 @@ const useStyles = makeStyles({ }); import { TagEntry } from "./azureSqlDatabaseDeploymentWizard"; -// import { KnownFreeLimitExhaustionBehavior } from "@azure/arm-sql"; interface AzureSqlDatabaseFormPageProps { onValidated?: () => void; @@ -185,16 +167,9 @@ export const AzureSqlDatabaseFormPage: React.FC = ); const hostIp = useAzureSqlDatabaseDeploymentSelector((s) => s.publicIp); - // const [localFreeLimitBehavior, setLocalFreeLimitBehavior] = useState( - // String(formState.freeLimitBehavior), - // ); const [isAdvancedDrawerOpen, setIsAdvancedDrawerOpen] = useState(false); const prevFormValidationLoadState = useRef(formValidationLoadState); - // useEffect(() => { - // setLocalFreeLimitBehavior(String(formState.freeLimitBehavior)); - // }, [formState.freeLimitBehavior]); - useEffect(() => { const changed = prevFormValidationLoadState.current !== formValidationLoadState; prevFormValidationLoadState.current = formValidationLoadState; @@ -413,93 +388,6 @@ export const AzureSqlDatabaseFormPage: React.FC = {renderFormField("savePassword")} )} - {/* -
-
- - { - setLocalFreeLimitBehavior(data.value); - context.formAction({ - propertyName: "freeLimitBehavior", - isAction: false, - value: data.value, - }); - }}> -
- - - {locConstants.azureSqlDatabase.autoPauseDescription} - -
-
- - - {locConstants.azureSqlDatabase.continueChargesDescription} - -
-
-
-
- {localFreeLimitBehavior === KnownFreeLimitExhaustionBehavior.BillOverUsage && ( - -
- - {locConstants.azureSqlDatabase.continueChargesWarning} -
- - {locConstants.common.learnMore} - - -
- )} - */} {renderFormField("profileName")}
diff --git a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseProvisioningPage.tsx b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseProvisioningPage.tsx index 2063c65ab7..124a9fa3dc 100644 --- a/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseProvisioningPage.tsx +++ b/extensions/mssql/src/webviews/pages/Deployment/AzureSqlDatabase/azureSqlDatabaseProvisioningPage.tsx @@ -4,9 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { makeStyles, tokens } from "@fluentui/react-components"; -// import { DocsLinkCard } from "./docsLinkCard"; import { ApiStatus } from "../../../../sharedInterfaces/webview"; -// import { AzureSqlDatabaseLinks } from "../../../../sharedInterfaces/azureSqlDatabase"; import { locConstants } from "../../../common/locConstants"; import { useAzureSqlDatabaseDeploymentSelector } from "../deploymentSelector"; import { DeploymentStepCard } from "../deploymentStepCard"; @@ -90,30 +88,6 @@ export const AzureSqlDatabaseProvisioningPage: React.FC = () => { if (!provisionLoadState) return undefined; - /* - const isDeploymentComplete = - provisionLoadState === ApiStatus.Loaded && connectionLoadState === ApiStatus.Loaded; - - const whatsNextLinks = [ - { - href: AzureSqlDatabaseLinks.connectQuerySsms, - label: locConstants.azureSqlDatabase.connectAndRunQuery, - }, - { - href: AzureSqlDatabaseLinks.createQuickstart, - label: locConstants.azureSqlDatabase.seedSampleData, - }, - { - href: AzureSqlDatabaseLinks.freeOffer, - label: locConstants.azureSqlDatabase.monitorUsage, - }, - { - href: AzureSqlDatabaseLinks.azureSqlDocs, - label: locConstants.azureSqlDatabase.browseTutorials, - }, - ]; - */ - const stepStatus = provisionLoadState !== ApiStatus.Loaded ? provisionLoadState : connectionLoadState; @@ -200,12 +174,6 @@ export const AzureSqlDatabaseProvisioningPage: React.FC = () => { )}
- {/* isDeploymentComplete && ( - - )*/}
); From 4f0f3ec5b0132177d78b4c11609cb2c4ea77f0cc Mon Sep 17 00:00:00 2001 From: Lauren Nathan Date: Tue, 2 Jun 2026 13:24:09 -0400 Subject: [PATCH 4/4] added unit tests for azure provisioning --- .../mssql/test/unit/azureHelpers.test.ts | 262 +++++++++++ .../test/unit/azureSqlDatabaseHelpers.test.ts | 438 ++++++++++++++++++ 2 files changed, 700 insertions(+) create mode 100644 extensions/mssql/test/unit/azureSqlDatabaseHelpers.test.ts diff --git a/extensions/mssql/test/unit/azureHelpers.test.ts b/extensions/mssql/test/unit/azureHelpers.test.ts index 844f9ef94f..028065e4ec 100644 --- a/extensions/mssql/test/unit/azureHelpers.test.ts +++ b/extensions/mssql/test/unit/azureHelpers.test.ts @@ -437,6 +437,268 @@ suite("Azure Helpers", () => { } }); + suite("getDefaultTenantId", () => { + test("should return empty string when accountId is empty", () => { + const result = azureHelpers.getDefaultTenantId("", mockTenants); + expect(result).to.equal(""); + }); + + test("should return empty string when tenants array is empty", () => { + const result = azureHelpers.getDefaultTenantId("some-account.some-tenant", []); + expect(result).to.equal(""); + }); + + test("should return home tenant ID when it matches a tenant in the list", () => { + const accountId = `someAccountPart.${mockTenants[0].tenantId}`; + const result = azureHelpers.getDefaultTenantId(accountId, mockTenants); + expect(result).to.equal(mockTenants[0].tenantId); + }); + + test("should return first tenant ID when home tenant is not in the list", () => { + const accountId = "someAccountPart.non-existent-tenant-id"; + const result = azureHelpers.getDefaultTenantId(accountId, mockTenants); + expect(result).to.equal(mockTenants[0].tenantId); + }); + + test("should return first tenant ID when accountId has no dot separator", () => { + const accountId = "no-dot-account"; + const result = azureHelpers.getDefaultTenantId(accountId, mockTenants); + expect(result).to.equal(mockTenants[0].tenantId); + }); + }); + + suite("getHomeTenantIdForAccount", () => { + test("should extract tenant ID from account ID string with dot separator", () => { + const result = + azureHelpers.VsCodeAzureHelper.getHomeTenantIdForAccount("accountPart.tenantPart"); + expect(result).to.equal("tenantPart"); + }); + + test("should extract tenant ID from AuthenticationSessionAccountInformation", () => { + const result = azureHelpers.VsCodeAzureHelper.getHomeTenantIdForAccount( + mockAccounts.signedInAccount, + ); + expect(result).to.equal("11111111-1111-1111-1111-111111111111"); + }); + + test("should return undefined when account ID has no dot separator", () => { + const result = + azureHelpers.VsCodeAzureHelper.getHomeTenantIdForAccount("no-dot-account"); + expect(result).to.be.undefined; + }); + + test("should return undefined when account ID is empty", () => { + const result = azureHelpers.VsCodeAzureHelper.getHomeTenantIdForAccount(""); + expect(result).to.be.undefined; + }); + }); + + suite("getAccountObjectId", () => { + test("should extract OID from a valid JWT access token", async () => { + const tokenPayload = { oid: "test-object-id-123", sub: "subject" }; + const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)).toString("base64"); + const mockToken = `header.${encodedPayload}.signature`; + + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().resolves({ accessToken: mockToken }), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = await azureHelpers.VsCodeAzureHelper.getAccountObjectId( + mockSubscription, + { id: "fallback-id.tenant" }, + ); + expect(result).to.equal("test-object-id-123"); + }); + + test("should handle base64url-encoded token payload", async () => { + const tokenPayload = { oid: "url-safe-oid" }; + const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + const mockToken = `header.${encodedPayload}.signature`; + + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().resolves({ accessToken: mockToken }), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = await azureHelpers.VsCodeAzureHelper.getAccountObjectId( + mockSubscription, + { id: "fallback.tenant" }, + ); + expect(result).to.equal("url-safe-oid"); + }); + + test("should fall back to account ID first segment when token decode fails", async () => { + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().rejects(new Error("session error")), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = await azureHelpers.VsCodeAzureHelper.getAccountObjectId( + mockSubscription, + { id: "fallback-oid.tenant-id" }, + ); + expect(result).to.equal("fallback-oid"); + }); + + test("should fall back when access token has no OID claim", async () => { + const tokenPayload = { sub: "subject-only" }; + const encodedPayload = Buffer.from(JSON.stringify(tokenPayload)).toString("base64"); + const mockToken = `header.${encodedPayload}.signature`; + + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().resolves({ accessToken: mockToken }), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = await azureHelpers.VsCodeAzureHelper.getAccountObjectId( + mockSubscription, + { id: "fallback-oid.tenant-id" }, + ); + expect(result).to.equal("fallback-oid"); + }); + + test("should return undefined when no account is provided and token fails", async () => { + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().rejects(new Error("fail")), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = + await azureHelpers.VsCodeAzureHelper.getAccountObjectId(mockSubscription); + expect(result).to.be.undefined; + }); + + test("should fall back when session returns null access token", async () => { + const mockSubscription = { + ...mockSubscriptions[0], + authentication: { + getSession: sandbox.stub().resolves({ accessToken: null }), + }, + } as unknown as import("@microsoft/vscode-azext-azureauth").AzureSubscription; + + const result = await azureHelpers.VsCodeAzureHelper.getAccountObjectId( + mockSubscription, + { id: "fallback-oid.tenant" }, + ); + expect(result).to.equal("fallback-oid"); + }); + }); + + suite("getAccounts (standalone)", () => { + test("should return mapped account options on success", async () => { + const mockAccountService = sandbox.createStubInstance(AzureAccountService); + (mockAccountService.getAccounts as sinon.SinonStub).resolves([ + { + displayInfo: { displayName: "Account 1" }, + key: { id: "acct-1" }, + }, + { + displayInfo: { displayName: "Account 2" }, + key: { id: "acct-2" }, + }, + ]); + + const result = await azureHelpers.getAccounts(mockAccountService, mockLogger); + + expect(result).to.have.lengthOf(2); + expect(result[0]).to.deep.equal({ displayName: "Account 1", value: "acct-1" }); + expect(result[1]).to.deep.equal({ displayName: "Account 2", value: "acct-2" }); + }); + + test("should return empty array and log error on failure", async () => { + sandbox.stub(require("../../src/telemetry/telemetry"), "sendErrorEvent"); + const mockAccountService = sandbox.createStubInstance(AzureAccountService); + (mockAccountService.getAccounts as sinon.SinonStub).rejects( + new Error("Service unavailable"), + ); + + const result = await azureHelpers.getAccounts(mockAccountService, mockLogger); + + expect(result).to.be.an("array").that.is.empty; + expect(mockLogger.error).to.have.been.calledWithMatch("Error loading Azure accounts"); + }); + }); + + suite("getTenants", () => { + test("should return empty array when accountId is empty", async () => { + const result = await azureHelpers.getTenants(mockAzureAccountService, "", mockLogger); + expect(result).to.be.an("array").that.is.empty; + expect(mockLogger.error).to.have.been.calledWithMatch("undefined accountId"); + }); + + test("should return mapped tenant options on success", async () => { + (mockAzureAccountService.getAccount as sinon.SinonStub).resolves({ + displayInfo: { userId: "test-user" }, + properties: { + tenants: [ + { displayName: "Tenant A", id: "tid-a" }, + { displayName: "Tenant B", id: "tid-b" }, + ], + }, + }); + + const result = await azureHelpers.getTenants( + mockAzureAccountService, + "test-user", + mockLogger, + ); + + expect(result).to.have.lengthOf(2); + expect(result[0]).to.deep.equal({ + displayName: "Tenant A (tid-a)", + value: "tid-a", + }); + expect(result[1]).to.deep.equal({ + displayName: "Tenant B (tid-b)", + value: "tid-b", + }); + }); + + test("should return empty array when getAccount throws", async () => { + sandbox.stub(require("../../src/telemetry/telemetry"), "sendErrorEvent"); + (mockAzureAccountService.getAccount as sinon.SinonStub).rejects( + new Error("Network error"), + ); + + const result = await azureHelpers.getTenants( + mockAzureAccountService, + "test-user", + mockLogger, + ); + + expect(result).to.be.an("array").that.is.empty; + expect(mockLogger.error).to.have.been.calledWithMatch("Error loading Azure tenants"); + }); + + test("should return empty array when account is undefined", async () => { + sandbox.stub(require("../../src/telemetry/telemetry"), "sendErrorEvent"); + (mockAzureAccountService.getAccount as sinon.SinonStub).resolves(undefined); + + const result = await azureHelpers.getTenants( + mockAzureAccountService, + "test-user", + mockLogger, + ); + + expect(result).to.be.an("array").that.is.empty; + expect(mockLogger.error).to.have.been.calledWithMatch("undefined account"); + }); + }); + test("fetchBlobsForContainer", async () => { const mockBlobs = [mockAzureResources.blob]; diff --git a/extensions/mssql/test/unit/azureSqlDatabaseHelpers.test.ts b/extensions/mssql/test/unit/azureSqlDatabaseHelpers.test.ts new file mode 100644 index 0000000000..4dea99aec9 --- /dev/null +++ b/extensions/mssql/test/unit/azureSqlDatabaseHelpers.test.ts @@ -0,0 +1,438 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from "sinon"; +import * as chai from "chai"; +import sinonChai from "sinon-chai"; +import { expect } from "chai"; +import { Server } from "@azure/arm-sql"; +import { AzureSqlDatabase } from "../../../src/constants/locConstants"; +import * as telemetry from "../../../src/telemetry/telemetry"; +import { TelemetryActions, TelemetryViews } from "../../../src/sharedInterfaces/telemetry"; +import { ApiStatus } from "../../../src/sharedInterfaces/webview"; +import { AuthenticationType } from "../../../src/sharedInterfaces/connectionDialog"; +import { + AzureSqlDatabaseState, + AzureSqlDatabaseFormItemSpec, + AzureSqlDatabaseFormState, + AZURE_SQL_DB_COMPONENT_ORDER, +} from "../../../src/sharedInterfaces/azureSqlDatabase"; +import { + applyServerAuthSettings, + reloadAzureComponentsDownstream, + sendAzureSqlDatabaseCloseEventTelemetry, +} from "../../../src/deployment/azureSqlDatabaseHelpers"; +import { FormItemType } from "../../../src/sharedInterfaces/form"; + +chai.use(sinonChai); + +/** + * Creates a minimal AzureSqlDatabaseState for testing. + * Provides default form state, form components, and azure component statuses. + */ +function createTestState(overrides?: Partial): AzureSqlDatabaseState { + const state = new AzureSqlDatabaseState({ + formState: { + accountId: "test-account", + tenantId: "test-tenant", + subscriptionId: "test-sub", + resourceGroup: "test-rg", + serverName: "", + databaseName: "", + authenticationType: AuthenticationType.AzureMFA, + userName: "", + password: "", + savePassword: false, + freeLimitBehavior: "AutoPause", + profileName: "", + groupId: "", + collation: "SQL_Latin1_General_CP1_CI_AS", + maintenanceConfig: "", + dataSource: "", + enableAlwaysEncrypted: false, + maxVcores: "2", + } as AzureSqlDatabaseFormState, + formComponents: createMinimalFormComponents(), + azureComponentStatuses: { + accountId: ApiStatus.Loaded, + tenantId: ApiStatus.Loaded, + subscriptionId: ApiStatus.Loaded, + resourceGroup: ApiStatus.Loaded, + serverName: ApiStatus.Loaded, + maintenanceConfig: ApiStatus.Loaded, + }, + servers: [], + subscriptions: [], + tenants: [], + resourceGroups: [], + accounts: [], + locations: [], + maintenanceConfigs: [], + ...overrides, + }); + return state; +} + +function createMinimalFormComponents(): Partial< + Record +> { + const makeComponent = (propertyName: string): AzureSqlDatabaseFormItemSpec => + ({ + propertyName, + label: propertyName, + type: FormItemType.Input, + required: false, + isAdvancedOption: false, + options: [{ displayName: "opt1", value: "val1" }], + tooltip: "", + componentWidth: "", + }) as AzureSqlDatabaseFormItemSpec; + + return { + accountId: makeComponent("accountId"), + tenantId: makeComponent("tenantId"), + subscriptionId: makeComponent("subscriptionId"), + resourceGroup: makeComponent("resourceGroup"), + serverName: makeComponent("serverName"), + databaseName: makeComponent("databaseName"), + maintenanceConfig: makeComponent("maintenanceConfig"), + }; +} + +function createTestServer( + name: string, + opts?: { + hasAdministrators?: boolean; + azureADOnly?: boolean; + adminLogin?: string; + fullyQualifiedDomainName?: string; + }, +): Server { + const server: Server = { + name, + location: "eastus", + administrators: opts?.hasAdministrators + ? { azureADOnlyAuthentication: opts.azureADOnly ?? false } + : undefined, + administratorLogin: opts?.adminLogin, + fullyQualifiedDomainName: opts?.fullyQualifiedDomainName, + }; + return server; +} + +suite("azureSqlDatabaseHelpers", () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + }); + + teardown(() => { + sandbox.restore(); + }); + + // ─── applyServerAuthSettings ───────────────────────────────────────────── + + suite("applyServerAuthSettings", () => { + test("should preserve auth when serverCreatedWithAuth is true", () => { + const state = createTestState({ + serverCreatedWithAuth: true, + }); + state.formState.authenticationType = AuthenticationType.SqlLogin; + state.formState.userName = "existingUser"; + state.formState.password = "existingPass"; + + applyServerAuthSettings(state, "any-server"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.SqlLogin); + expect(state.formState.userName).to.equal("existingUser"); + expect(state.formState.password).to.equal("existingPass"); + }); + + test("should default to AzureMFA when server is not found", () => { + const state = createTestState(); + state.formState.userName = "oldUser"; + state.formState.password = "oldPass"; + state.formState.savePassword = true; + + applyServerAuthSettings(state, "nonexistent-server"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.AzureMFA); + expect(state.formState.userName).to.equal(""); + expect(state.formState.password).to.equal(""); + expect(state.formState.savePassword).to.equal(false); + }); + + test("should detect AzureMFA when azureADOnlyAuthentication is true", () => { + const server = createTestServer("myserver", { + hasAdministrators: true, + azureADOnly: true, + adminLogin: "admin", + }); + const state = createTestState({ servers: [server] }); + + applyServerAuthSettings(state, "myserver"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.AzureMFA); + expect(state.formState.userName).to.equal(""); + expect(state.formState.password).to.equal(""); + expect(state.formComponents.serverName!.tooltip).to.equal( + AzureSqlDatabase.serverTooltipMFA, + ); + expect(state.formComponents.databaseName!.tooltip).to.equal( + AzureSqlDatabase.databaseTooltipMFA, + ); + }); + + test("should detect AzureMFAAndUser when administrators exist but azureADOnly is false", () => { + const server = createTestServer("myserver", { + hasAdministrators: true, + azureADOnly: false, + adminLogin: "sqladmin", + }); + const state = createTestState({ servers: [server] }); + + applyServerAuthSettings(state, "myserver"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.AzureMFAAndUser); + expect(state.formState.userName).to.equal("sqladmin"); + expect(state.formState.password).to.equal(""); + expect(state.formComponents.serverName!.tooltip).to.equal( + AzureSqlDatabase.serverTooltipMFAAndUser, + ); + expect(state.formComponents.databaseName!.tooltip).to.equal( + AzureSqlDatabase.databaseTooltipMFAAndUser, + ); + }); + + test("should detect SqlLogin when no administrators property exists", () => { + const server = createTestServer("myserver", { + hasAdministrators: false, + adminLogin: "sa", + }); + const state = createTestState({ servers: [server] }); + + applyServerAuthSettings(state, "myserver"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.SqlLogin); + expect(state.formState.userName).to.equal("sa"); + expect(state.formComponents.serverName!.tooltip).to.equal( + AzureSqlDatabase.serverTooltipSqlLogin, + ); + expect(state.formComponents.databaseName!.tooltip).to.equal( + AzureSqlDatabase.databaseTooltipSqlLogin, + ); + }); + + test("should default adminLogin to empty string when administratorLogin is undefined", () => { + const server = createTestServer("myserver", { + hasAdministrators: false, + }); + const state = createTestState({ servers: [server] }); + + applyServerAuthSettings(state, "myserver"); + + expect(state.formState.userName).to.equal(""); + }); + + test("should clear savePassword when server changes", () => { + const server = createTestServer("myserver", { + hasAdministrators: true, + azureADOnly: true, + }); + const state = createTestState({ servers: [server] }); + state.formState.savePassword = true; + + applyServerAuthSettings(state, "myserver"); + + expect(state.formState.savePassword).to.equal(false); + }); + }); + + // ─── reloadAzureComponentsDownstream ───────────────────────────────────── + + suite("reloadAzureComponentsDownstream", () => { + test("should reset all components downstream of accountId", () => { + const state = createTestState(); + state.formState.tenantId = "t1"; + state.formState.subscriptionId = "s1"; + state.formState.resourceGroup = "rg1"; + state.formState.serverName = "srv1"; + + reloadAzureComponentsDownstream(state, "accountId"); + + expect(state.formState.tenantId).to.equal(""); + expect(state.formState.subscriptionId).to.equal(""); + expect(state.formState.resourceGroup).to.equal(""); + expect(state.formState.serverName).to.equal(""); + + for (const comp of AZURE_SQL_DB_COMPONENT_ORDER.slice(1)) { + expect(state.azureComponentStatuses[comp]).to.equal(ApiStatus.NotStarted); + } + }); + + test("should reset only components downstream of subscriptionId", () => { + const state = createTestState(); + state.formState.tenantId = "t1"; + state.formState.subscriptionId = "s1"; + state.formState.resourceGroup = "rg1"; + state.formState.serverName = "srv1"; + + reloadAzureComponentsDownstream(state, "subscriptionId"); + + // Upstream components should remain untouched + expect(state.formState.tenantId).to.equal("t1"); + expect(state.formState.subscriptionId).to.equal("s1"); + expect(state.azureComponentStatuses["tenantId"]).to.equal(ApiStatus.Loaded); + + // Downstream components should be reset + expect(state.formState.resourceGroup).to.equal(""); + expect(state.formState.serverName).to.equal(""); + expect(state.azureComponentStatuses["resourceGroup"]).to.equal(ApiStatus.NotStarted); + expect(state.azureComponentStatuses["serverName"]).to.equal(ApiStatus.NotStarted); + }); + + test("should reset maintenance config when subscriptionId resets", () => { + const state = createTestState(); + state.formState.maintenanceConfig = "config-id"; + state.maintenanceConfigs = [{ name: "SQL_Default", id: "config-id" }]; + state.formComponents.maintenanceConfig!.options = [ + { displayName: "SQL_Default", value: "config-id" }, + ]; + + reloadAzureComponentsDownstream(state, "accountId"); + + expect(state.formState.maintenanceConfig).to.equal(""); + expect(state.azureComponentStatuses["maintenanceConfig"]).to.equal( + ApiStatus.NotStarted, + ); + expect(state.maintenanceConfigs).to.deep.equal([]); + expect(state.formComponents.maintenanceConfig!.options).to.deep.equal([]); + }); + + test("should reset maintenance config when resourceGroup resets", () => { + const state = createTestState(); + state.formState.maintenanceConfig = "config-id"; + + reloadAzureComponentsDownstream(state, "subscriptionId"); + + expect(state.formState.maintenanceConfig).to.equal(""); + expect(state.azureComponentStatuses["maintenanceConfig"]).to.equal( + ApiStatus.NotStarted, + ); + }); + + test("should clear auth fields when serverName resets", () => { + const state = createTestState(); + state.formState.authenticationType = AuthenticationType.AzureMFA; + state.formState.userName = "user"; + state.formState.password = "pass"; + state.formState.savePassword = true; + state.serverCreatedWithAuth = true; + + reloadAzureComponentsDownstream(state, "resourceGroup"); + + expect(state.formState.authenticationType).to.equal(AuthenticationType.SqlLogin); + expect(state.formState.userName).to.equal(""); + expect(state.formState.password).to.equal(""); + expect(state.formState.savePassword).to.equal(false); + expect(state.serverCreatedWithAuth).to.equal(false); + }); + + test("should clear form component options for downstream components", () => { + const state = createTestState(); + + reloadAzureComponentsDownstream(state, "tenantId"); + + expect(state.formComponents.subscriptionId!.options).to.deep.equal([]); + expect(state.formComponents.resourceGroup!.options).to.deep.equal([]); + expect(state.formComponents.serverName!.options).to.deep.equal([]); + }); + + test("should do nothing for an unknown component name", () => { + const state = createTestState(); + state.formState.tenantId = "t1"; + state.formState.serverName = "srv1"; + + reloadAzureComponentsDownstream(state, "nonexistent"); + + expect(state.formState.tenantId).to.equal("t1"); + expect(state.formState.serverName).to.equal("srv1"); + }); + + test("should do nothing when called with the last component in the order", () => { + const state = createTestState(); + state.formState.serverName = "srv1"; + state.formState.resourceGroup = "rg1"; + + reloadAzureComponentsDownstream(state, "serverName"); + + // serverName is last in the order, so nothing downstream to reset + expect(state.formState.serverName).to.equal("srv1"); + expect(state.formState.resourceGroup).to.equal("rg1"); + }); + }); + + // ─── sendAzureSqlDatabaseCloseEventTelemetry ───────────────────────────── + + suite("sendAzureSqlDatabaseCloseEventTelemetry", () => { + let sendActionEventStub: sinon.SinonStub; + + setup(() => { + sendActionEventStub = sandbox.stub(telemetry, "sendActionEvent"); + }); + + test("should send telemetry with error message when present", () => { + const state = createTestState({ + errorMessage: "Something went wrong", + provisionLoadState: ApiStatus.Error, + }); + + sendAzureSqlDatabaseCloseEventTelemetry(state); + + expect(sendActionEventStub).to.have.been.calledWithMatch( + TelemetryViews.AzureSqlDatabase, + TelemetryActions.FinishAzureSqlDatabaseDeployment, + sinon.match({ + errorMessage: "Something went wrong", + provisionState: ApiStatus.Error, + }), + ); + }); + + test("should send telemetry with empty error message on success", () => { + const state = createTestState({ + provisionLoadState: ApiStatus.Loaded, + }); + + sendAzureSqlDatabaseCloseEventTelemetry(state); + + expect(sendActionEventStub).to.have.been.calledWithMatch( + TelemetryViews.AzureSqlDatabase, + TelemetryActions.FinishAzureSqlDatabaseDeployment, + sinon.match({ + errorMessage: "", + provisionState: ApiStatus.Loaded, + }), + ); + }); + + test("should send telemetry with not-started provision state", () => { + const state = createTestState({ + provisionLoadState: ApiStatus.NotStarted, + }); + + sendAzureSqlDatabaseCloseEventTelemetry(state); + + expect(sendActionEventStub).to.have.been.calledWithMatch( + TelemetryViews.AzureSqlDatabase, + TelemetryActions.FinishAzureSqlDatabaseDeployment, + sinon.match({ + provisionState: ApiStatus.NotStarted, + }), + ); + }); + }); +});