diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index 01df9a9c2..531a45f58 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -89,6 +89,9 @@ export class CapabilityNames { public static readonly EnableMongo: string = "EnableMongo"; public static readonly EnableServerless: string = "EnableServerless"; public static readonly EnableNoSQLVectorSearch: string = "EnableNoSQLVectorSearch"; + public static readonly EnableNoSQLFullTextSearch: string = "EnableNoSQLFullTextSearch"; + public static readonly EnableDataMasking: string = "EnableDataMasking"; + public static readonly EnableDynamicDataMasking: string = "EnableDynamicDataMasking"; public static readonly EnableNoSQLFullTextSearchPreviewFeatures: string = "EnableNoSQLFullTextSearchPreviewFeatures"; } diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 7dfe33bbb..dd8b0b471 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -163,6 +163,7 @@ export interface Collection extends Resource { geospatialConfig?: GeospatialConfig; vectorEmbeddingPolicy?: VectorEmbeddingPolicy; fullTextPolicy?: FullTextPolicy; + dataMaskingPolicy?: DataMaskingPolicy; schema?: ISchema; requestSchema?: () => void; computedProperties?: ComputedProperties; @@ -227,6 +228,18 @@ export interface ComputedProperty { export type ComputedProperties = ComputedProperty[]; +export interface DataMaskingPolicy { + includedPaths: Array<{ + path: string; + strategy: string; + startPosition: number; + length: number; + }>; + excludedPaths: string[]; + policyFormatVersion: number; + isPolicyEnabled: boolean; +} + export interface MaterializedView { id: string; _rid: string; diff --git a/src/Contracts/ViewModels.ts b/src/Contracts/ViewModels.ts index 6963129db..a8f6e0c9c 100644 --- a/src/Contracts/ViewModels.ts +++ b/src/Contracts/ViewModels.ts @@ -140,6 +140,7 @@ export interface Collection extends CollectionBase { requestSchema?: () => void; vectorEmbeddingPolicy: ko.Observable; fullTextPolicy: ko.Observable; + dataMaskingPolicy: ko.Observable; indexingPolicy: ko.Observable; uniqueKeyPolicy: DataModels.UniqueKeyPolicy; usageSizeInKB: ko.Observable; diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 186ba7f3b..9d643d4e0 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -27,6 +27,12 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({ changeFeedPolicy: undefined, analyticalStorageTtl: undefined, geospatialConfig: undefined, + dataMaskingPolicy: { + includedPaths: [], + excludedPaths: ["/excludedPath"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }, indexes: [], }), })); @@ -95,7 +101,6 @@ describe("SettingsComponent", () => { const settingsComponentInstance = wrapper.instance() as SettingsComponent; expect(settingsComponentInstance.hasProvisioningTypeChanged()).toEqual(false); wrapper.setState({ - userCanChangeProvisioningTypes: true, isAutoPilotSelected: true, wasAutopilotOriginallySet: false, autoPilotThroughput: 1000, @@ -289,6 +294,167 @@ describe("SettingsComponent", () => { expect(wrapper.state("isThroughputBucketsSaveable")).toBe(false); }); + + it("should handle data masking policy updates correctly", async () => { + updateUserContext({ + apiType: "SQL", + authType: AuthType.AAD, + }); + + const wrapper = shallow(); + const settingsComponentInstance = wrapper.instance() as SettingsComponent; + + wrapper.setState({ + dataMaskingContent: { + includedPaths: [], + excludedPaths: ["/excludedPath"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }, + dataMaskingContentBaseline: { + includedPaths: [], + excludedPaths: [], + policyFormatVersion: 2, + isPolicyEnabled: false, + }, + isDataMaskingDirty: true, + }); + + await settingsComponentInstance.onSaveClick(); + + // The test needs to match what onDataMaskingContentChange returns + expect(updateCollection).toHaveBeenCalled(); + + expect(wrapper.state("isDataMaskingDirty")).toBe(false); + expect(wrapper.state("dataMaskingContentBaseline")).toEqual({ + includedPaths: [], + excludedPaths: ["/excludedPath"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }); + }); + + it("should validate data masking policy content", () => { + const wrapper = shallow(); + const settingsComponentInstance = wrapper.instance() as SettingsComponent; + + // Test with invalid data structure + // Use invalid data type for testing validation + type InvalidPolicy = Omit & { includedPaths: string }; + const invalidPolicy: InvalidPolicy = { + includedPaths: "invalid", + excludedPaths: [], + policyFormatVersion: 2, + isPolicyEnabled: false, + }; + // Use type assertion since we're deliberately testing with invalid data + settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy); + + // State should update with the content but also set validation errors + expect(wrapper.state("dataMaskingContent")).toEqual({ + includedPaths: "invalid", + excludedPaths: [], + policyFormatVersion: 2, + isPolicyEnabled: false, + }); + expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]); + + // Test with valid data + const validPolicy = { + includedPaths: [ + { + path: "/path1", + strategy: "mask", + startPosition: 0, + length: 4, + }, + ], + excludedPaths: ["/excludedPath"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }; + + settingsComponentInstance["onDataMaskingContentChange"](validPolicy); + + // State should update with valid data and no validation errors + expect(wrapper.state("dataMaskingContent")).toEqual(validPolicy); + expect(wrapper.state("dataMaskingValidationErrors")).toEqual([]); + }); + + it("should handle data masking discard correctly", () => { + const wrapper = shallow(); + const settingsComponentInstance = wrapper.instance() as SettingsComponent; + + const baselinePolicy = { + includedPaths: [ + { + path: "/basePath", + strategy: "mask", + startPosition: 0, + length: 4, + }, + ], + excludedPaths: ["/excludedPath1"], + policyFormatVersion: 2, + isPolicyEnabled: false, + }; + + const modifiedPolicy = { + includedPaths: [ + { + path: "/newPath", + strategy: "mask", + startPosition: 1, + length: 5, + }, + ], + excludedPaths: ["/excludedPath2"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }; + + // Set initial state + wrapper.setState({ + dataMaskingContent: modifiedPolicy, + dataMaskingContentBaseline: baselinePolicy, + isDataMaskingDirty: true, + }); + + // Call revert + settingsComponentInstance.onRevertClick(); + + // Verify state is reset + expect(wrapper.state("dataMaskingContent")).toEqual(baselinePolicy); + expect(wrapper.state("isDataMaskingDirty")).toBe(false); + expect(wrapper.state("shouldDiscardDataMasking")).toBe(true); + }); + + it("should disable save button when data masking has validation errors", () => { + const wrapper = shallow(); + const settingsComponentInstance = wrapper.instance() as SettingsComponent; + + // Initially, save button should be disabled + expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false); + + // Make data masking dirty with valid data + wrapper.setState({ + isDataMaskingDirty: true, + dataMaskingValidationErrors: [], + }); + expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true); + + // Add validation errors - save should be disabled + wrapper.setState({ + dataMaskingValidationErrors: ["includedPaths must be an array"], + }); + expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(false); + + // Clear validation errors - save should be enabled again + wrapper.setState({ + dataMaskingValidationErrors: [], + }); + expect(settingsComponentInstance.isSaveSettingsButtonEnabled()).toBe(true); + }); }); describe("SettingsComponent - indexing policy subscription", () => { diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 4d9531904..d010bd725 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.tsx @@ -16,7 +16,7 @@ import { import { useIndexingPolicyStore } from "Explorer/Tabs/QueryTab/ResultsView"; import { useDatabases } from "Explorer/useDatabases"; import { isFabricNative } from "Platform/Fabric/FabricUtil"; -import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; +import { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import * as React from "react"; import DiscardIcon from "../../../../images/discard.svg"; @@ -48,6 +48,7 @@ import { ConflictResolutionComponent, ConflictResolutionComponentProps, } from "./SettingsSubComponents/ConflictResolutionComponent"; +import { DataMaskingComponent, DataMaskingComponentProps } from "./SettingsSubComponents/DataMaskingComponent"; import { GlobalSecondaryIndexComponent, GlobalSecondaryIndexComponentProps, @@ -151,6 +152,12 @@ export interface SettingsComponentState { conflictResolutionPolicyProcedureBaseline: string; isConflictResolutionDirty: boolean; + dataMaskingContent: DataModels.DataMaskingPolicy; + dataMaskingContentBaseline: DataModels.DataMaskingPolicy; + shouldDiscardDataMasking: boolean; + isDataMaskingDirty: boolean; + dataMaskingValidationErrors: string[]; + selectedTab: SettingsV2TabTypes; } @@ -258,6 +265,12 @@ export class SettingsComponent extends React.Component { - if (this.isOfferReplacePending()) { + if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) { return false; } @@ -353,6 +366,10 @@ export class SettingsComponent extends React.Component 0) { + return false; + } + return ( this.state.isScaleSaveable || this.state.isSubSettingsSaveable || @@ -360,12 +377,16 @@ export class SettingsComponent extends React.Component { + if (this.props.settingsTab.isExecuting()) { + return false; + } return ( this.state.isScaleDiscardable || this.state.isSubSettingsDiscardable || @@ -373,6 +394,7 @@ export class SettingsComponent extends React.Component { + if (this.props.settingsTab.isExecuting()) { + return; + } trace(Action.SettingsV2Discarded, ActionModifiers.Mark, { message: "Settings Discarded", }); @@ -497,6 +519,10 @@ export class SettingsComponent extends React.Component this.setState({ isComputedPropertiesDirty: isComputedPropertiesDirty }); + private onDataMaskingContentChange = (newDataMasking: DataModels.DataMaskingPolicy): void => { + if (!newDataMasking.excludedPaths) { + newDataMasking.excludedPaths = []; + } + if (!newDataMasking.includedPaths) { + newDataMasking.includedPaths = []; + } + + const validationErrors = []; + if (!Array.isArray(newDataMasking.includedPaths)) { + validationErrors.push("includedPaths must be an array"); + } + if (!Array.isArray(newDataMasking.excludedPaths)) { + validationErrors.push("excludedPaths must be an array"); + } + if (typeof newDataMasking.policyFormatVersion !== "number") { + validationErrors.push("policyFormatVersion must be a number"); + } + if (typeof newDataMasking.isPolicyEnabled !== "boolean") { + validationErrors.push("isPolicyEnabled must be a boolean"); + } + + this.setState({ + dataMaskingContent: newDataMasking, + dataMaskingValidationErrors: validationErrors, + }); + }; + + private resetShouldDiscardDataMasking = (): void => this.setState({ shouldDiscardDataMasking: false }); + + private onDataMaskingDirtyChange = (isDataMaskingDirty: boolean): void => + this.setState({ isDataMaskingDirty: isDataMaskingDirty }); + private calculateTotalThroughputUsed = (): void => { this.totalThroughputUsed = 0; (useDatabases.getState().databases || []).forEach(async (database) => { @@ -783,6 +842,12 @@ export class SettingsComponent extends React.Component { const buttons: CommandButtonComponentProps[] = []; + const isExecuting = this.props.settingsTab.isExecuting(); if (this.saveSettingsButton.isVisible()) { const label = "Save"; buttons.push({ @@ -848,7 +916,7 @@ export class SettingsComponent extends React.Component { + if (isExecuting) { + return; + } + this.onRevertClick(); + }, commandButtonLabel: label, ariaLabel: label, hasPopup: false, - disabled: !this.discardSettingsChangesButton.isEnabled(), + disabled: isExecuting || !this.discardSettingsChangesButton.isEnabled(), }); } return buttons; @@ -980,7 +1053,8 @@ export class SettingsComponent extends React.Component { + const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking); + const isSqlAccount = userContext.apiType === "SQL"; + + return isSqlAccount && hasDataMaskingCapability; // Only show for SQL accounts with DDM capability + }; + + if (shouldEnableDDM()) { + const dataMaskingComponentProps: DataMaskingComponentProps = { + shouldDiscardDataMasking: this.state.shouldDiscardDataMasking, + resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking, + dataMaskingContent: this.state.dataMaskingContent, + dataMaskingContentBaseline: this.state.dataMaskingContentBaseline, + onDataMaskingContentChange: this.onDataMaskingContentChange, + onDataMaskingDirtyChange: this.onDataMaskingDirtyChange, + validationErrors: this.state.dataMaskingValidationErrors, + }; + + tabs.push({ + tab: SettingsV2TabTypes.DataMaskingTab, + content: , + }); + } + if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) { tabs.push({ tab: SettingsV2TabTypes.ThroughputBucketsTab, diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx index 06a9d4a0c..72ae9f783 100644 --- a/src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.test.tsx @@ -69,13 +69,111 @@ describe("SettingsUtils functions", () => { expect(wrapper).toMatchSnapshot(); }); - it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => { - const prices = getRuPriceBreakdown(500, "", 1, false, false); - expect(prices.hourlyPrice).toBe(0.04); - expect(prices.dailyPrice).toBe(0.96); - expect(prices.monthlyPrice).toBe(29.2); - expect(prices.pricePerRu).toBe(0.00008); - expect(prices.currency).toBe("USD"); - expect(prices.currencySign).toBe("$"); + describe("getRuPriceBreakdown", () => { + it("should return correct price breakdown for a manual RU setting of 500, 1 region, multimaster disabled", () => { + const prices = getRuPriceBreakdown(500, "", 1, false, false); + expect(prices.hourlyPrice).toBe(0.04); + expect(prices.dailyPrice).toBe(0.96); + expect(prices.monthlyPrice).toBe(29.2); + expect(prices.pricePerRu).toBe(0.00008); + expect(prices.currency).toBe("USD"); + expect(prices.currencySign).toBe("$"); + }); + + it("should return correct price breakdown for autoscale", () => { + const prices = getRuPriceBreakdown(1000, "", 1, false, true); + // For autoscale, the baseline RU is 10% of max RU + expect(prices.hourlyPrice).toBe(0.12); // Higher because autoscale pricing is different + expect(prices.dailyPrice).toBe(2.88); // hourlyPrice * 24 + expect(prices.monthlyPrice).toBe(87.6); // hourlyPrice * 730 + expect(prices.pricePerRu).toBe(0.00012); // Autoscale price per RU + expect(prices.currency).toBe("USD"); + expect(prices.currencySign).toBe("$"); + }); + + it("should return correct price breakdown for multimaster", () => { + const prices = getRuPriceBreakdown(500, "", 2, true, false); + // For multimaster with 2 regions, price is multiplied by 4 + expect(prices.hourlyPrice).toBe(0.16); // Base price * 4 + expect(prices.dailyPrice).toBe(3.84); // hourlyPrice * 24 + expect(prices.monthlyPrice).toBe(116.8); // hourlyPrice * 730 + expect(prices.pricePerRu).toBe(0.00016); // Base price per RU * 2 (regions) * 2 (multimaster) + expect(prices.currency).toBe("USD"); + expect(prices.currencySign).toBe("$"); + }); + }); + + describe("message formatting", () => { + it("should format throughput apply delayed message correctly", () => { + const message = getThroughputApplyDelayedMessage(false, 1000, "RU/s", "testDb", "testColl", 2000); + const wrapper = shallow(message); + const text = wrapper.text(); + expect(text).toContain("testDb"); + expect(text).toContain("testColl"); + expect(text).toContain("Current manual throughput: 1000 RU/s"); + expect(text).toContain("Target manual throughput: 2000"); + }); + + it("should format autoscale throughput message correctly", () => { + const message = getThroughputApplyDelayedMessage(true, 1000, "RU/s", "testDb", "testColl", 2000); + const wrapper = shallow(message); + const text = wrapper.text(); + expect(text).toContain("Current autoscale throughput: 100 - 1000 RU/s"); + expect(text).toContain("Target autoscale throughput: 200 - 2000 RU/s"); + }); + }); + + describe("estimated spending element", () => { + // Mock Stack component since we're using shallow rendering + const mockStack = ({ children }: { children: React.ReactNode }) =>
{children}
; + + beforeEach(() => { + jest.mock("@fluentui/react", () => ({ + ...jest.requireActual("@fluentui/react"), + Stack: mockStack, + })); + }); + + afterEach(() => { + jest.resetModules(); + }); + + it("should render correct spending info for manual throughput", () => { + const costElement =
Cost
; + const priceBreakdown: PriceBreakdown = { + hourlyPrice: 1.0, + dailyPrice: 24.0, + monthlyPrice: 730.0, + pricePerRu: 0.0001, + currency: "USD", + currencySign: "$", + }; + + const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, false); + const wrapper = shallow(element); + const spendElement = wrapper.find("#throughputSpendElement"); + + expect(spendElement.find("span").at(0).text()).toBe("1 region"); + expect(spendElement.find("span").at(1).text()).toBe("1000 RU/s"); + expect(spendElement.find("span").at(2).text()).toBe("$0.0001/RU"); + }); + + it("should render correct spending info for autoscale throughput", () => { + const costElement =
Cost
; + const priceBreakdown: PriceBreakdown = { + hourlyPrice: 1.0, + dailyPrice: 24.0, + monthlyPrice: 730.0, + pricePerRu: 0.0001, + currency: "USD", + currencySign: "$", + }; + + const element = getEstimatedSpendingElement(costElement, 1000, 1, priceBreakdown, true); + const wrapper = shallow(element); + const spendElement = wrapper.find("#throughputSpendElement"); + + expect(spendElement.find("span").at(1).text()).toBe("100 RU/s - 1000 RU/s"); + }); }); }); diff --git a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx index 1fe4536f0..a703771c7 100644 --- a/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsRenderUtils.tsx @@ -61,7 +61,7 @@ export interface PriceBreakdown { currencySign: string; } -export type editorType = "indexPolicy" | "computedProperties"; +export type editorType = "indexPolicy" | "computedProperties" | "dataMasking"; export const infoAndToolTipTextStyle: ITextStyles = { root: { fontSize: 14, color: "windowtext" } }; @@ -170,6 +170,14 @@ export const messageBarStyles: Partial = { text: { fontSize: 14 }, }; +export const unsavedEditorMessageBarStyles: Partial = { + root: { + marginTop: "5px", + padding: "8px 12px", + }, + text: { fontSize: 14 }, +}; + export const throughputUnit = "RU/s"; export function onRenderRow(props: IDetailsRowProps): JSX.Element { @@ -259,7 +267,12 @@ export const ttlWarning: JSX.Element = ( export const unsavedEditorWarningMessage = (editor: editorType): JSX.Element => ( You have not saved the latest changes made to your{" "} - {editor === "indexPolicy" ? "indexing policy" : "computed properties"}. Please click save to confirm the changes. + {editor === "indexPolicy" + ? "indexing policy" + : editor === "dataMasking" + ? "data masking policy" + : "computed properties"} + . Please click save to confirm the changes. ); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx new file mode 100644 index 000000000..4847df7ea --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx @@ -0,0 +1,212 @@ +import { MessageBar, MessageBarType } from "@fluentui/react"; +import { mount } from "enzyme"; +import React from "react"; +import * as DataModels from "../../../../Contracts/DataModels"; +import { DataMaskingComponent } from "./DataMaskingComponent"; + +const mockGetValue = jest.fn(); +const mockSetValue = jest.fn(); +const mockOnDidChangeContent = jest.fn(); +const mockGetModel = jest.fn(() => ({ + getValue: mockGetValue, + setValue: mockSetValue, + onDidChangeContent: mockOnDidChangeContent, +})); + +const mockEditor = { + getModel: mockGetModel, + dispose: jest.fn(), +}; + +jest.mock("../../../LazyMonaco", () => ({ + loadMonaco: jest.fn(() => + Promise.resolve({ + editor: { + create: jest.fn(() => mockEditor), + }, + }), + ), +})); + +jest.mock("../../../../Utils/CapabilityUtils", () => ({ + isCapabilityEnabled: jest.fn().mockReturnValue(true), +})); + +describe("DataMaskingComponent", () => { + const mockProps = { + shouldDiscardDataMasking: false, + resetShouldDiscardDataMasking: jest.fn(), + dataMaskingContent: undefined as DataModels.DataMaskingPolicy, + dataMaskingContentBaseline: undefined as DataModels.DataMaskingPolicy, + onDataMaskingContentChange: jest.fn(), + onDataMaskingDirtyChange: jest.fn(), + validationErrors: [] as string[], + }; + + const samplePolicy: DataModels.DataMaskingPolicy = { + includedPaths: [ + { + path: "/test", + strategy: "Default", + startPosition: 0, + length: -1, + }, + ], + excludedPaths: [], + policyFormatVersion: 2, + isPolicyEnabled: false, + }; + + let changeContentCallback: () => void; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetValue.mockReturnValue(JSON.stringify(samplePolicy)); + mockOnDidChangeContent.mockImplementation((callback) => { + changeContentCallback = callback; + }); + }); + + it("renders without crashing", async () => { + const wrapper = mount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + expect(wrapper.exists()).toBeTruthy(); + }); + + it("displays warning message when content is dirty", async () => { + const wrapper = mount( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Verify editor div is rendered + const editorDiv = wrapper.find(".settingsV2Editor"); + expect(editorDiv.exists()).toBeTruthy(); + + // Warning message should be visible when content is dirty + const messageBar = wrapper.find(MessageBar); + expect(messageBar.exists()).toBeTruthy(); + expect(messageBar.prop("messageBarType")).toBe(MessageBarType.warning); + }); + + it("updates content and dirty state on valid JSON input", async () => { + const wrapper = mount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Simulate valid JSON input by setting mock return value and triggering callback + const validJson = JSON.stringify(samplePolicy); + mockGetValue.mockReturnValue(validJson); + changeContentCallback(); + + expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(samplePolicy); + expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); + }); + + it("doesn't update content on invalid JSON input", async () => { + const wrapper = mount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Simulate invalid JSON input + const invalidJson = "{invalid:json}"; + mockGetValue.mockReturnValue(invalidJson); + changeContentCallback(); + + expect(mockProps.onDataMaskingContentChange).not.toHaveBeenCalled(); + }); + + it("resets content when shouldDiscardDataMasking is true", async () => { + const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true }; + + const wrapper = mount( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Now update props to trigger shouldDiscardDataMasking + wrapper.setProps({ shouldDiscardDataMasking: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Check that reset was triggered + expect(mockProps.resetShouldDiscardDataMasking).toHaveBeenCalled(); + expect(mockSetValue).toHaveBeenCalledWith(JSON.stringify(samplePolicy, undefined, 4)); + }); + + it("recalculates dirty state when baseline changes", async () => { + const wrapper = mount( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Update baseline to trigger componentDidUpdate + const newBaseline = { ...samplePolicy, isPolicyEnabled: true }; + wrapper.setProps({ dataMaskingContentBaseline: newBaseline }); + + expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); + }); + + it("validates required fields in policy", async () => { + const wrapper = mount(); + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // Test with missing required fields + const invalidPolicy: Record = { + includedPaths: "not an array", + excludedPaths: [] as string[], + policyFormatVersion: "not a number", + isPolicyEnabled: "not a boolean", + }; + + mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy)); + changeContentCallback(); + + // Parent callback should be called even with invalid data (parent will validate) + expect(mockProps.onDataMaskingContentChange).toHaveBeenCalledWith(invalidPolicy); + }); + + it("maintains dirty state after multiple content changes", async () => { + const wrapper = mount( + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + + // First change + const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true }; + mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1)); + changeContentCallback(); + expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); + + // Second change back to baseline + mockGetValue.mockReturnValue(JSON.stringify(samplePolicy)); + changeContentCallback(); + expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx new file mode 100644 index 000000000..87f6ff854 --- /dev/null +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx @@ -0,0 +1,163 @@ +import { MessageBar, MessageBarType, Stack } from "@fluentui/react"; +import * as monaco from "monaco-editor"; +import * as React from "react"; +import * as Constants from "../../../../Common/Constants"; +import * as DataModels from "../../../../Contracts/DataModels"; +import { isCapabilityEnabled } from "../../../../Utils/CapabilityUtils"; +import { loadMonaco } from "../../../LazyMonaco"; +import { titleAndInputStackProps, unsavedEditorWarningMessage } from "../SettingsRenderUtils"; +import { isDirty as isContentDirty } from "../SettingsUtils"; + +export interface DataMaskingComponentProps { + shouldDiscardDataMasking: boolean; + resetShouldDiscardDataMasking: () => void; + dataMaskingContent: DataModels.DataMaskingPolicy; + dataMaskingContentBaseline: DataModels.DataMaskingPolicy; + onDataMaskingContentChange: (dataMasking: DataModels.DataMaskingPolicy) => void; + onDataMaskingDirtyChange: (isDirty: boolean) => void; + validationErrors: string[]; +} + +interface DataMaskingComponentState { + isDirty: boolean; + dataMaskingContentIsValid: boolean; +} + +const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = { + includedPaths: [ + { + path: "/", + strategy: "Default", + startPosition: 0, + length: -1, + }, + ], + excludedPaths: [], + policyFormatVersion: 2, + isPolicyEnabled: false, +}; + +export class DataMaskingComponent extends React.Component { + private dataMaskingDiv = React.createRef(); + private dataMaskingEditor: monaco.editor.IStandaloneCodeEditor; + private shouldCheckComponentIsDirty = true; + + constructor(props: DataMaskingComponentProps) { + super(props); + this.state = { + isDirty: false, + dataMaskingContentIsValid: true, + }; + } + + public componentDidUpdate(): void { + if (this.props.shouldDiscardDataMasking) { + this.resetDataMaskingEditor(); + this.props.resetShouldDiscardDataMasking(); + } + this.onComponentUpdate(); + } + + componentDidMount(): void { + this.resetDataMaskingEditor(); + this.onComponentUpdate(); + } + + private onComponentUpdate = (): void => { + if (!this.shouldCheckComponentIsDirty) { + this.shouldCheckComponentIsDirty = true; + return; + } + this.props.onDataMaskingDirtyChange(this.IsComponentDirty()); + this.shouldCheckComponentIsDirty = false; + }; + + public IsComponentDirty = (): boolean => { + if ( + isContentDirty(this.props.dataMaskingContent, this.props.dataMaskingContentBaseline) && + this.state.dataMaskingContentIsValid + ) { + return true; + } + return false; + }; + + private resetDataMaskingEditor = (): void => { + if (!this.dataMaskingEditor) { + this.createDataMaskingEditor(); + } else { + const dataMaskingEditorModel = this.dataMaskingEditor.getModel(); + const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4); + dataMaskingEditorModel.setValue(value); + } + this.onComponentUpdate(); + }; + + private async createDataMaskingEditor(): Promise { + const value: string = JSON.stringify(this.props.dataMaskingContent || emptyDataMaskingPolicy, undefined, 4); + const monaco = await loadMonaco(); + this.dataMaskingEditor = monaco.editor.create(this.dataMaskingDiv.current, { + value: value, + language: "json", + automaticLayout: true, + ariaLabel: "Data Masking Policy", + fontSize: 13, + minimap: { enabled: false }, + wordWrap: "off", + scrollBeyondLastLine: false, + lineNumbers: "on", + }); + if (this.dataMaskingEditor) { + const dataMaskingEditorModel = this.dataMaskingEditor.getModel(); + dataMaskingEditorModel.onDidChangeContent(this.onEditorContentChange.bind(this)); + } + } + + private onEditorContentChange = (): void => { + const dataMaskingEditorModel = this.dataMaskingEditor.getModel(); + try { + const newContent = JSON.parse(dataMaskingEditorModel.getValue()) as DataModels.DataMaskingPolicy; + + // Always call parent's validation - it will handle validation and store errors + this.props.onDataMaskingContentChange(newContent); + + const isDirty = isContentDirty(newContent, this.props.dataMaskingContentBaseline); + this.setState( + { + dataMaskingContentIsValid: this.props.validationErrors.length === 0, + isDirty, + }, + () => { + this.props.onDataMaskingDirtyChange(isDirty); + }, + ); + } catch (e) { + // Invalid JSON - mark as invalid without propagating + this.setState({ + dataMaskingContentIsValid: false, + isDirty: false, + }); + } + }; + + public render(): JSX.Element { + if (!isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking)) { + return null; + } + + const isDirty = this.IsComponentDirty(); + return ( + + {isDirty && ( + {unsavedEditorWarningMessage("dataMasking")} + )} + {this.props.validationErrors.length > 0 && ( + + Validation failed: {this.props.validationErrors.join(", ")} + + )} +
+
+ ); + } +} diff --git a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx index 020a8efef..fbecfb953 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.test.tsx @@ -5,7 +5,12 @@ import { getMongoIndexType, getMongoIndexTypeText, getMongoNotification, + getPartitionKeyName, + getPartitionKeyPlaceHolder, + getPartitionKeySubtext, + getPartitionKeyTooltipText, getSanitizedInputValue, + getTabTitle, hasDatabaseSharedThroughput, isDirty, isIndexTransforming, @@ -14,6 +19,7 @@ import { MongoWildcardPlaceHolder, parseConflictResolutionMode, parseConflictResolutionProcedure, + SettingsV2TabTypes, SingleFieldText, WildcardText, } from "./SettingsUtils"; @@ -50,14 +56,46 @@ describe("SettingsUtils", () => { expect(hasDatabaseSharedThroughput(newCollection)).toEqual(true); }); - it("parseConflictResolutionMode", () => { - expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom); - expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins); + describe("parseConflictResolutionMode", () => { + it("parses valid modes correctly", () => { + expect(parseConflictResolutionMode("custom")).toEqual(DataModels.ConflictResolutionMode.Custom); + expect(parseConflictResolutionMode("lastwriterwins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins); + expect(parseConflictResolutionMode("Custom")).toEqual(DataModels.ConflictResolutionMode.Custom); + expect(parseConflictResolutionMode("CUSTOM")).toEqual(DataModels.ConflictResolutionMode.Custom); + expect(parseConflictResolutionMode("LastWriterWins")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins); + }); + + it("handles empty/undefined input", () => { + expect(parseConflictResolutionMode(undefined)).toBeUndefined(); + expect(parseConflictResolutionMode(null)).toBeUndefined(); + expect(parseConflictResolutionMode("")).toBeUndefined(); + }); + + it("defaults to LastWriterWins for invalid inputs", () => { + expect(parseConflictResolutionMode("invalid")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins); + expect(parseConflictResolutionMode("123")).toEqual(DataModels.ConflictResolutionMode.LastWriterWins); + }); }); - it("parseConflictResolutionProcedure", () => { - expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual("conflictResSproc"); - expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc"); + describe("parseConflictResolutionProcedure", () => { + it("extracts procedure name from valid paths", () => { + expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/sprocs/conflictResSproc")).toEqual( + "conflictResSproc", + ); + expect(parseConflictResolutionProcedure("conflictResSproc")).toEqual("conflictResSproc"); + expect(parseConflictResolutionProcedure("/dbs/mydb/colls/mycoll/sprocs/myProc")).toEqual("myProc"); + }); + + it("handles empty/undefined input", () => { + expect(parseConflictResolutionProcedure(undefined)).toBeUndefined(); + expect(parseConflictResolutionProcedure(null)).toBeUndefined(); + expect(parseConflictResolutionProcedure("")).toBeUndefined(); + }); + + it("handles invalid path formats", () => { + expect(parseConflictResolutionProcedure("/invalid/path")).toBeUndefined(); + expect(parseConflictResolutionProcedure("/dbs/db/colls/coll/wrongtype/name")).toBeUndefined(); + }); }); describe("isDirty", () => { @@ -68,68 +106,235 @@ describe("SettingsUtils", () => { excludedPaths: [], } as DataModels.IndexingPolicy; - it("works on all types", () => { - expect(isDirty("baseline", "baseline")).toEqual(false); - expect(isDirty(0, 0)).toEqual(false); - expect(isDirty(true, true)).toEqual(false); - expect(isDirty(undefined, undefined)).toEqual(false); - expect(isDirty(indexingPolicy, indexingPolicy)).toEqual(false); + describe("primitive types", () => { + it("handles strings", () => { + expect(isDirty("baseline", "baseline")).toBeFalsy(); + expect(isDirty("baseline", "current")).toBeTruthy(); + expect(isDirty("", "")).toBeFalsy(); + expect(isDirty("test", "")).toBeTruthy(); + }); - expect(isDirty("baseline", "current")).toEqual(true); - expect(isDirty(0, 1)).toEqual(true); - expect(isDirty(true, false)).toEqual(true); - expect(isDirty(undefined, indexingPolicy)).toEqual(true); - expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toEqual(true); + it("handles numbers", () => { + expect(isDirty(0, 0)).toBeFalsy(); + expect(isDirty(1, 1)).toBeFalsy(); + expect(isDirty(0, 1)).toBeTruthy(); + expect(isDirty(-1, 1)).toBeTruthy(); + }); + + it("handles booleans", () => { + expect(isDirty(true, true)).toBeFalsy(); + expect(isDirty(false, false)).toBeFalsy(); + expect(isDirty(true, false)).toBeTruthy(); + }); + + it("handles undefined and null", () => { + expect(isDirty(undefined, undefined)).toBeFalsy(); + expect(isDirty(null, null)).toBeFalsy(); + expect(isDirty(undefined, null)).toBeTruthy(); + expect(isDirty(undefined, "value")).toBeTruthy(); + }); + }); + + describe("complex types", () => { + it("handles indexing policy", () => { + expect(isDirty(indexingPolicy, indexingPolicy)).toBeFalsy(); + expect(isDirty(indexingPolicy, { ...indexingPolicy, automatic: false })).toBeTruthy(); + expect(isDirty(indexingPolicy, { ...indexingPolicy, includedPaths: ["/path"] })).toBeTruthy(); + }); + + it("handles array type policies", () => { + const computedProperties: DataModels.ComputedProperties = [ + { name: "prop1", query: "SELECT * FROM c" }, + { name: "prop2", query: "SELECT * FROM c" }, + ]; + const otherProperties: DataModels.ComputedProperties = [{ name: "prop1", query: "SELECT * FROM c" }]; + expect(isDirty(computedProperties, computedProperties)).toBeFalsy(); + expect(isDirty(computedProperties, otherProperties)).toBeTruthy(); + }); + }); + + describe("type mismatch handling", () => { + it("throws error for mismatched types", () => { + expect(() => isDirty("string", 123)).toThrow("current and baseline values are not of the same type"); + expect(() => isDirty(true, "true")).toThrow("current and baseline values are not of the same type"); + expect(() => isDirty(0, false)).toThrow("current and baseline values are not of the same type"); + }); }); }); - it("getSanitizedInputValue", () => { + describe("getSanitizedInputValue", () => { const max = 100; - expect(getSanitizedInputValue("", max)).toEqual(0); - expect(getSanitizedInputValue("999", max)).toEqual(100); - expect(getSanitizedInputValue("10", max)).toEqual(10); + + it("handles empty or invalid inputs", () => { + expect(getSanitizedInputValue("", max)).toEqual(0); + expect(getSanitizedInputValue("abc", max)).toEqual(0); + expect(getSanitizedInputValue("!@#", max)).toEqual(0); + expect(getSanitizedInputValue(null, max)).toEqual(0); + expect(getSanitizedInputValue(undefined, max)).toEqual(0); + }); + + it("handles valid inputs within max", () => { + expect(getSanitizedInputValue("10", max)).toEqual(10); + expect(getSanitizedInputValue("50", max)).toEqual(50); + expect(getSanitizedInputValue("100", max)).toEqual(100); + }); + + it("handles inputs exceeding max", () => { + expect(getSanitizedInputValue("101", max)).toEqual(100); + expect(getSanitizedInputValue("999", max)).toEqual(100); + expect(getSanitizedInputValue("1000000", max)).toEqual(100); + }); + + it("handles inputs without max constraint", () => { + expect(getSanitizedInputValue("10")).toEqual(10); + expect(getSanitizedInputValue("1000")).toEqual(1000); + expect(getSanitizedInputValue("999999")).toEqual(999999); + }); + + it("handles negative numbers", () => { + expect(getSanitizedInputValue("-10", max)).toEqual(-10); + expect(getSanitizedInputValue("-999", max)).toEqual(-999); + }); }); - it("getMongoIndexType", () => { - expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single); - expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard); - expect(getMongoIndexType(["Key1", "Key2"])).toEqual(undefined); + describe("getMongoIndexType", () => { + it("correctly identifies single field indexes", () => { + expect(getMongoIndexType(["Single"])).toEqual(MongoIndexTypes.Single); + expect(getMongoIndexType(["field1"])).toEqual(MongoIndexTypes.Single); + expect(getMongoIndexType(["name"])).toEqual(MongoIndexTypes.Single); + }); + + it("correctly identifies wildcard indexes", () => { + expect(getMongoIndexType(["Wildcard.$**"])).toEqual(MongoIndexTypes.Wildcard); + expect(getMongoIndexType(["field.$**"])).toEqual(MongoIndexTypes.Wildcard); + expect(getMongoIndexType(["nested.path.$**"])).toEqual(MongoIndexTypes.Wildcard); + }); + + it("returns undefined for invalid or compound indexes", () => { + expect(getMongoIndexType(["Key1", "Key2"])).toBeUndefined(); + expect(getMongoIndexType([])).toBeUndefined(); + expect(getMongoIndexType(undefined)).toBeUndefined(); + }); }); - it("getMongoIndexTypeText", () => { - expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText); - expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText); + describe("getMongoIndexTypeText", () => { + it("returns correct text for single field indexes", () => { + expect(getMongoIndexTypeText(MongoIndexTypes.Single)).toEqual(SingleFieldText); + }); + + it("returns correct text for wildcard indexes", () => { + expect(getMongoIndexTypeText(MongoIndexTypes.Wildcard)).toEqual(WildcardText); + }); }); - it("getMongoNotification", () => { + describe("getMongoNotification", () => { const singleIndexDescription = "sampleKey"; const wildcardIndexDescription = "sampleKey.$**"; - let notification = getMongoNotification(singleIndexDescription, undefined); - expect(notification.message).toEqual("Please select a type for each index."); - expect(notification.type).toEqual(MongoNotificationType.Warning); + describe("type validation", () => { + it("returns warning when type is missing", () => { + const notification = getMongoNotification(singleIndexDescription, undefined); + expect(notification.message).toEqual("Please select a type for each index."); + expect(notification.type).toEqual(MongoNotificationType.Warning); + }); - notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Single); - expect(notification).toEqual(undefined); + it("returns undefined for valid type and description combinations", () => { + expect(getMongoNotification(singleIndexDescription, MongoIndexTypes.Single)).toBeUndefined(); + expect(getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard)).toBeUndefined(); + }); + }); - notification = getMongoNotification(wildcardIndexDescription, MongoIndexTypes.Wildcard); - expect(notification).toEqual(undefined); + describe("field name validation", () => { + it("returns error when field name is empty", () => { + const notification = getMongoNotification("", MongoIndexTypes.Single); + expect(notification.message).toEqual("Please enter a field name."); + expect(notification.type).toEqual(MongoNotificationType.Error); - notification = getMongoNotification("", MongoIndexTypes.Single); - expect(notification.message).toEqual("Please enter a field name."); - expect(notification.type).toEqual(MongoNotificationType.Error); + const whitespaceNotification = getMongoNotification(" ", MongoIndexTypes.Single); + expect(whitespaceNotification.message).toEqual("Please enter a field name."); + expect(whitespaceNotification.type).toEqual(MongoNotificationType.Error); + }); - notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard); - expect(notification.message).toEqual( - "Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder, - ); - expect(notification.type).toEqual(MongoNotificationType.Error); + it("returns error when wildcard index is missing $** pattern", () => { + const notification = getMongoNotification(singleIndexDescription, MongoIndexTypes.Wildcard); + expect(notification.message).toEqual( + "Wildcard path is not present in the field name. Use a pattern like " + MongoWildcardPlaceHolder, + ); + expect(notification.type).toEqual(MongoNotificationType.Error); + }); + }); + + it("handles undefined field name", () => { + const notification = getMongoNotification(undefined, MongoIndexTypes.Single); + expect(notification.message).toEqual("Please enter a field name."); + expect(notification.type).toEqual(MongoNotificationType.Error); + }); + }); + it("isIndexingTransforming", () => { + expect(isIndexTransforming(undefined)).toBeFalsy(); + expect(isIndexTransforming(0)).toBeTruthy(); + expect(isIndexTransforming(90)).toBeTruthy(); + expect(isIndexTransforming(100)).toBeFalsy(); + }); + + describe("getTabTitle", () => { + it("returns correct titles for each tab type", () => { + expect(getTabTitle(SettingsV2TabTypes.ScaleTab)).toBe("Scale"); + expect(getTabTitle(SettingsV2TabTypes.ConflictResolutionTab)).toBe("Conflict Resolution"); + expect(getTabTitle(SettingsV2TabTypes.SubSettingsTab)).toBe("Settings"); + expect(getTabTitle(SettingsV2TabTypes.IndexingPolicyTab)).toBe("Indexing Policy"); + expect(getTabTitle(SettingsV2TabTypes.ComputedPropertiesTab)).toBe("Computed Properties"); + expect(getTabTitle(SettingsV2TabTypes.ContainerVectorPolicyTab)).toBe("Container Policies"); + expect(getTabTitle(SettingsV2TabTypes.ThroughputBucketsTab)).toBe("Throughput Buckets"); + expect(getTabTitle(SettingsV2TabTypes.DataMaskingTab)).toBe("Masking Policy (preview)"); + }); + + it("handles partition key tab title based on fabric native", () => { + // Assuming initially not fabric native + expect(getTabTitle(SettingsV2TabTypes.PartitionKeyTab)).toBe("Partition Keys (preview)"); + }); + + it("throws error for unknown tab type", () => { + expect(() => getTabTitle(999 as SettingsV2TabTypes)).toThrow("Unknown tab 999"); + }); + }); + + describe("partition key utils", () => { + it("getPartitionKeyName returns correct name based on API type", () => { + expect(getPartitionKeyName("Mongo")).toBe("Shard key"); + expect(getPartitionKeyName("SQL")).toBe("Partition key"); + expect(getPartitionKeyName("Mongo", true)).toBe("shard key"); + expect(getPartitionKeyName("SQL", true)).toBe("partition key"); + }); + + it("getPartitionKeyTooltipText returns correct tooltip based on API type", () => { + const mongoTooltip = getPartitionKeyTooltipText("Mongo"); + expect(mongoTooltip).toContain("shard key"); + expect(mongoTooltip).toContain("replica sets"); + + const sqlTooltip = getPartitionKeyTooltipText("SQL"); + expect(sqlTooltip).toContain("partition key"); + expect(sqlTooltip).toContain("id is often a good choice"); + }); + + it("getPartitionKeySubtext returns correct subtext", () => { + expect(getPartitionKeySubtext(true, "SQL")).toBe( + "For small workloads, the item ID is a suitable choice for the partition key.", + ); + expect(getPartitionKeySubtext(true, "Mongo")).toBe( + "For small workloads, the item ID is a suitable choice for the partition key.", + ); + expect(getPartitionKeySubtext(false, "SQL")).toBe(""); + expect(getPartitionKeySubtext(true, "Other")).toBe(""); + }); + + it("getPartitionKeyPlaceHolder returns correct placeholder based on API type", () => { + expect(getPartitionKeyPlaceHolder("Mongo")).toBe("e.g., categoryId"); + expect(getPartitionKeyPlaceHolder("Gremlin")).toBe("e.g., /address"); + expect(getPartitionKeyPlaceHolder("SQL")).toBe("Required - first partition key e.g., /TenantId"); + expect(getPartitionKeyPlaceHolder("SQL", 0)).toBe("second partition key e.g., /UserId"); + expect(getPartitionKeyPlaceHolder("SQL", 1)).toBe("third partition key e.g., /SessionId"); + expect(getPartitionKeyPlaceHolder("Other")).toBe("e.g., /address/zipCode"); + }); }); }); - -it("isIndexingTransforming", () => { - expect(isIndexTransforming(undefined)).toBeFalsy(); - expect(isIndexTransforming(0)).toBeTruthy(); - expect(isIndexTransforming(90)).toBeTruthy(); - expect(isIndexTransforming(100)).toBeFalsy(); -}); diff --git a/src/Explorer/Controls/Settings/SettingsUtils.tsx b/src/Explorer/Controls/Settings/SettingsUtils.tsx index 2617af6ac..39590865c 100644 --- a/src/Explorer/Controls/Settings/SettingsUtils.tsx +++ b/src/Explorer/Controls/Settings/SettingsUtils.tsx @@ -13,7 +13,8 @@ export type isDirtyTypes = | DataModels.ComputedProperties | DataModels.VectorEmbedding[] | DataModels.FullTextPolicy - | DataModels.ThroughputBucket[]; + | DataModels.ThroughputBucket[] + | DataModels.DataMaskingPolicy; export const TtlOff = "off"; export const TtlOn = "on"; export const TtlOnNoDefault = "on-nodefault"; @@ -59,6 +60,7 @@ export enum SettingsV2TabTypes { ContainerVectorPolicyTab, ThroughputBucketsTab, GlobalSecondaryIndexTab, + DataMaskingTab, } export enum ContainerPolicyTabTypes { @@ -175,6 +177,8 @@ export const getTabTitle = (tab: SettingsV2TabTypes): string => { return "Throughput Buckets"; case SettingsV2TabTypes.GlobalSecondaryIndexTab: return "Global Secondary Index (Preview)"; + case SettingsV2TabTypes.DataMaskingTab: + return "Masking Policy (preview)"; default: throw new Error(`Unknown tab ${tab}`); } diff --git a/src/Explorer/Controls/Settings/TestUtils.tsx b/src/Explorer/Controls/Settings/TestUtils.tsx index 96dfbddb3..5cc09bb8d 100644 --- a/src/Explorer/Controls/Settings/TestUtils.tsx +++ b/src/Explorer/Controls/Settings/TestUtils.tsx @@ -65,6 +65,12 @@ export const collection = { sourceCollectionId: "source1", sourceCollectionRid: "rid123", }), + dataMaskingPolicy: ko.observable({ + includedPaths: [], + excludedPaths: ["/excludedPath"], + policyFormatVersion: 2, + isPolicyEnabled: true, + }), readSettings: () => { return; }, diff --git a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap index 8b9c96735..0bd3fd7f1 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -53,6 +53,7 @@ exports[`SettingsComponent renders 1`] = ` "parameters": [Function], }, }, + "dataMaskingPolicy": [Function], "databaseId": "test", "defaultTtl": [Function], "fullTextPolicy": [Function], @@ -155,6 +156,7 @@ exports[`SettingsComponent renders 1`] = ` "parameters": [Function], }, }, + "dataMaskingPolicy": [Function], "databaseId": "test", "defaultTtl": [Function], "fullTextPolicy": [Function], @@ -330,6 +332,7 @@ exports[`SettingsComponent renders 1`] = ` "parameters": [Function], }, }, + "dataMaskingPolicy": [Function], "databaseId": "test", "defaultTtl": [Function], "fullTextPolicy": [Function], @@ -480,6 +483,7 @@ exports[`SettingsComponent renders 1`] = ` "parameters": [Function], }, }, + "dataMaskingPolicy": [Function], "databaseId": "test", "defaultTtl": [Function], "fullTextPolicy": [Function], diff --git a/src/Explorer/Tree/Collection.test.ts b/src/Explorer/Tree/Collection.test.ts index 0894a2565..7b89a166c 100644 --- a/src/Explorer/Tree/Collection.test.ts +++ b/src/Explorer/Tree/Collection.test.ts @@ -14,17 +14,30 @@ describe("Collection", () => { defaultTtl: 1, indexingPolicy: {} as DataModels.IndexingPolicy, partitionKey, - _rid: "", - _self: "", - _etag: "", + _rid: "testRid", + _self: "testSelf", + _etag: "testEtag", _ts: 1, - id: "", + id: "testCollection", }; }; const generateMockCollectionWithDataModel = (data: DataModels.Collection): Collection => { - const mockContainer = {} as Explorer; - return generateCollection(mockContainer, "abc", data); + const mockContainer = { + isReadOnly: () => false, + isFabricCapable: () => true, + databaseAccount: () => ({ + name: () => "testAccount", + id: () => "testAccount", + properties: { + enablePartitionKey: true, + partitionKeyDefinitionVersion: 2, + capabilities: [] as string[], + databaseAccountEndpoint: "test.documents.azure.com", + }, + }), + } as unknown as Explorer; + return generateCollection(mockContainer, "testDb", data); }; describe("Partition key path parsing", () => { @@ -78,7 +91,7 @@ describe("Collection", () => { expect(collection.partitionKeyPropertyHeaders[0]).toBe("/somePartitionKey"); }); - it("should be null if there is no partition key", () => { + it("should be empty if there is no partition key", () => { const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ version: 2, paths: [], @@ -88,4 +101,103 @@ describe("Collection", () => { expect(collection.partitionKeyPropertyHeaders.length).toBe(0); }); }); + + describe("Collection properties", () => { + let collection: Collection; + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: ["/id"], + kind: "Hash", + version: 2, + }); + + beforeEach(() => { + collection = generateMockCollectionWithDataModel(collectionsDataModel); + }); + + it("should return correct collection id", () => { + expect(collection.id()).toBe("testCollection"); + }); + + it("should return correct rid", () => { + expect(collection.rid).toBe("testRid"); + }); + + it("should return correct self", () => { + expect(collection.self).toBe("testSelf"); + }); + + it("should return correct collection type", () => { + expect(collection.partitionKey).toBeDefined(); + }); + }); + + describe("Collection type", () => { + it("should identify large partitioned collection for v2 partition key", () => { + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: ["/id"], + kind: "Hash", + version: 2, + }); + const collection = generateMockCollectionWithDataModel(collectionsDataModel); + expect(collection.partitionKey.version).toBe(2); + expect(collection.partitionKey.paths).toEqual(["/id"]); + }); + + it("should identify standard partitioned collection for v1 partition key", () => { + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: ["/id"], + kind: "Hash", + version: 1, + }); + const collection = generateMockCollectionWithDataModel(collectionsDataModel); + expect(collection.partitionKey.version).toBe(1); + expect(collection.partitionKey.paths).toEqual(["/id"]); + }); + + it("should identify collection without partition key", () => { + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: [], + kind: "Hash", + version: 2, + }); + const collection = generateMockCollectionWithDataModel(collectionsDataModel); + expect(collection.partitionKey.paths).toEqual([]); + }); + }); + + describe("Partition key handling", () => { + it("should return correct partition key paths for multiple paths", () => { + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: ["/id", "/pk"], + kind: "Hash", + version: 2, + }); + const collection = generateMockCollectionWithDataModel(collectionsDataModel); + expect(collection.partitionKey.paths).toEqual(["/id", "/pk"]); + expect(collection.partitionKeyProperties).toEqual(["id", "pk"]); + }); + + it("should handle empty partition key paths", () => { + const collectionsDataModel = generateMockCollectionsDataModelWithPartitionKey({ + paths: [], + kind: "Hash", + version: 2, + }); + const collection = generateMockCollectionWithDataModel(collectionsDataModel); + expect(collection.partitionKey.paths).toEqual([]); + expect(collection.partitionKeyProperties).toEqual([]); + }); + + it("should handle undefined partition key", () => { + const collectionData = generateMockCollectionsDataModelWithPartitionKey({ + paths: ["/id"], + kind: "Hash", + version: 2, + }); + delete collectionData.partitionKey; + const collection = generateMockCollectionWithDataModel(collectionData); + expect(collection.partitionKey).toBeUndefined(); + expect(collection.partitionKeyProperties).toEqual([]); + }); + }); }); diff --git a/src/Explorer/Tree/Collection.ts b/src/Explorer/Tree/Collection.ts index 1dffc93be..adbc24cce 100644 --- a/src/Explorer/Tree/Collection.ts +++ b/src/Explorer/Tree/Collection.ts @@ -67,6 +67,7 @@ export default class Collection implements ViewModels.Collection { public computedProperties: ko.Observable; public materializedViews: ko.Observable; public materializedViewDefinition: ko.Observable; + public dataMaskingPolicy: ko.Observable; public offer: ko.Observable; public conflictResolutionPolicy: ko.Observable; @@ -136,25 +137,36 @@ export default class Collection implements ViewModels.Collection { this.materializedViews = ko.observable(data.materializedViews); this.materializedViewDefinition = ko.observable(data.materializedViewDefinition); - this.partitionKeyPropertyHeaders = this.partitionKey?.paths; - this.partitionKeyProperties = this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => { - // TODO fix this to only replace non-excaped single quotes - let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""); + // Initialize dataMaskingPolicy with default values if not present + const defaultDataMaskingPolicy: DataModels.DataMaskingPolicy = { + includedPaths: Array<{ path: string; strategy: string; startPosition: number; length: number }>(), + excludedPaths: Array(), + policyFormatVersion: 2, + isPolicyEnabled: false, + }; + const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy); + observablePolicy.subscribe(() => {}); + this.dataMaskingPolicy = observablePolicy; + this.partitionKeyPropertyHeaders = this.partitionKey?.paths || []; + this.partitionKeyProperties = + this.partitionKeyPropertyHeaders?.map((partitionKeyPropertyHeader, i) => { + // TODO fix this to only replace non-excaped single quotes + let partitionKeyProperty = partitionKeyPropertyHeader.replace(/[/]+/g, ".").substring(1).replace(/[']+/g, ""); - if (userContext.apiType === "Mongo" && partitionKeyProperty) { - if (~partitionKeyProperty.indexOf(`"`)) { - partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); + if (userContext.apiType === "Mongo" && partitionKeyProperty) { + if (~partitionKeyProperty.indexOf(`"`)) { + partitionKeyProperty = partitionKeyProperty.replace(/["]+/g, ""); + } + // TODO #10738269 : Add this logic in a derived class for Mongo + if (partitionKeyProperty.indexOf("$v") > -1) { + // From $v.shard.$v.key.$v > shard.key + partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, ""); + this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty; + } } - // TODO #10738269 : Add this logic in a derived class for Mongo - if (partitionKeyProperty.indexOf("$v") > -1) { - // From $v.shard.$v.key.$v > shard.key - partitionKeyProperty = partitionKeyProperty.replace(/.\$v/g, "").replace(/\$v./g, ""); - this.partitionKeyPropertyHeaders[i] = "/" + partitionKeyProperty; - } - } - return partitionKeyProperty; - }); + return partitionKeyProperty; + }) || []; this.documentIds = ko.observableArray([]); this.isCollectionExpanded = ko.observable(false); @@ -163,7 +175,6 @@ export default class Collection implements ViewModels.Collection { this.documentsFocused = ko.observable(); this.documentsFocused.subscribe((focus) => { - console.log("Focus set on Documents: " + focus); this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents); });