DDM in DE for NOSQL (#2224)

* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm for DE for noSQL

* ddm fix for the default case and test fix

* formatting issue

* updated the text change

* added validation errors

---------

Co-authored-by: Sakshi Gupta <sakshig@microsoft.com>
This commit is contained in:
sakshigupta12feb
2025-10-27 19:37:40 +05:30
committed by GitHub
parent ff1eb6a78e
commit 0578910b9e
15 changed files with 1212 additions and 96 deletions

View File

@@ -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";
}

View File

@@ -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;

View File

@@ -140,6 +140,7 @@ export interface Collection extends CollectionBase {
requestSchema?: () => void;
vectorEmbeddingPolicy: ko.Observable<DataModels.VectorEmbeddingPolicy>;
fullTextPolicy: ko.Observable<DataModels.FullTextPolicy>;
dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
indexingPolicy: ko.Observable<DataModels.IndexingPolicy>;
uniqueKeyPolicy: DataModels.UniqueKeyPolicy;
usageSizeInKB: ko.Observable<number>;

View File

@@ -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(<SettingsComponent {...baseProps} />);
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(<SettingsComponent {...baseProps} />);
const settingsComponentInstance = wrapper.instance() as SettingsComponent;
// Test with invalid data structure
// Use invalid data type for testing validation
type InvalidPolicy = Omit<DataModels.DataMaskingPolicy, "includedPaths"> & { 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(<SettingsComponent {...baseProps} />);
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(<SettingsComponent {...baseProps} />);
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", () => {

View File

@@ -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<SettingsComponentProps, S
shouldDiscardComputedProperties: false,
isComputedPropertiesDirty: false,
dataMaskingContent: undefined,
dataMaskingContentBaseline: undefined,
shouldDiscardDataMasking: false,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
conflictResolutionPolicyMode: undefined,
conflictResolutionPolicyModeBaseline: undefined,
conflictResolutionPolicyPath: undefined,
@@ -345,7 +358,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public isSaveSettingsButtonEnabled = (): boolean => {
if (this.isOfferReplacePending()) {
if (this.isOfferReplacePending() || this.props.settingsTab.isExecuting()) {
return false;
}
@@ -353,6 +366,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
return false;
}
if (this.state.dataMaskingValidationErrors.length > 0) {
return false;
}
return (
this.state.isScaleSaveable ||
this.state.isSubSettingsSaveable ||
@@ -360,12 +377,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicySaveable) ||
this.state.isThroughputBucketsSaveable
);
};
public isDiscardSettingsButtonEnabled = (): boolean => {
if (this.props.settingsTab.isExecuting()) {
return false;
}
return (
this.state.isScaleDiscardable ||
this.state.isSubSettingsDiscardable ||
@@ -373,6 +394,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty ||
(!!this.state.currentMongoIndexes && this.state.isMongoIndexingPolicyDiscardable) ||
this.state.isThroughputBucketsSaveable
);
@@ -428,7 +450,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
: this.saveDatabaseSettings(startKey));
} catch (error) {
this.props.settingsTab.isExecutionError(true);
console.error(error);
traceFailure(
Action.SettingsV2Updated,
{
@@ -445,8 +466,6 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
} finally {
this.props.settingsTab.isExecuting(false);
// Send message to Fabric no matter success or failure.
// In case of failure, saveCollectionSettings might have partially succeeded and Fabric needs to refresh
if (isFabricNative() && this.isCollectionSettingsTab) {
sendMessage({
type: FabricMessageTypes.ContainerUpdated,
@@ -457,6 +476,9 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
};
public onRevertClick = (): void => {
if (this.props.settingsTab.isExecuting()) {
return;
}
trace(Action.SettingsV2Discarded, ActionModifiers.Mark, {
message: "Settings Discarded",
});
@@ -497,6 +519,10 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
computedPropertiesContent: this.state.computedPropertiesContentBaseline,
shouldDiscardComputedProperties: true,
isComputedPropertiesDirty: false,
dataMaskingContent: this.state.dataMaskingContentBaseline,
shouldDiscardDataMasking: true,
isDataMaskingDirty: false,
dataMaskingValidationErrors: [],
});
};
@@ -659,6 +685,39 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
private onComputedPropertiesDirtyChange = (isComputedPropertiesDirty: boolean): void =>
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<SettingsComponentProps, S
const fullTextPolicy: DataModels.FullTextPolicy =
this.collection.fullTextPolicy && this.collection.fullTextPolicy();
const indexingPolicyContent = this.collection.indexingPolicy();
const dataMaskingContent: DataModels.DataMaskingPolicy = {
includedPaths: this.collection.dataMaskingPolicy?.()?.includedPaths || [],
excludedPaths: this.collection.dataMaskingPolicy?.()?.excludedPaths || [],
policyFormatVersion: this.collection.dataMaskingPolicy?.()?.policyFormatVersion || 2,
isPolicyEnabled: this.collection.dataMaskingPolicy?.()?.isPolicyEnabled || false,
};
const conflictResolutionPolicy: DataModels.ConflictResolutionPolicy =
this.collection.conflictResolutionPolicy && this.collection.conflictResolutionPolicy();
const conflictResolutionPolicyMode = parseConflictResolutionMode(conflictResolutionPolicy?.mode);
@@ -834,11 +899,14 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
geospatialConfigTypeBaseline: geoSpatialConfigType,
computedPropertiesContent: computedPropertiesContent,
computedPropertiesContentBaseline: computedPropertiesContent,
dataMaskingContent: dataMaskingContent,
dataMaskingContentBaseline: dataMaskingContent,
};
};
private getTabsButtons = (): CommandButtonComponentProps[] => {
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<SettingsComponentProps, S
commandButtonLabel: label,
ariaLabel: label,
hasPopup: false,
disabled: !this.saveSettingsButton.isEnabled(),
disabled: isExecuting || !this.saveSettingsButton.isEnabled(),
});
}
@@ -857,11 +925,16 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
buttons.push({
iconSrc: DiscardIcon,
iconAlt: label,
onCommandClick: this.onRevertClick,
onCommandClick: () => {
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<SettingsComponentProps, S
this.state.isContainerPolicyDirty ||
this.state.isIndexingPolicyDirty ||
this.state.isConflictResolutionDirty ||
this.state.isComputedPropertiesDirty
this.state.isComputedPropertiesDirty ||
this.state.isDataMaskingDirty
) {
let defaultTtl: number;
switch (this.state.timeToLive) {
@@ -1002,6 +1076,7 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
newCollection.vectorEmbeddingPolicy = this.state.vectorEmbeddingPolicy;
newCollection.fullTextPolicy = this.state.fullTextPolicy;
newCollection.dataMaskingPolicy = this.state.dataMaskingContent;
newCollection.indexingPolicy = this.state.indexingPolicyContent;
@@ -1048,13 +1123,18 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
await this.refreshIndexTransformationProgress();
}
// Update collection object with new data
this.collection.dataMaskingPolicy(updatedCollection.dataMaskingPolicy);
this.setState({
dataMaskingContentBaseline: this.state.dataMaskingContent,
isSubSettingsSaveable: false,
isSubSettingsDiscardable: false,
isContainerPolicyDirty: false,
isIndexingPolicyDirty: false,
isConflictResolutionDirty: false,
isComputedPropertiesDirty: false,
isDataMaskingDirty: false,
});
}
@@ -1383,6 +1463,31 @@ export class SettingsComponent extends React.Component<SettingsComponentProps, S
});
}
// Check if DDM should be enabled
const shouldEnableDDM = (): boolean => {
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: <DataMaskingComponent {...dataMaskingComponentProps} />,
});
}
if (this.throughputBucketsEnabled && !hasDatabaseSharedThroughput(this.collection) && this.offer) {
tabs.push({
tab: SettingsV2TabTypes.ThroughputBucketsTab,

View File

@@ -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 }) => <div>{children}</div>;
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 = <div>Cost</div>;
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 = <div>Cost</div>;
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");
});
});
});

View File

@@ -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<IMessageBarStyles> = {
text: { fontSize: 14 },
};
export const unsavedEditorMessageBarStyles: Partial<IMessageBarStyles> = {
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 => (
<Text styles={infoAndToolTipTextStyle}>
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.
</Text>
);

View File

@@ -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(<DataMaskingComponent {...mockProps} />);
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(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={{ ...samplePolicy, isPolicyEnabled: true }}
/>,
);
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(<DataMaskingComponent {...mockProps} />);
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(<DataMaskingComponent {...mockProps} />);
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(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={baselinePolicy}
/>,
);
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(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={samplePolicy}
/>,
);
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(<DataMaskingComponent {...mockProps} />);
await new Promise((resolve) => setTimeout(resolve, 0));
wrapper.update();
// Test with missing required fields
const invalidPolicy: Record<string, unknown> = {
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(
<DataMaskingComponent
{...mockProps}
dataMaskingContent={samplePolicy}
dataMaskingContentBaseline={samplePolicy}
/>,
);
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);
});
});

View File

@@ -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<DataMaskingComponentProps, DataMaskingComponentState> {
private dataMaskingDiv = React.createRef<HTMLDivElement>();
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<void> {
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 (
<Stack {...titleAndInputStackProps}>
{isDirty && (
<MessageBar messageBarType={MessageBarType.warning}>{unsavedEditorWarningMessage("dataMasking")}</MessageBar>
)}
{this.props.validationErrors.length > 0 && (
<MessageBar messageBarType={MessageBarType.error}>
Validation failed: {this.props.validationErrors.join(", ")}
</MessageBar>
)}
<div className="settingsV2Editor" tabIndex={0} ref={this.dataMaskingDiv}></div>
</Stack>
);
}
}

View File

@@ -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();
});

View File

@@ -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}`);
}

View File

@@ -65,6 +65,12 @@ export const collection = {
sourceCollectionId: "source1",
sourceCollectionRid: "rid123",
}),
dataMaskingPolicy: ko.observable<DataModels.DataMaskingPolicy>({
includedPaths: [],
excludedPaths: ["/excludedPath"],
policyFormatVersion: 2,
isPolicyEnabled: true,
}),
readSettings: () => {
return;
},

View File

@@ -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],

View File

@@ -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([]);
});
});
});

View File

@@ -67,6 +67,7 @@ export default class Collection implements ViewModels.Collection {
public computedProperties: ko.Observable<DataModels.ComputedProperties>;
public materializedViews: ko.Observable<DataModels.MaterializedView[]>;
public materializedViewDefinition: ko.Observable<DataModels.MaterializedViewDefinition>;
public dataMaskingPolicy: ko.Observable<DataModels.DataMaskingPolicy>;
public offer: ko.Observable<DataModels.Offer>;
public conflictResolutionPolicy: ko.Observable<DataModels.ConflictResolutionPolicy>;
@@ -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<string>(),
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<DocumentId>([]);
this.isCollectionExpanded = ko.observable<boolean>(false);
@@ -163,7 +175,6 @@ export default class Collection implements ViewModels.Collection {
this.documentsFocused = ko.observable<boolean>();
this.documentsFocused.subscribe((focus) => {
console.log("Focus set on Documents: " + focus);
this.focusedSubnodeKind(ViewModels.CollectionTabKind.Documents);
});