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

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