diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dac26c32d..d91f15973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,18 +201,18 @@ jobs: GREMLIN_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-gremlin.documents.azure.com/.default" -o tsv --query accessToken) echo "::add-mask::$GREMLIN_TESTACCOUNT_TOKEN" echo GREMLIN_TESTACCOUNT_TOKEN=$GREMLIN_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN" - # echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN" - # echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN" - # echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV - # MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) - # echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" - # echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV + CASSANDRA_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-cassandra.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$CASSANDRA_TESTACCOUNT_TOKEN" + echo CASSANDRA_TESTACCOUNT_TOKEN=$CASSANDRA_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO_TESTACCOUNT_TOKEN" + echo MONGO_TESTACCOUNT_TOKEN=$MONGO_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO32_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo32.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO32_TESTACCOUNT_TOKEN" + echo MONGO32_TESTACCOUNT_TOKEN=$MONGO32_TESTACCOUNT_TOKEN >> $GITHUB_ENV + MONGO_READONLY_TESTACCOUNT_TOKEN=$(az account get-access-token --scope "https://github-e2etests-mongo-readonly.documents.azure.com/.default" -o tsv --query accessToken) + echo "::add-mask::$MONGO_READONLY_TESTACCOUNT_TOKEN" + echo MONGO_READONLY_TESTACCOUNT_TOKEN=$MONGO_READONLY_TESTACCOUNT_TOKEN >> $GITHUB_ENV - name: List test files for shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --list - name: Run test shard ${{ matrix['shardIndex'] }} of ${{ matrix['shardTotal']}} diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index ece8c8dba..80991db2a 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -6,8 +6,8 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: schedule: - # Once every two hours - - cron: "0 */2 * * *" + # Once every day at 7 AM PST + - cron: "0 13 * * *" permissions: id-token: write diff --git a/src/Common/ErrorHandlingUtils.ts b/src/Common/ErrorHandlingUtils.ts index bfa321fe9..ea25d8e34 100644 --- a/src/Common/ErrorHandlingUtils.ts +++ b/src/Common/ErrorHandlingUtils.ts @@ -7,16 +7,27 @@ import { HttpStatusCodes } from "./Constants"; import { logError } from "./Logger"; import { sendMessage } from "./MessageHandler"; -export const handleError = (error: string | ARMError | Error, area: string, consoleErrorPrefix?: string): void => { +export interface HandleErrorOptions { + /** Optional redacted error to use for telemetry logging instead of the original error */ + redactedError?: string | ARMError | Error; +} + +export const handleError = ( + error: string | ARMError | Error, + area: string, + consoleErrorPrefix?: string, + options?: HandleErrorOptions, +): void => { const errorMessage = getErrorMessage(error); const errorCode = error instanceof ARMError ? error.code : undefined; - // logs error to data explorer console + // logs error to data explorer console (always shows original, non-redacted message) const consoleErrorMessage = consoleErrorPrefix ? `${consoleErrorPrefix}:\n ${errorMessage}` : errorMessage; logConsoleError(consoleErrorMessage); - // logs error to both app insight and kusto - logError(errorMessage, area, errorCode); + // logs error to both app insight and kusto (use redacted message if provided) + const telemetryErrorMessage = options?.redactedError ? getErrorMessage(options.redactedError) : errorMessage; + logError(telemetryErrorMessage, area, errorCode); // checks for errors caused by firewall and sends them to portal to handle sendNotificationForError(errorMessage, errorCode); diff --git a/src/Common/dataAccess/deleteDocument.ts b/src/Common/dataAccess/deleteDocument.ts index 9d4b8a396..b8a4a0430 100644 --- a/src/Common/dataAccess/deleteDocument.ts +++ b/src/Common/dataAccess/deleteDocument.ts @@ -44,7 +44,8 @@ export const deleteDocuments = async ( documentIds: DocumentId[], abortSignal: AbortSignal, ): Promise => { - const clearMessage = logConsoleProgress(`Deleting ${documentIds.length} ${getEntityName(true)}`); + const totalCount = documentIds.length; + const clearMessage = logConsoleProgress(`Deleting ${totalCount} ${getEntityName(true)}`); try { const v2Container = await client().database(collection.databaseId).container(collection.id()); @@ -83,11 +84,7 @@ export const deleteDocuments = async ( const flatAllResult = Array.prototype.concat.apply([], allResult); return flatAllResult; } catch (error) { - handleError( - error, - "DeleteDocuments", - `Error while deleting ${documentIds.length} ${getEntityName(documentIds.length > 1)}`, - ); + handleError(error, "DeleteDocuments", `Error while deleting ${totalCount} ${getEntityName(totalCount > 1)}`); throw error; } finally { clearMessage(); diff --git a/src/Common/dataAccess/queryDocumentsPage.test.ts b/src/Common/dataAccess/queryDocumentsPage.test.ts new file mode 100644 index 000000000..adf68bd02 --- /dev/null +++ b/src/Common/dataAccess/queryDocumentsPage.test.ts @@ -0,0 +1,171 @@ +import { redactSyntaxErrorMessage } from "./queryDocumentsPage"; + +/* Typical error to redact looks like this (the message property contains a JSON string with nested structure): +{ + "message": "{\"code\":\"BadRequest\",\"message\":\"{\\\"errors\\\":[{\\\"severity\\\":\\\"Error\\\",\\\"location\\\":{\\\"start\\\":0,\\\"end\\\":5},\\\"code\\\":\\\"SC1001\\\",\\\"message\\\":\\\"Syntax error, incorrect syntax near 'Crazy'.\\\"}]}\\r\\nActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17, Windows/10.0.20348 cosmos-netstandard-sdk/3.18.0\"}" +} +*/ + +// Helper to create the nested error structure that matches what the SDK returns +const createNestedError = ( + errors: Array<{ severity?: string; location?: { start: number; end: number }; code: string; message: string }>, + activityId: string = "test-activity-id", +): { message: string } => { + const innerErrorsJson = JSON.stringify({ errors }); + const innerMessage = `${innerErrorsJson}\r\n${activityId}`; + const outerJson = JSON.stringify({ code: "BadRequest", message: innerMessage }); + return { message: outerJson }; +}; + +// Helper to parse the redacted result +const parseRedactedResult = (result: { message: string }) => { + const outerParsed = JSON.parse(result.message); + const [innerErrorsJson, activityIdPart] = outerParsed.message.split("\r\n"); + const innerErrors = JSON.parse(innerErrorsJson); + return { outerParsed, innerErrors, activityIdPart }; +}; + +describe("redactSyntaxErrorMessage", () => { + it("should redact SC1001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 5 }, + code: "SC1001", + message: "Syntax error, incorrect syntax near 'Crazy'.", + }, + ], + "ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: d5424e10-51bd-46f7-9aec-7b40bed36f17"); + }); + + it("should redact SC2001 error message", () => { + const error = createNestedError( + [ + { + severity: "Error", + location: { start: 0, end: 10 }, + code: "SC2001", + message: "Some sensitive syntax error message.", + }, + ], + "ActivityId: abc123", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { outerParsed, innerErrors, activityIdPart } = parseRedactedResult(result); + + expect(outerParsed.code).toBe("BadRequest"); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(activityIdPart).toContain("ActivityId: abc123"); + }); + + it("should redact multiple errors with SC1001 and SC2001 codes", () => { + const error = createNestedError( + [ + { severity: "Error", code: "SC1001", message: "First error" }, + { severity: "Error", code: "SC2001", message: "Second error" }, + ], + "ActivityId: xyz", + ); + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + expect(innerErrors.errors[1].message).toBe("__REDACTED__"); + }); + + it("should not redact errors with other codes", () => { + const error = createNestedError( + [{ severity: "Error", code: "SC9999", message: "This should not be redacted." }], + "ActivityId: test123", + ); + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); // Should return original error unchanged + }); + + it("should not modify non-BadRequest errors", () => { + const innerMessage = JSON.stringify({ errors: [{ code: "SC1001", message: "Should not be redacted" }] }); + const error = { + message: JSON.stringify({ code: "NotFound", message: innerMessage }), + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle errors without message property", () => { + const error = { code: "BadRequest" }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle non-object errors", () => { + const stringError = "Simple string error"; + const nullError: null = null; + const undefinedError: undefined = undefined; + + expect(redactSyntaxErrorMessage(stringError)).toBe(stringError); + expect(redactSyntaxErrorMessage(nullError)).toBe(nullError); + expect(redactSyntaxErrorMessage(undefinedError)).toBe(undefinedError); + }); + + it("should handle malformed JSON in message", () => { + const error = { + message: "not valid json", + }; + + const result = redactSyntaxErrorMessage(error); + + expect(result).toBe(error); + }); + + it("should handle message without ActivityId suffix", () => { + const innerErrorsJson = JSON.stringify({ + errors: [{ severity: "Error", code: "SC1001", message: "Syntax error near something." }], + }); + const error = { + message: JSON.stringify({ code: "BadRequest", message: innerErrorsJson + "\r\n" }), + }; + + const result = redactSyntaxErrorMessage(error) as { message: string }; + const { innerErrors } = parseRedactedResult(result); + + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); + + it("should preserve other error properties", () => { + const baseError = createNestedError([{ code: "SC1001", message: "Error" }], "ActivityId: test"); + const error = { + ...baseError, + statusCode: 400, + additionalInfo: "extra data", + }; + + const result = redactSyntaxErrorMessage(error) as { + message: string; + statusCode: number; + additionalInfo: string; + }; + + expect(result.statusCode).toBe(400); + expect(result.additionalInfo).toBe("extra data"); + + const { innerErrors } = parseRedactedResult(result); + expect(innerErrors.errors[0].message).toBe("__REDACTED__"); + }); +}); diff --git a/src/Common/dataAccess/queryDocumentsPage.ts b/src/Common/dataAccess/queryDocumentsPage.ts index 556ed290c..b5ec9c684 100644 --- a/src/Common/dataAccess/queryDocumentsPage.ts +++ b/src/Common/dataAccess/queryDocumentsPage.ts @@ -4,6 +4,51 @@ import { getEntityName } from "../DocumentUtility"; import { handleError } from "../ErrorHandlingUtils"; import { MinimalQueryIterator, nextPage } from "../IteratorUtilities"; +// Redact sensitive information from BadRequest errors with specific codes +export const redactSyntaxErrorMessage = (error: unknown): unknown => { + const codesToRedact = ["SC1001", "SC2001"]; + + try { + // Handle error objects with a message property + if (error && typeof error === "object" && "message" in error) { + const errorObj = error as { code?: string; message?: string }; + if (typeof errorObj.message === "string") { + // Parse the inner JSON from the message + const innerJson = JSON.parse(errorObj.message); + if (innerJson.code === "BadRequest" && typeof innerJson.message === "string") { + const [innerErrorsJson, activityIdPart] = innerJson.message.split("\r\n"); + const innerErrorsObj = JSON.parse(innerErrorsJson); + if (Array.isArray(innerErrorsObj.errors)) { + let modified = false; + innerErrorsObj.errors = innerErrorsObj.errors.map((err: { code?: string; message?: string }) => { + if (err.code && codesToRedact.includes(err.code)) { + modified = true; + return { ...err, message: "__REDACTED__" }; + } + return err; + }); + + if (modified) { + // Reconstruct the message with the redacted content + const redactedMessage = JSON.stringify(innerErrorsObj) + `\r\n${activityIdPart}`; + const redactedError = { + ...error, + message: JSON.stringify({ ...innerJson, message: redactedMessage }), + body: undefined as unknown, // Clear body to avoid sensitive data + }; + return redactedError; + } + } + } + } + } + } catch { + // If parsing fails, return the original error + } + + return error; +}; + export const queryDocumentsPage = async ( resourceName: string, documentsIterator: MinimalQueryIterator, @@ -18,7 +63,12 @@ export const queryDocumentsPage = async ( logConsoleInfo(`Successfully fetched ${itemCount} ${entityName} for container ${resourceName}`); return result; } catch (error) { - handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`); + // Redact sensitive information for telemetry while showing original in console + const redactedError = redactSyntaxErrorMessage(error); + + handleError(error, "QueryDocumentsPage", `Failed to query ${entityName} for container ${resourceName}`, { + redactedError: redactedError as Error, + }); throw error; } finally { clearMessage(); diff --git a/src/Contracts/DataExplorerMessagesContract.ts b/src/Contracts/DataExplorerMessagesContract.ts index de81ea2ea..405f4e5d5 100644 --- a/src/Contracts/DataExplorerMessagesContract.ts +++ b/src/Contracts/DataExplorerMessagesContract.ts @@ -46,6 +46,10 @@ export type DataExploreMessageV3 = params: { updateType: "created" | "deleted" | "settings"; }; + } + | { + type: FabricMessageTypes.RestoreContainer; + params: []; }; export interface GetCosmosTokenMessageOptions { verb: "connect" | "delete" | "get" | "head" | "options" | "patch" | "post" | "put" | "trace"; diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index fef85ab87..0510a7c23 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -275,8 +275,7 @@ export interface DataMaskingPolicy { startPosition: number; length: number; }>; - excludedPaths: string[]; - isPolicyEnabled: boolean; + excludedPaths?: string[]; } export interface MaterializedView { diff --git a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts index d63f0cfad..65b308b9b 100644 --- a/src/Explorer/ContainerCopy/ContainerCopyMessages.ts +++ b/src/Explorer/ContainerCopy/ContainerCopyMessages.ts @@ -184,5 +184,10 @@ export default { Skipped: "Cancelled", Cancelled: "Cancelled", }, + dialog: { + heading: "", + confirmButtonText: "Confirm", + cancelButtonText: "Cancel", + }, }, }; diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx index f8cad1cd5..a0d6035a4 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.test.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-conditional-expect */ import "@testing-library/jest-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; @@ -5,6 +6,20 @@ import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../E import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; import CopyJobActionMenu from "./CopyJobActionMenu"; +const mockShowOkCancelModalDialog = jest.fn(); +const mockCloseDialog = jest.fn(); +const mockOpenDialog = jest.fn(); + +jest.mock("../../../Controls/Dialog", () => ({ + useDialog: { + getState: () => ({ + showOkCancelModalDialog: mockShowOkCancelModalDialog, + closeDialog: mockCloseDialog, + openDialog: mockOpenDialog, + }), + }, +})); + jest.mock("../../ContainerCopyMessages", () => ({ __esModule: true, default: { @@ -18,6 +33,11 @@ jest.mock("../../ContainerCopyMessages", () => ({ cancel: "Cancel", complete: "Complete", }, + dialog: { + heading: "Confirm Action", + confirmButtonText: "Confirm", + cancelButtonText: "Cancel", + }, }, }, })); @@ -50,6 +70,9 @@ describe("CopyJobActionMenu", () => { beforeEach(() => { jest.clearAllMocks(); + mockShowOkCancelModalDialog.mockClear(); + mockCloseDialog.mockClear(); + mockOpenDialog.mockClear(); }); describe("Component Rendering", () => { @@ -266,7 +289,29 @@ describe("CopyJobActionMenu", () => { expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.pause, expect.any(Function)); }); - it("should call handleClick when cancel action is clicked", () => { + it("should show confirmation dialog when cancel action is clicked", () => { + const job = createMockJob({ Name: "Test Job", Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.any(Object), // dialogBody content + ); + }); + + it("should call handleClick when dialog is confirmed for cancel action", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress }); render(); @@ -277,6 +322,9 @@ describe("CopyJobActionMenu", () => { const cancelButton = screen.getByText("Cancel"); fireEvent.click(cancelButton); + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); }); @@ -294,7 +342,33 @@ describe("CopyJobActionMenu", () => { expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.resume, expect.any(Function)); }); - it("should call handleClick when complete action is clicked", () => { + it("should show confirmation dialog when complete action is clicked", () => { + const job = createMockJob({ + Name: "Test Online Job", + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.any(Object), // dialogBody content + ); + }); + + it("should call handleClick when dialog is confirmed for complete action", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress, Mode: CopyJobMigrationType.Online, @@ -308,10 +382,87 @@ describe("CopyJobActionMenu", () => { const completeButton = screen.getByText("Complete"); fireEvent.click(completeButton); + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); }); }); + describe("Dialog Body Content", () => { + it("should pass correct dialog body content for cancel action", () => { + const job = createMockJob({ Name: "MyTestJob", Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.objectContaining({ + props: expect.objectContaining({ + tokens: expect.any(Object), + children: expect.any(Array), + }), + }), + ); + }); + + it("should pass correct dialog body content for complete action", () => { + const job = createMockJob({ + Name: "OnlineTestJob", + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", + null, + "Confirm", + expect.any(Function), + "Cancel", + null, + expect.objectContaining({ + props: expect.objectContaining({ + tokens: expect.any(Object), + children: expect.any(Array), + }), + }), + ); + }); + + it("should not show dialog body for actions without confirmation", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled(); + }); + }); + describe("Disabled States During Updates", () => { const TestComponentWrapper: React.FC<{ job: CopyJobType; @@ -339,8 +490,13 @@ describe("CopyJobActionMenu", () => { const pauseButton = screen.getByText("Pause"); fireEvent.click(pauseButton); fireEvent.click(actionButton); - const pauseButtonAfterClick = screen.getByText("Pause"); + + const pauseButtonAfterClick = screen.getByText("Pause").closest("button"); expect(pauseButtonAfterClick).toBeInTheDocument(); + expect(pauseButtonAfterClick).toHaveAttribute("aria-disabled", "true"); + + const cancelButtonAfterClick = screen.getByText("Cancel").closest("button"); + expect(cancelButtonAfterClick).toHaveAttribute("aria-disabled", "true"); }); it("should not disable actions for different jobs when one is updating", () => { @@ -360,23 +516,7 @@ describe("CopyJobActionMenu", () => { expect(screen.getByText("Cancel")).toBeInTheDocument(); }); - it("should properly handle multiple action types being disabled for the same job", () => { - const job = createMockJob({ Status: CopyJobStatusType.InProgress }); - render(); - const actionButton = screen.getByRole("button", { name: "Actions" }); - - fireEvent.click(actionButton); - fireEvent.click(screen.getByText("Pause")); - - fireEvent.click(actionButton); - fireEvent.click(screen.getByText("Cancel")); - - fireEvent.click(actionButton); - expect(screen.getByText("Pause")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); - }); - - it("should handle complete action disabled state for online jobs", () => { + it("should disable complete action when job is being updated", () => { const job = createMockJob({ Status: CopyJobStatusType.InProgress, Mode: CopyJobMigrationType.Online, @@ -390,8 +530,34 @@ describe("CopyJobActionMenu", () => { const completeButton = screen.getByText("Complete"); fireEvent.click(completeButton); + // Simulate dialog confirmation to trigger state update + const [, , , onOkCallback] = mockShowOkCancelModalDialog.mock.calls[0]; + onOkCallback(); + fireEvent.click(actionButton); - expect(screen.getByText("Complete")).toBeInTheDocument(); + const completeButtonAfterClick = screen.getByText("Complete").closest("button"); + expect(completeButtonAfterClick).toBeInTheDocument(); + expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true"); + }); + + it("should disable complete action when any other action is being performed", () => { + const job = createMockJob({ + Status: CopyJobStatusType.InProgress, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + fireEvent.click(actionButton); + + const completeButtonAfterClick = screen.getByText("Complete").closest("button"); + expect(completeButtonAfterClick).toBeInTheDocument(); + expect(completeButtonAfterClick).toHaveAttribute("aria-disabled", "true"); }); }); @@ -462,6 +628,7 @@ describe("CopyJobActionMenu", () => { expect(actionButton).toHaveAttribute("aria-label", "Actions"); expect(actionButton).toHaveAttribute("title", "Actions"); + expect(actionButton).toHaveAttribute("role", "button"); const moreIcon = actionButton.querySelector('[data-icon-name="More"]'); expect(moreIcon || actionButton).toBeInTheDocument(); @@ -608,4 +775,129 @@ describe("CopyJobActionMenu", () => { }).not.toThrow(); }); }); + + describe("Complete Coverage Tests", () => { + it("should handle all possible dialog scenarios", () => { + const dialogTests = [ + { action: CopyJobActions.cancel, status: CopyJobStatusType.InProgress, shouldShowDialog: true }, + { + action: CopyJobActions.complete, + status: CopyJobStatusType.InProgress, + mode: CopyJobMigrationType.Online, + shouldShowDialog: true, + }, + { action: CopyJobActions.pause, status: CopyJobStatusType.InProgress, shouldShowDialog: false }, + { action: CopyJobActions.resume, status: CopyJobStatusType.Paused, shouldShowDialog: false }, + ]; + + dialogTests.forEach(({ action, status, mode = CopyJobMigrationType.Offline, shouldShowDialog }, index) => { + jest.clearAllMocks(); + + const job = createMockJob({ Status: status, Mode: mode, Name: `DialogTestJob${index}` }); + const { unmount } = render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const actionText = action.charAt(0).toUpperCase() + action.slice(1); + if (screen.queryByText(actionText)) { + fireEvent.click(screen.getByText(actionText)); + + if (shouldShowDialog) { + expect(mockShowOkCancelModalDialog).toHaveBeenCalled(); + } else { + expect(mockShowOkCancelModalDialog).not.toHaveBeenCalled(); + expect(mockHandleClick).toHaveBeenCalled(); + } + } + + unmount(); + }); + }); + + it("should verify component handles state updates correctly", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const stateUpdater = jest.fn(); + + const testHandleClick: HandleJobActionClickType = (job, action, setUpdatingJobAction) => { + setUpdatingJobAction({ jobName: job.Name, action }); + stateUpdater(job.Name, action); + }; + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const pauseButton = screen.getByText("Pause"); + fireEvent.click(pauseButton); + + expect(stateUpdater).toHaveBeenCalledWith(job.Name, CopyJobActions.pause); + }); + }); + + describe("Full Integration Coverage", () => { + it("should test complete workflow for cancel action with dialog", () => { + const job = createMockJob({ Name: "Integration Test Job", Status: CopyJobStatusType.InProgress }); + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + expect(actionButton).toHaveAttribute("data-test", "CopyJobActionMenu/Button:Integration Test Job"); + fireEvent.click(actionButton); + + const cancelButton = screen.getByText("Cancel"); + fireEvent.click(cancelButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalledWith( + "Confirm Action", // title + null, // subText + "Confirm", // confirmLabel + expect.any(Function), // onOk + "Cancel", // cancelLabel + null, // onCancel + expect.any(Object), // contentHtml (dialogBody) + ); + + const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3]; + onOkCallback(); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.cancel, expect.any(Function)); + }); + + it("should test complete workflow for complete action with dialog", () => { + const job = createMockJob({ + Name: "Online Integration Job", + Status: CopyJobStatusType.Running, + Mode: CopyJobMigrationType.Online, + }); + + render(); + + const actionButton = screen.getByRole("button", { name: "Actions" }); + fireEvent.click(actionButton); + + const completeButton = screen.getByText("Complete"); + fireEvent.click(completeButton); + + expect(mockShowOkCancelModalDialog).toHaveBeenCalled(); + + const dialogContent = mockShowOkCancelModalDialog.mock.calls[0][6]; + expect(dialogContent).toBeTruthy(); + + const onOkCallback = mockShowOkCancelModalDialog.mock.calls[0][3]; + onOkCallback(); + + expect(mockHandleClick).toHaveBeenCalledWith(job, CopyJobActions.complete, expect.any(Function)); + }); + + it("should maintain proper component lifecycle", () => { + const job = createMockJob({ Status: CopyJobStatusType.InProgress }); + const { rerender, unmount } = render(); + + rerender(); + expect(screen.getByRole("button", { name: "Actions" })).toBeInTheDocument(); + + expect(() => unmount()).not.toThrow(); + }); + }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx index 5d41b8595..682e20c9a 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobActionMenu.tsx @@ -1,5 +1,6 @@ -import { IconButton, IContextualMenuProps } from "@fluentui/react"; +import { DirectionalHint, IconButton, IContextualMenuProps, Stack } from "@fluentui/react"; import React from "react"; +import { useDialog } from "../../../Controls/Dialog"; import ContainerCopyMessages from "../../ContainerCopyMessages"; import { CopyJobActions, CopyJobMigrationType, CopyJobStatusType } from "../../Enums/CopyJobEnums"; import { CopyJobType, HandleJobActionClickType } from "../../Types/CopyJobTypes"; @@ -9,6 +10,28 @@ interface CopyJobActionMenuProps { handleClick: HandleJobActionClickType; } +const dialogBody = { + [CopyJobActions.cancel]: (jobName: string) => ( + + + You are about to cancel {jobName} copy job. + + Cancelling will stop the job immediately. + + ), + [CopyJobActions.complete]: (jobName: string) => ( + + + You are about to complete {jobName} copy job. + + + Once completed, continuous data copy will stop after any pending documents are processed. To maintain data + integrity, we recommend stopping updates to the source container before completing the job. + + + ), +}; + const CopyJobActionMenu: React.FC = ({ job, handleClick }) => { const [updatingJobAction, setUpdatingJobAction] = React.useState<{ jobName: string; action: string } | null>(null); if ( @@ -22,9 +45,22 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick return null; } + const showActionConfirmationDialog = (job: CopyJobType, action: CopyJobActions): void => { + useDialog + .getState() + .showOkCancelModalDialog( + ContainerCopyMessages.MonitorJobs.dialog.heading, + null, + ContainerCopyMessages.MonitorJobs.dialog.confirmButtonText, + () => handleClick(job, action, setUpdatingJobAction), + ContainerCopyMessages.MonitorJobs.dialog.cancelButtonText, + null, + action in dialogBody ? dialogBody[action as keyof typeof dialogBody](job.Name) : null, + ); + }; + const getMenuItems = (): IContextualMenuProps["items"] => { const isThisJobUpdating = updatingJobAction?.jobName === job.Name; - const updatingAction = updatingJobAction?.action; const baseItems = [ { @@ -32,21 +68,21 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick text: ContainerCopyMessages.MonitorJobs.Actions.pause, iconProps: { iconName: "Pause" }, onClick: () => handleClick(job, CopyJobActions.pause, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.pause, + disabled: isThisJobUpdating, }, { key: CopyJobActions.cancel, text: ContainerCopyMessages.MonitorJobs.Actions.cancel, iconProps: { iconName: "Cancel" }, - onClick: () => handleClick(job, CopyJobActions.cancel, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.cancel, + onClick: () => showActionConfirmationDialog(job, CopyJobActions.cancel), + disabled: isThisJobUpdating, }, { key: CopyJobActions.resume, text: ContainerCopyMessages.MonitorJobs.Actions.resume, iconProps: { iconName: "Play" }, onClick: () => handleClick(job, CopyJobActions.resume, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.resume, + disabled: isThisJobUpdating, }, ]; @@ -67,8 +103,8 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick key: CopyJobActions.complete, text: ContainerCopyMessages.MonitorJobs.Actions.complete, iconProps: { iconName: "CheckMark" }, - onClick: () => handleClick(job, CopyJobActions.complete, setUpdatingJobAction), - disabled: isThisJobUpdating && updatingAction === CopyJobActions.complete, + onClick: () => showActionConfirmationDialog(job, CopyJobActions.complete), + disabled: isThisJobUpdating, }); } return filteredItems; @@ -86,8 +122,8 @@ const CopyJobActionMenu: React.FC = ({ job, handleClick data-test={`CopyJobActionMenu/Button:${job.Name}`} role="button" iconProps={{ iconName: "More", styles: { root: { fontSize: "20px", fontWeight: "bold" } } }} - menuProps={{ items: getMenuItems() }} - menuIconProps={{ iconName: "" }} + menuProps={{ items: getMenuItems(), directionalHint: DirectionalHint.leftTopEdge, directionalHintFixed: false }} + menuIconProps={{ iconName: "", className: "hidden" }} ariaLabel={ContainerCopyMessages.MonitorJobs.Columns.actions} title={ContainerCopyMessages.MonitorJobs.Columns.actions} /> diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx index c3b723265..64778f2ff 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.test.tsx @@ -11,9 +11,17 @@ jest.mock("../../Actions/CopyJobActions", () => ({ jest.mock("./CopyJobColumns", () => ({ getColumns: jest.fn(() => [ + { + key: "LastUpdatedTime", + name: "Date & time", + fieldName: "LastUpdatedTime", + minWidth: 140, + maxWidth: 300, + isResizable: true, + }, { key: "Name", - name: "Name", + name: "Job name", fieldName: "Name", minWidth: 140, maxWidth: 300, @@ -165,6 +173,165 @@ describe("CopyJobsList", () => { expect(screen.getByTestId("action-menu-job-2")).toBeInTheDocument(); expect(screen.getByTestId("action-menu-job-3")).toBeInTheDocument(); }); + + it("renders filter TextField with data-test attribute", () => { + render(); + + const filterTextField = document.querySelector('[data-test="CopyJobsList/FilterTextField"]'); + expect(filterTextField).toBeInTheDocument(); + }); + + it("renders search TextField with correct placeholder", () => { + render(); + + const searchInput = screen.getByPlaceholderText("Search jobs..."); + expect(searchInput).toBeInTheDocument(); + }); + }); + + describe("Filtering", () => { + it("filters jobs by Name when text is entered", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs case-insensitively", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "test job 1" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + }); + + it("shows all jobs when filter text is empty", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + }); + + fireEvent.change(filterInput, { target: { value: "" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("filters jobs by Status across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: CopyJobStatusType.Running } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters jobs by Mode across all columns", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Offline" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("shows no results when filter matches no jobs", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "NonExistentJob" } }); + + await waitFor(() => { + expect(screen.queryByText("Test Job 1")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 2")).not.toBeInTheDocument(); + expect(screen.queryByText("Test Job 3")).not.toBeInTheDocument(); + }); + }); + + it("filters by partial text match", async () => { + render(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Test" } }); + + await waitFor(() => { + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + expect(screen.getByText("Test Job 2")).toBeInTheDocument(); + expect(screen.getByText("Test Job 3")).toBeInTheDocument(); + }); + }); + + it("resets pagination when filter changes", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: `Test Job ${i + 1}`, + })); + + render(); + + // Navigate to page 2 + fireEvent.click(screen.getByLabelText("Go to next page")); + + await waitFor(() => { + expect(screen.getByText("Showing 11 - 20 of 25 items")).toBeInTheDocument(); + }); + + // Apply filter - should reset to page 1 + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Job 1" } }); + + await waitFor(() => { + // Filtered results show from the beginning + expect(screen.getByText("Test Job 1")).toBeInTheDocument(); + }); + }); + + it("updates filtered count in pager", async () => { + const manyJobs: CopyJobType[] = Array.from({ length: 25 }, (_, i) => ({ + ...mockJobs[0], + ID: `job-${i + 1}`, + Name: i < 5 ? `Alpha Job ${i + 1}` : `Beta Job ${i + 1}`, + })); + + render(); + + expect(screen.getByText("Showing 1 - 10 of 25 items")).toBeInTheDocument(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Alpha" } }); + + await waitFor(() => { + expect(screen.queryByText("Showing 1 - 10 of 25 items")).not.toBeInTheDocument(); + // Pager should not be visible since filtered results (5) are less than page size (10) + expect(screen.queryByLabelText("Go to next page")).not.toBeInTheDocument(); + }); + }); }); describe("Pagination", () => { @@ -342,7 +509,7 @@ describe("CopyJobsList", () => { describe("Component Props", () => { it("uses default page size when not provided", () => { - const manyJobs: CopyJobType[] = Array.from({ length: 12 }, (_, i) => ({ + const manyJobs: CopyJobType[] = Array.from({ length: 20 }, (_, i) => ({ ...mockJobs[0], ID: `job-${i + 1}`, Name: `Test Job ${i + 1}`, @@ -351,7 +518,7 @@ describe("CopyJobsList", () => { render(); expect(screen.getByLabelText("Go to next page")).toBeInTheDocument(); - expect(screen.getByText("Showing 1 - 10 of 12 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 20 items")).toBeInTheDocument(); }); it("passes correct props to getColumns function", async () => { @@ -440,7 +607,33 @@ describe("CopyJobsList", () => { render(); }).not.toThrow(); - expect(screen.getByText("Showing 1 - 10 of 1000 items")).toBeInTheDocument(); + expect(screen.getByText("Showing 1 - 15 of 1000 items")).toBeInTheDocument(); + }); + + it("handles filtering with null or undefined values gracefully", async () => { + const jobsWithNullValues: CopyJobType[] = [ + { + ...mockJobs[0], + ID: "job-with-values", + Name: "Valid Job", + }, + { + ...mockJobs[1], + ID: "job-null-name", + Name: undefined as unknown as string, + }, + ]; + + expect(() => { + render(); + }).not.toThrow(); + + const filterInput = screen.getByPlaceholderText("Search jobs..."); + fireEvent.change(filterInput, { target: { value: "Valid" } }); + + await waitFor(() => { + expect(screen.getByText("Valid Job")).toBeInTheDocument(); + }); }); }); }); diff --git a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx index a263ac137..dcdfd1033 100644 --- a/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx +++ b/src/Explorer/ContainerCopy/MonitorCopyJobs/Components/CopyJobsList.tsx @@ -12,8 +12,9 @@ import { Stack, Sticky, StickyPositionType, + TextField, } from "@fluentui/react"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import Pager from "../../../../Common/Pager"; import { useThemeStore } from "../../../../hooks/useTheme"; import { getThemeTokens } from "../../../Theme/ThemeUtil"; @@ -30,9 +31,15 @@ interface CopyJobsListProps { const styles = { container: { height: "100%" } as React.CSSProperties, stackItem: { position: "relative", marginBottom: "20px" } as React.CSSProperties, + filterContainer: { + margin: "15px 5px", + }, }; -const PAGE_SIZE = 10; +const PAGE_SIZE = 15; + +// Columns to search across +const searchableFields = ["Name", "Status", "LastUpdatedTime", "Mode"]; const CopyJobsList: React.FC = ({ jobs, handleActionClick, pageSize = PAGE_SIZE }) => { const isDarkMode = useThemeStore((state) => state.isDarkMode); @@ -41,6 +48,23 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa const [sortedJobs, setSortedJobs] = React.useState(jobs); const [sortedColumnKey, setSortedColumnKey] = React.useState(undefined); const [isSortedDescending, setIsSortedDescending] = React.useState(false); + const [filterText, setFilterText] = React.useState(""); + + const filteredJobs = useMemo(() => { + if (!filterText) { + return sortedJobs; + } + const lowerFilterText = filterText.toLowerCase(); + return sortedJobs.filter((job: any) => { + return searchableFields.some((field) => { + const value = job[field]; + if (value === undefined || value === null) { + return false; + } + return String(value).toLowerCase().includes(lowerFilterText); + }); + }); + }, [sortedJobs, filterText]); useEffect(() => { setSortedJobs(jobs); @@ -64,7 +88,15 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa setStartIndex(0); }; - const columns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + const sortableColumns: IColumn[] = getColumns(handleSort, handleActionClick, sortedColumnKey, isSortedDescending); + + const handleFilterTextChange = ( + _event: React.FormEvent, + newValue?: string, + ) => { + setFilterText(newValue || ""); + setStartIndex(0); + }; const _handleRowClick = (job: CopyJobType) => { openCopyJobDetailsPanel(job); @@ -81,14 +113,25 @@ const CopyJobsList: React.FC = ({ jobs, handleActionClick, pa return (
+ +
+ +
+
= ({ jobs, handleActionClick, pa /> - {sortedJobs.length > pageSize && ( + {filteredJobs.length > pageSize && ( { setStartIndex(startIdx); diff --git a/src/Explorer/ContainerCopy/containerCopyStyles.less b/src/Explorer/ContainerCopy/containerCopyStyles.less index 6f99f4055..9cc625860 100644 --- a/src/Explorer/ContainerCopy/containerCopyStyles.less +++ b/src/Explorer/ContainerCopy/containerCopyStyles.less @@ -1,5 +1,27 @@ @import "../../../less/Common/Constants.less"; +.themedTextFieldStyles() { + .ms-TextField { + .ms-TextField-fieldGroup { + background-color: var(--colorNeutralBackground1); + border-color: var(--colorNeutralStroke1); + } + + .ms-TextField-field { + color: var(--colorNeutralForeground1); + background-color: var(--colorNeutralBackground1); + + &::placeholder { + color: var(--colorNeutralForeground4); + } + } + + .ms-Label { + color: var(--colorNeutralForeground1); + } + } +} + // Common theme-aware classes .themeText { color: var(--colorNeutralForeground1); @@ -119,25 +141,8 @@ filter: invert(1); } - .ms-TextField { - .ms-TextField-fieldGroup { - background-color: var(--colorNeutralBackground1); - border-color: var(--colorNeutralStroke1); - } + .themedTextFieldStyles(); - .ms-TextField-field { - color: var(--colorNeutralForeground1); - background-color: var(--colorNeutralBackground1); - - &::placeholder { - color: var(--colorNeutralForeground4); - } - } - - .ms-Label { - color: var(--colorNeutralForeground1); - } - } .migrationTypeDescription { p { color: var(--colorNeutralForeground1); @@ -173,6 +178,11 @@ width: 100%; max-width: 100%; margin: 0 auto; + + body.isDarkMode & { + .themedTextFieldStyles(); + } + .ms-DetailsList { width: 100%; diff --git a/src/Explorer/ContextMenuButtonFactory.tsx b/src/Explorer/ContextMenuButtonFactory.tsx index 3f6a795ee..76b75dda8 100644 --- a/src/Explorer/ContextMenuButtonFactory.tsx +++ b/src/Explorer/ContextMenuButtonFactory.tsx @@ -7,7 +7,7 @@ import { AddGlobalSecondaryIndexPanelProps, } from "Explorer/Panes/AddGlobalSecondaryIndexPanel/AddGlobalSecondaryIndexPanel"; import { useDatabases } from "Explorer/useDatabases"; -import { isFabric, isFabricNative } from "Platform/Fabric/FabricUtil"; +import { isFabric, isFabricNative, openRestoreContainerDialog } from "Platform/Fabric/FabricUtil"; import { Action } from "Shared/Telemetry/TelemetryConstants"; import { traceOpen } from "Shared/Telemetry/TelemetryProcessor"; import { ReactTabKind, useTabs } from "hooks/useTabs"; @@ -35,6 +35,7 @@ import StoredProcedure from "./Tree/StoredProcedure"; import Trigger from "./Tree/Trigger"; import UserDefinedFunction from "./Tree/UserDefinedFunction"; import { useSelectedNode } from "./useSelectedNode"; +import { extractFeatures } from "../Platform/Hosted/extractFeatures"; export interface CollectionContextMenuButtonParams { databaseId: string; @@ -60,6 +61,17 @@ export const createDatabaseContextMenu = (container: Explorer, databaseId: strin }, ]; + if (isFabricNative() && !userContext.fabricContext?.isReadOnly) { + const features = extractFeatures(); + if (features?.enableRestoreContainer) { + items.push({ + iconSrc: AddCollectionIcon, + onClick: () => openRestoreContainerDialog(), + label: `Restore ${getCollectionName()}`, + }); + } + } + if (!isFabricNative() && (userContext.apiType !== "Tables" || userContext.features.enableSDKoperations)) { items.push({ iconSrc: DeleteDatabaseIcon, diff --git a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx index 7dfd49b11..ff7485b5e 100644 --- a/src/Explorer/Controls/Settings/SettingsComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsComponent.test.tsx @@ -30,7 +30,6 @@ jest.mock("../../../Common/dataAccess/updateCollection", () => ({ dataMaskingPolicy: { includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }, indexes: [], }), @@ -307,12 +306,10 @@ describe("SettingsComponent", () => { dataMaskingContent: { includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }, dataMaskingContentBaseline: { includedPaths: [], excludedPaths: [], - isPolicyEnabled: false, }, isDataMaskingDirty: true, }); @@ -326,7 +323,6 @@ describe("SettingsComponent", () => { expect(wrapper.state("dataMaskingContentBaseline")).toEqual({ includedPaths: [], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }); }); @@ -340,7 +336,6 @@ describe("SettingsComponent", () => { const invalidPolicy: InvalidPolicy = { includedPaths: "invalid", excludedPaths: [], - isPolicyEnabled: false, }; // Use type assertion since we're deliberately testing with invalid data settingsComponentInstance["onDataMaskingContentChange"](invalidPolicy as unknown as DataModels.DataMaskingPolicy); @@ -349,7 +344,6 @@ describe("SettingsComponent", () => { expect(wrapper.state("dataMaskingContent")).toEqual({ includedPaths: "invalid", excludedPaths: [], - isPolicyEnabled: false, }); expect(wrapper.state("dataMaskingValidationErrors")).toEqual(["includedPaths must be an array"]); @@ -364,7 +358,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath"], - isPolicyEnabled: true, }; settingsComponentInstance["onDataMaskingContentChange"](validPolicy); @@ -388,7 +381,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath1"], - isPolicyEnabled: false, }; const modifiedPolicy = { @@ -401,7 +393,6 @@ describe("SettingsComponent", () => { }, ], excludedPaths: ["/excludedPath2"], - isPolicyEnabled: true, }; // Set initial state diff --git a/src/Explorer/Controls/Settings/SettingsComponent.tsx b/src/Explorer/Controls/Settings/SettingsComponent.tsx index 95f7159cc..9f50b53c2 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 { isCapabilityEnabled, isVectorSearchEnabled } from "Utils/CapabilityUtils"; +import { isVectorSearchEnabled } from "Utils/CapabilityUtils"; import { isRunningOnPublicCloud } from "Utils/CloudUtils"; import * as React from "react"; import DiscardIcon from "../../../../images/discard.svg"; @@ -70,6 +70,7 @@ import { getMongoNotification, getTabTitle, hasDatabaseSharedThroughput, + isDataMaskingEnabled, isDirty, parseConflictResolutionMode, parseConflictResolutionProcedure, @@ -686,22 +687,14 @@ export class SettingsComponent extends React.Component { - if (!newDataMasking.excludedPaths) { - newDataMasking.excludedPaths = []; - } - if (!newDataMasking.includedPaths) { - newDataMasking.includedPaths = []; - } - const validationErrors = []; - if (!Array.isArray(newDataMasking.includedPaths)) { + if (newDataMasking.includedPaths === undefined || newDataMasking.includedPaths === null) { + validationErrors.push("includedPaths is required"); + } else 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.isPolicyEnabled !== "boolean") { - validationErrors.push("isPolicyEnabled must be a boolean"); + if (newDataMasking.excludedPaths !== undefined && !Array.isArray(newDataMasking.excludedPaths)) { + validationErrors.push("excludedPaths must be an array if provided"); } this.setState({ @@ -842,7 +835,6 @@ 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()) { + if (isDataMaskingEnabled(this.collection.dataMaskingPolicy?.())) { const dataMaskingComponentProps: DataMaskingComponentProps = { shouldDiscardDataMasking: this.state.shouldDiscardDataMasking, resetShouldDiscardDataMasking: this.resetShouldDiscardDataMasking, diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx index a51d55a32..4e25c1980 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.test.tsx @@ -53,7 +53,6 @@ describe("DataMaskingComponent", () => { }, ], excludedPaths: [], - isPolicyEnabled: false, }; let changeContentCallback: () => void; @@ -78,7 +77,7 @@ describe("DataMaskingComponent", () => { , ); @@ -123,7 +122,7 @@ describe("DataMaskingComponent", () => { }); it("resets content when shouldDiscardDataMasking is true", async () => { - const baselinePolicy = { ...samplePolicy, isPolicyEnabled: true }; + const baselinePolicy = { ...samplePolicy, excludedPaths: ["/excluded"] }; const wrapper = mount( { wrapper.update(); // Update baseline to trigger componentDidUpdate - const newBaseline = { ...samplePolicy, isPolicyEnabled: true }; + const newBaseline = { ...samplePolicy, excludedPaths: ["/excluded"] }; wrapper.setProps({ dataMaskingContentBaseline: newBaseline }); expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); @@ -174,7 +173,6 @@ describe("DataMaskingComponent", () => { const invalidPolicy: Record = { includedPaths: "not an array", excludedPaths: [] as string[], - isPolicyEnabled: "not a boolean", }; mockGetValue.mockReturnValue(JSON.stringify(invalidPolicy)); @@ -197,7 +195,7 @@ describe("DataMaskingComponent", () => { wrapper.update(); // First change - const modifiedPolicy1 = { ...samplePolicy, isPolicyEnabled: true }; + const modifiedPolicy1 = { ...samplePolicy, excludedPaths: ["/path1"] }; mockGetValue.mockReturnValue(JSON.stringify(modifiedPolicy1)); changeContentCallback(); expect(mockProps.onDataMaskingDirtyChange).toHaveBeenCalledWith(true); diff --git a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx index 80314fe7c..61ac40931 100644 --- a/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx +++ b/src/Explorer/Controls/Settings/SettingsSubComponents/DataMaskingComponent.tsx @@ -1,12 +1,10 @@ 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"; +import { isDirty as isContentDirty, isDataMaskingEnabled } from "../SettingsUtils"; export interface DataMaskingComponentProps { shouldDiscardDataMasking: boolean; @@ -24,16 +22,8 @@ interface DataMaskingComponentState { } const emptyDataMaskingPolicy: DataModels.DataMaskingPolicy = { - includedPaths: [ - { - path: "/", - strategy: "Default", - startPosition: 0, - length: -1, - }, - ], + includedPaths: [], excludedPaths: [], - isPolicyEnabled: true, }; export class DataMaskingComponent extends React.Component { @@ -140,7 +130,7 @@ export class DataMaskingComponent extends React.Component { + const isSqlAccount = userContext.apiType === "SQL"; + if (!isSqlAccount) { + return false; + } + + const hasDataMaskingCapability = isCapabilityEnabled(Constants.CapabilityNames.EnableDynamicDataMasking); + const hasDataMaskingPolicyFromCollection = + dataMaskingPolicy?.includedPaths?.length > 0 || dataMaskingPolicy?.excludedPaths?.length > 0; + + return hasDataMaskingCapability || hasDataMaskingPolicyFromCollection; +}; + export const parseConflictResolutionMode = (modeFromBackend: string): DataModels.ConflictResolutionMode => { // Backend can contain different casing as it does case-insensitive comparisson if (!modeFromBackend) { diff --git a/src/Explorer/Controls/Settings/TestUtils.tsx b/src/Explorer/Controls/Settings/TestUtils.tsx index f30e84709..c3b3f8b84 100644 --- a/src/Explorer/Controls/Settings/TestUtils.tsx +++ b/src/Explorer/Controls/Settings/TestUtils.tsx @@ -68,7 +68,6 @@ export const collection = { dataMaskingPolicy: ko.observable({ includedPaths: [], excludedPaths: ["/excludedPath"], - 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 569bfd035..7f8452ddf 100644 --- a/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap +++ b/src/Explorer/Controls/Settings/__snapshots__/SettingsComponent.test.tsx.snap @@ -604,6 +604,58 @@ exports[`SettingsComponent renders 1`] = ` />
+ + + + + = ({ explorer }) => { const container = explorer; const subscriptions: Array<{ dispose: () => void }> = []; + let title: string; + let subtitle: string; + + switch (userContext.apiType) { + case "Postgres": + title = "Welcome to Azure Cosmos DB for PostgreSQL"; + subtitle = "Get started with our sample datasets, documentation, and additional tools."; + break; + case "VCoreMongo": + title = "Welcome to Azure DocumentDB (with MongoDB compatibility)"; + subtitle = "Get started with our sample datasets, documentation, and additional tools."; + break; + default: + title = "Welcome to Azure Cosmos DB"; + subtitle = "Globally distributed, multi-model database service for any scale"; + } + React.useEffect(() => { subscriptions.push( { @@ -902,10 +919,11 @@ export const SplashScreen: React.FC = ({ explorer }) => { return (
-

- Welcome to Azure Cosmos DB +

+ {title} +

-
Globally distributed, multi-model database service for any scale
+
{subtitle}
{getSplashScreenButtons()} {useCarousel.getState().showCoachMark && ( (), excludedPaths: Array(), - isPolicyEnabled: true, }; const observablePolicy = ko.observable(data.dataMaskingPolicy || defaultDataMaskingPolicy); observablePolicy.subscribe(() => {}); diff --git a/src/Main.tsx b/src/Main.tsx index f30ff9902..c90f016ac 100644 --- a/src/Main.tsx +++ b/src/Main.tsx @@ -105,9 +105,12 @@ const App = (): JSX.Element => { // Scenario-based health tracking: start ApplicationLoad and complete phases. const { startScenario, completePhase } = useMetricScenario(); React.useEffect(() => { - startScenario(MetricScenario.ApplicationLoad); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Only start scenario after config is initialized to avoid race conditions + // with message handlers that depend on configContext.platform + if (config) { + startScenario(MetricScenario.ApplicationLoad); + } + }, [config, startScenario]); React.useEffect(() => { if (explorer) { @@ -128,6 +131,7 @@ const App = (): JSX.Element => { <> + ) : ( diff --git a/src/NotebookViewer/NotebookViewer.tsx b/src/NotebookViewer/NotebookViewer.tsx deleted file mode 100644 index 2bb1441b2..000000000 --- a/src/NotebookViewer/NotebookViewer.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { initializeIcons } from "@fluentui/react"; -import "bootstrap/dist/css/bootstrap.css"; -import React from "react"; -import * as ReactDOM from "react-dom"; -import { configContext, initializeConfiguration } from "../ConfigContext"; -import { GalleryHeaderComponent } from "../Explorer/Controls/Header/GalleryHeaderComponent"; -import { GalleryTab } from "../Explorer/Controls/NotebookGallery/GalleryViewerComponent"; -import { - NotebookViewerComponent, - NotebookViewerComponentProps, -} from "../Explorer/Controls/NotebookViewer/NotebookViewerComponent"; -import * as FileSystemUtil from "../Explorer/Notebook/FileSystemUtil"; -import { IGalleryItem, JunoClient } from "../Juno/JunoClient"; -import * as GalleryUtils from "../Utils/GalleryUtils"; - -const onInit = async () => { - initializeIcons(); - await initializeConfiguration(); - const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search); - const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search); - let backNavigationText: string; - let onBackClick: () => void; - if (galleryViewerProps.selectedTab !== undefined) { - backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); - onBackClick = () => - (window.location.href = `${configContext.hostedExplorerURL}gallery.html?tab=${ - GalleryTab[galleryViewerProps.selectedTab] - }`); - } - const hideInputs = notebookViewerProps.hideInputs; - - const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl); - - const galleryItemId = notebookViewerProps.galleryItemId; - let galleryItem: IGalleryItem; - - if (galleryItemId) { - const junoClient = new JunoClient(); - const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId); - galleryItem = galleryItemJunoResponse.data; - } - - // The main purpose of hiding the prompt is to hide everything when hiding inputs. - // It is generally not very useful to just hide the prompt. - const hidePrompts = hideInputs; - - render(notebookUrl, backNavigationText, hideInputs, hidePrompts, galleryItem, onBackClick); -}; - -const render = ( - notebookUrl: string, - backNavigationText: string, - hideInputs?: boolean, - hidePrompts?: boolean, - galleryItem?: IGalleryItem, - onBackClick?: () => void, -) => { - const props: NotebookViewerComponentProps = { - junoClient: galleryItem ? new JunoClient() : undefined, - notebookUrl, - galleryItem, - backNavigationText, - hideInputs, - hidePrompts, - onBackClick: onBackClick, - onTagClick: undefined, - }; - - if (galleryItem) { - document.title = FileSystemUtil.stripExtension(galleryItem.name, "ipynb"); - } - - const element = ( - <> -
- -
-
- -
- - ); - - ReactDOM.render(element, document.getElementById("notebookContent")); -}; - -// Entry point -window.addEventListener("load", onInit); diff --git a/src/Platform/Fabric/FabricUtil.ts b/src/Platform/Fabric/FabricUtil.ts index 22ac2603a..52e3a645d 100644 --- a/src/Platform/Fabric/FabricUtil.ts +++ b/src/Platform/Fabric/FabricUtil.ts @@ -105,6 +105,12 @@ const requestAndStoreAccessToken = async (): Promise => { }); }; +export const openRestoreContainerDialog = (): void => { + if (isFabricNative()) { + sendCachedDataMessage(FabricMessageTypes.RestoreContainer, []); + } +}; + /** * Check token validity and schedule a refresh if necessary * @param tokenTimestamp diff --git a/src/Platform/Hosted/extractFeatures.ts b/src/Platform/Hosted/extractFeatures.ts index b5e324116..e9c4fc6b1 100644 --- a/src/Platform/Hosted/extractFeatures.ts +++ b/src/Platform/Hosted/extractFeatures.ts @@ -40,6 +40,7 @@ export type Features = { readonly disableConnectionStringLogin: boolean; readonly enableContainerCopy: boolean; readonly enableCloudShell: boolean; + readonly enableRestoreContainer: boolean; // only for Fabric // can be set via both flight and feature flag autoscaleDefault: boolean; @@ -93,7 +94,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear notebookBasePath: get("notebookbasepath"), notebookServerToken: get("notebookservertoken"), notebookServerUrl: get("notebookserverurl"), - sandboxNotebookOutputs: "true" === get("sandboxnotebookoutputs", "true"), + sandboxNotebookOutputs: true, selfServeType: get("selfservetype"), showMinRUSurvey: "true" === get("showminrusurvey"), ttl90Days: "true" === get("ttl90days"), @@ -111,6 +112,7 @@ export function extractFeatures(given = new URLSearchParams(window.location.sear enablePriorityBasedExecution: "true" === get("enableprioritybasedexecution"), disableConnectionStringLogin: "true" === get("disableconnectionstringlogin"), enableContainerCopy: "true" === get("enablecontainercopy"), + enableRestoreContainer: "true" === get("enablerestorecontainer"), enableCloudShell: true, }; } diff --git a/src/Utils/AuthorizationUtils.test.ts b/src/Utils/AuthorizationUtils.test.ts index 650f2ed17..305b1cc9d 100644 --- a/src/Utils/AuthorizationUtils.test.ts +++ b/src/Utils/AuthorizationUtils.test.ts @@ -27,7 +27,7 @@ describe("AuthorizationUtils", () => { enableKoResourceTree: false, enableThroughputBuckets: false, hostedDataExplorer: false, - sandboxNotebookOutputs: false, + sandboxNotebookOutputs: true, showMinRUSurvey: false, ttl90Days: false, enableThroughputCap: false, @@ -43,6 +43,7 @@ describe("AuthorizationUtils", () => { partitionKeyDefault: false, partitionKeyDefault2: false, notebooksDownBanner: false, + enableRestoreContainer: false, }, }); }; diff --git a/test/fx.ts b/test/fx.ts index 1de8be90d..9c8c382a3 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -58,7 +58,9 @@ export const defaultAccounts: Record = { export const resourceGroupName = process.env.DE_TEST_RESOURCE_GROUP ?? "de-e2e-tests"; export const subscriptionId = process.env.DE_TEST_SUBSCRIPTION_ID ?? "69e02f2d-f059-4409-9eac-97e8a276ae2c"; export const TEST_AUTOSCALE_THROUGHPUT_RU = 1000; +export const TEST_MANUAL_THROUGHPUT_RU = 800; export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_2K = 2000; +export const TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K = 4000; export const TEST_MANUAL_THROUGHPUT_RU_2K = 2000; export const ONE_MINUTE_MS: number = 60 * 1000; @@ -378,9 +380,11 @@ type PanelOpenOptions = { export enum CommandBarButton { Save = "Save", + Delete = "Delete", Execute = "Execute", ExecuteQuery = "Execute Query", UploadItem = "Upload Item", + NewDocument = "New Document", } /** Helper class that provides locator methods for DataExplorer components, on top of a Frame */ @@ -478,7 +482,7 @@ export class DataExplorer { return await this.waitForNode(`${databaseId}/${containerId}/Documents`); } - async waitForCommandBarButton(label: string, timeout?: number): Promise { + async waitForCommandBarButton(label: CommandBarButton, timeout?: number): Promise { const commandBar = this.commandBarButton(label); await commandBar.waitFor({ state: "visible", timeout }); return commandBar; @@ -515,15 +519,6 @@ export class DataExplorer { const containerNode = await this.waitForContainerNode(context.database.id, context.container.id); await containerNode.expand(); - // refresh tree to remove deleted database - const consoleMessages = await this.getNotificationConsoleMessages(); - const refreshButton = this.frame.getByTestId("Sidebar/RefreshButton"); - await refreshButton.click(); - await expect(consoleMessages).toContainText("Successfully refreshed databases", { - timeout: ONE_MINUTE_MS, - }); - await this.collapseNotificationConsole(); - const scaleAndSettingsButton = this.frame.getByTestId( `TreeNode:${context.database.id}/${context.container.id}/Scale & Settings`, ); diff --git a/test/mongo/document.spec.ts b/test/mongo/document.spec.ts index b6703c49a..cf98ebcc7 100644 --- a/test/mongo/document.spec.ts +++ b/test/mongo/document.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { setupCORSBypass } from "../CORSBypass"; -import { DataExplorer, DocumentsTab, TestAccount } from "../fx"; +import { CommandBarButton, DataExplorer, DocumentsTab, TestAccount } from "../fx"; import { retry, serializeMongoToJson, setPartitionKeys } from "../testData"; import { documentTestCases } from "./testCases"; @@ -48,19 +48,20 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { expect(resultData?._id).not.toBeNull(); expect(resultData?._id).toEqual(docId); }); - test(`should be able to create and delete new document from ${docId}`, async () => { + test(`should be able to create and delete new document from ${docId}`, async ({ page }) => { const span = documentsTab.documentsListPane.getByText(docId, { exact: true }).nth(0); await span.waitFor(); await expect(span).toBeVisible(); await span.click(); + await page.waitForTimeout(5000); // wait for 5 seconds to ensure document is fully loaded. waitforTimeout is not recommended generally but here we are working around flakiness in the test env + let newDocumentId; await retry(async () => { - const newDocumentButton = await explorer.waitForCommandBarButton("New Document", 5000); + const newDocumentButton = await explorer.waitForCommandBarButton(CommandBarButton.NewDocument, 5000); await expect(newDocumentButton).toBeVisible(); await expect(newDocumentButton).toBeEnabled(); await newDocumentButton.click(); - await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); newDocumentId = `${Date.now().toString()}-delete`; @@ -71,8 +72,9 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { }; await documentsTab.resultsEditor.setText(JSON.stringify(newDocument)); - const saveButton = await explorer.waitForCommandBarButton("Save", 5000); + const saveButton = await explorer.waitForCommandBarButton(CommandBarButton.Save, 5000); await saveButton.click({ timeout: 5000 }); + await expect(saveButton).toBeHidden({ timeout: 5000 }); }, 3); @@ -84,7 +86,7 @@ for (const { name, databaseId, containerId, documents } of documentTestCases) { await newSpan.click(); await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: 60 * 1000 }); - const deleteButton = await explorer.waitForCommandBarButton("Delete", 5000); + const deleteButton = await explorer.waitForCommandBarButton(CommandBarButton.Delete, 5000); await deleteButton.click(); const deleteDialogButton = await explorer.waitForDialogButton("Delete", 5000); diff --git a/test/sql/containercopy.spec.ts b/test/sql/containercopy.spec.ts deleted file mode 100644 index eff5faca1..000000000 --- a/test/sql/containercopy.spec.ts +++ /dev/null @@ -1,505 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { expect, Frame, Locator, Page, test } from "@playwright/test"; -import { set } from "lodash"; -import { truncateName } from "../../src/Explorer/ContainerCopy/CopyJobUtils"; -import { - ContainerCopy, - getAccountName, - getDropdownItemByNameOrPosition, - interceptAndInspectApiRequest, - TestAccount, - waitForApiResponse, -} from "../fx"; -import { createMultipleTestContainers } from "../testData"; - -let page: Page; -let wrapper: Locator = null!; -let panel: Locator = null!; -let frame: Frame = null!; -let expectedCopyJobNameInitial: string = null!; -let expectedJobName: string = ""; -let targetAccountName: string = ""; -let expectedSourceAccountName: string = ""; -let expectedSubscriptionName: string = ""; -const VISIBLE_TIMEOUT_MS = 30 * 1000; - -test.describe.configure({ mode: "serial" }); - -test.describe("Container Copy", () => { - test.beforeAll("Container Copy - Before All", async ({ browser }) => { - await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 3 }); - - page = await browser.newPage(); - ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); - expectedJobName = `test_job_${Date.now()}`; - targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); - }); - - test.afterEach("Container Copy - After Each", async () => { - await page.unroute(/.*/, (route) => route.continue()); - }); - - test("Loading and verifying the content of the page", async () => { - expect(wrapper).not.toBeNull(); - await expect(wrapper.getByTestId("CommandBar/Button:Create Copy Job")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible({ timeout: VISIBLE_TIMEOUT_MS }); - }); - - test("Successfully create a copy job for offline migration", async () => { - expect(wrapper).not.toBeNull(); - // Loading and verifying subscription & account dropdown - - const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); - await createCopyJobButton.click(); - panel = frame.getByTestId("Panel:Create copy job"); - await expect(panel).toBeVisible(); - - await page.waitForTimeout(10 * 1000); - - const subscriptionDropdown = panel.getByTestId("subscription-dropdown"); - - const expectedAccountName = targetAccountName; - expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText(); - - await subscriptionDropdown.click(); - const subscriptionItem = await getDropdownItemByNameOrPosition( - frame, - { name: expectedSubscriptionName }, - { ariaLabel: "Subscription" }, - ); - await subscriptionItem.click(); - - // Load account dropdown based on selected subscription - - const accountDropdown = panel.getByTestId("account-dropdown"); - await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName)); - await accountDropdown.click(); - - const accountItem = await getDropdownItemByNameOrPosition( - frame, - { name: expectedAccountName }, - { ariaLabel: "Account" }, - ); - await accountItem.click(); - - // Verifying online or offline migration functionality - /** - * This test verifies the functionality of the migration type radio that toggles between - * online and offline container copy modes. It ensures that: - * 1. When online mode is selected, the user is directed to a permissions screen - * 2. When offline mode is selected, the user bypasses the permissions screen - * 3. The UI correctly reflects the selected migration type throughout the workflow - */ - const migrationTypeContainer = panel.getByTestId("migration-type"); - const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); - await onlineCopyRadioButton.click({ force: true }); - - await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); - - await panel.getByRole("button", { name: "Next" }).click(); - - await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); - await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); - await panel.getByRole("button", { name: "Previous" }).click(); - - const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); - await offlineCopyRadioButton.click({ force: true }); - - await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); - - await panel.getByRole("button", { name: "Next" }).click(); - - await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); - await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); - - // Verifying source and target container selection - - const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); - expect(sourceContainerDropdown).toBeVisible(); - await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); - await sourceDatabaseDropdown.click(); - - const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Database" }, - ); - await sourceDbDropdownItem.click(); - - await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - await sourceContainerDropdown.click(); - const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Container" }, - ); - await sourceContainerDropdownItem.click(); - - const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); - expect(targetContainerDropdown).toBeVisible(); - await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); - await targetDatabaseDropdown.click(); - const targetDbDropdownItem = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Database" }, - ); - await targetDbDropdownItem.click(); - - await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - await targetContainerDropdown.click(); - const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition( - frame, - { position: 0 }, - { ariaLabel: "Container" }, - ); - await targetContainerDropdownItem1.click(); - - await panel.getByRole("button", { name: "Next" }).click(); - - const errorContainer = panel.getByTestId("Panel:ErrorContainer"); - await expect(errorContainer).toBeVisible(); - await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i); - - // Reselect target container to be different from source container - await targetContainerDropdown.click(); - const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition( - frame, - { position: 1 }, - { ariaLabel: "Container" }, - ); - await targetContainerDropdownItem2.click(); - - const selectedSourceDatabase = await sourceDatabaseDropdown.innerText(); - const selectedSourceContainer = await sourceContainerDropdown.innerText(); - const selectedTargetDatabase = await targetDatabaseDropdown.innerText(); - const selectedTargetContainer = await targetContainerDropdown.innerText(); - expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName( - selectedSourceContainer, - )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`; - - await panel.getByRole("button", { name: "Next" }).click(); - - await expect(errorContainer).not.toBeVisible(); - await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible(); - - // Verifying the preview of the copy job - const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); - await expect(previewContainer).toBeVisible(); - await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName); - await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName); - const jobNameInput = previewContainer.getByTestId("job-name-textfield"); - await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial)); - const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true }); - await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - - await jobNameInput.fill("test job name"); - await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/); - - // Testing API request interception with duplicate job name - const duplicateJobName = "test-job-name-1"; - await jobNameInput.fill(duplicateJobName); - - const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); - const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`; - await interceptAndInspectApiRequest( - page, - `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`, - "PUT", - new Error(expectedErrorMessage), - (url?: string) => url?.includes(duplicateJobName) ?? false, - ); - - let errorThrown = false; - try { - await copyButton.click(); - await page.waitForTimeout(2000); - } catch (error: any) { - errorThrown = true; - expect(error.message).toContain("not allowed"); - } - if (!errorThrown) { - const errorContainer = panel.getByTestId("Panel:ErrorContainer"); - await expect(errorContainer).toBeVisible(); - await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i")); - } - - await expect(panel).toBeVisible(); - - // Testing API request success with valid job name and verifying copy job creation - - const validJobName = expectedJobName; - - const copyJobCreationPromise = waitForApiResponse( - page, - `${expectedAccountName}/dataTransferJobs/${validJobName}`, - "PUT", - ); - - await jobNameInput.fill(validJobName); - await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); - - await copyButton.click(); - - const response = await copyJobCreationPromise; - expect(response.ok()).toBe(true); - - await expect(panel).not.toBeVisible({ timeout: 10000 }); - - const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); - await jobsListContainer.waitFor({ state: "visible" }); - - const jobItem = jobsListContainer.getByText(validJobName); - await jobItem.waitFor({ state: "visible" }); - await expect(jobItem).toBeVisible(); - }); - - test("Verify Online or Offline Container Copy Permissions Panel", async () => { - expect(wrapper).not.toBeNull(); - - // Opening the Create Copy Job panel again to verify initial state - const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); - await createCopyJobButton.click(); - panel = frame.getByTestId("Panel:Create copy job"); - await expect(panel).toBeVisible(); - await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible(); - - // select different account dropdown - - const accountDropdown = panel.getByTestId("account-dropdown"); - await accountDropdown.click(); - - const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); - expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account"); - - const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all(); - - const filteredItems = []; - for (const item of allDropdownItems) { - const testContent = (await item.textContent()) ?? ""; - if (testContent.trim() !== targetAccountName.trim()) { - filteredItems.push(item); - } - } - - if (filteredItems.length > 0) { - const firstDropdownItem = filteredItems[0]; - expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? ""; - await firstDropdownItem.click(); - } else { - throw new Error("No dropdown items available after filtering"); - } - - const migrationTypeContainer = panel.getByTestId("migration-type"); - const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); - await onlineCopyRadioButton.click({ force: true }); - - await panel.getByRole("button", { name: "Next" }).click(); - - // Verifying Assign Permissions panel for online copy - - const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); - await expect(permissionScreen).toBeVisible(); - - await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); - await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible(); - - // Verify Point-in-Time Restore timer and refresh button workflow - - await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => { - const mockData = { - identity: { - type: "SystemAssigned", - principalId: "00-11-22-33", - }, - properties: { - defaultIdentity: "SystemAssignedIdentity", - backupPolicy: { - type: "Continuous", - }, - capabilities: [{ name: "EnableOnlineContainerCopy" }], - }, - }; - if (route.request().method() === "GET") { - const response = await route.fetch(); - const actualData = await response.json(); - const mergedData = { ...actualData }; - - set(mergedData, "identity", mockData.identity); - set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); - set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); - set(mergedData, "properties.capabilities", mockData.properties.capabilities); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(mergedData), - }); - } else { - await route.continue(); - } - }); - - await expect(permissionScreen).toBeVisible(); - - const expandedOnlineAccordionHeader = permissionScreen - .getByTestId("permission-group-container-onlineConfigs") - .locator("button[aria-expanded='true']"); - await expect(expandedOnlineAccordionHeader).toBeVisible(); - - const accordionItem = expandedOnlineAccordionHeader - .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") - .first(); - - const accordionPanel = accordionItem - .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") - .first(); - - await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") }); - - const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); - await expect(pitrBtn).toBeVisible(); - await pitrBtn.click(); - - page.context().on("page", async (newPage) => { - const expectedUrlEndPattern = new RegExp( - `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`, - ); - expect(newPage.url()).toMatch(expectedUrlEndPattern); - await newPage.close(); - }); - - const loadingOverlay = frame.locator("[data-test='loading-overlay']"); - await expect(loadingOverlay).toBeVisible(); - - const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn"); - await expect(refreshBtn).not.toBeVisible(); - - // Fast forward time by 11 minutes (11 * 60 * 1000ms = 660000ms) - await page.clock.fastForward(11 * 60 * 1000); - - await expect(refreshBtn).toBeVisible(); - await expect(pitrBtn).not.toBeVisible(); - - // Veify Popover & Loading Overlay on permission screen with API mocks and accordion interactions - - await page.route( - `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`, - async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - value: [ - { - principalId: "00-11-22-33", - roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`, - }, - ], - }), - }); - }, - ); - - await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - value: [ - { - name: "00000000-0000-0000-0000-000000000001", - }, - ], - }), - }); - }); - - await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => { - const mockData = { - identity: { - type: "SystemAssigned", - principalId: "00-11-22-33", - }, - properties: { - defaultIdentity: "SystemAssignedIdentity", - backupPolicy: { - type: "Continuous", - }, - capabilities: [{ name: "EnableOnlineContainerCopy" }], - }, - }; - - if (route.request().method() === "PATCH") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ status: "Succeeded" }), - }); - } else if (route.request().method() === "GET") { - // Get the actual response and merge with mock data - const response = await route.fetch(); - const actualData = await response.json(); - const mergedData = { ...actualData }; - set(mergedData, "identity", mockData.identity); - set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); - set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); - set(mergedData, "properties.capabilities", mockData.properties.capabilities); - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(mergedData), - }); - } else { - await route.continue(); - } - }); - - await expect(permissionScreen).toBeVisible(); - - const expandedCrossAccordionHeader = permissionScreen - .getByTestId("permission-group-container-crossAccountConfigs") - .locator("button[aria-expanded='true']"); - await expect(expandedCrossAccordionHeader).toBeVisible(); - - const crossAccordionItem = expandedCrossAccordionHeader - .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") - .first(); - - const crossAccordionPanel = crossAccordionItem - .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") - .first(); - - const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); - await expect(toggleButton).toBeVisible(); - await toggleButton.click(); - - const popover = frame.locator("[data-test='popover-container']"); - await expect(popover).toBeVisible(); - - const yesButton = popover.getByRole("button", { name: /Yes/i }); - const noButton = popover.getByRole("button", { name: /No/i }); - await expect(yesButton).toBeVisible(); - await expect(noButton).toBeVisible(); - - await yesButton.click(); - - await expect(loadingOverlay).toBeVisible(); - - await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 }); - await expect(popover).toBeHidden({ timeout: 10 * 1000 }); - - await panel.getByRole("button", { name: "Cancel" }).click(); - }); - - test.afterAll("Container Copy - After All", async () => { - await page.unroute(/.*/, (route) => route.continue()); - await page.close(); - }); -}); diff --git a/test/sql/containercopy/offlineMigration.spec.ts b/test/sql/containercopy/offlineMigration.spec.ts new file mode 100644 index 000000000..e99610cb4 --- /dev/null +++ b/test/sql/containercopy/offlineMigration.spec.ts @@ -0,0 +1,262 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { truncateName } from "../../../src/Explorer/ContainerCopy/CopyJobUtils"; +import { + ContainerCopy, + getAccountName, + getDropdownItemByNameOrPosition, + interceptAndInspectApiRequest, + TestAccount, + waitForApiResponse, +} from "../../fx"; +import { createMultipleTestContainers } from "../../testData"; + +test.describe("Container Copy - Offline Migration", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let expectedJobName: string; + let targetAccountName: string; + let expectedSubscriptionName: string; + let expectedCopyJobNameInitial: string; + + test.beforeEach("Setup for offline migration test", async ({ browser }) => { + await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 }); + + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + expectedJobName = `offline_test_job_${Date.now()}`; + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after offline migration test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Successfully create and manage offline migration copy job", async () => { + expect(wrapper).not.toBeNull(); + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" }); + + // Open Create Copy Job panel + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + + // Reduced wait time for better performance + await page.waitForTimeout(2000); + + // Setup subscription and account + const subscriptionDropdown = panel.getByTestId("subscription-dropdown"); + const expectedAccountName = targetAccountName; + expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText(); + + await subscriptionDropdown.click(); + const subscriptionItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedSubscriptionName }, + { ariaLabel: "Subscription" }, + ); + await subscriptionItem.click(); + + // Select account + const accountDropdown = panel.getByTestId("account-dropdown"); + await expect(accountDropdown).toHaveText(new RegExp(expectedAccountName)); + await accountDropdown.click(); + + const accountItem = await getDropdownItemByNameOrPosition( + frame, + { name: expectedAccountName }, + { ariaLabel: "Account" }, + ); + await accountItem.click(); + + // Test offline migration mode toggle functionality + const migrationTypeContainer = panel.getByTestId("migration-type"); + + // First test online mode (should show permissions screen) + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + await panel.getByRole("button", { name: "Next" }).click(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).toBeVisible(); + await expect(panel.getByText("Online container copy", { exact: true })).toBeVisible(); + + // Go back and switch to offline mode + await panel.getByRole("button", { name: "Previous" }).click(); + + const offlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Offline mode/i }); + await offlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-offline")).toBeVisible(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify we skip permissions screen in offline mode + await expect(panel.getByTestId("Panel:SelectSourceAndTargetContainers")).toBeVisible(); + await expect(panel.getByTestId("Panel:AssignPermissionsContainer")).not.toBeVisible(); + + // Test source and target container selection with validation + const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); + expect(sourceContainerDropdown).toBeVisible(); + await expect(sourceContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Select source database first (containers are disabled until database is selected) + const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); + await sourceDatabaseDropdown.click(); + const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await sourceDbDropdownItem.click(); + + // Now container dropdown should be enabled + await expect(sourceContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await sourceContainerDropdown.click(); + const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await sourceContainerDropdownItem.click(); + + // Test target container selection + const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); + expect(targetContainerDropdown).toBeVisible(); + await expect(targetContainerDropdown).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); + await targetDatabaseDropdown.click(); + const targetDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await targetDbDropdownItem.click(); + + await expect(targetContainerDropdown).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + await targetContainerDropdown.click(); + + // First try selecting the same container (should show error) + const targetContainerDropdownItem1 = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem1.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify validation error for same source and target containers + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(/Source and destination containers cannot be the same/i); + + // Select different target container + await targetContainerDropdown.click(); + const targetContainerDropdownItem2 = await getDropdownItemByNameOrPosition( + frame, + { position: 1 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem2.click(); + + // Generate expected job name based on selections + const selectedSourceDatabase = await sourceDatabaseDropdown.innerText(); + const selectedSourceContainer = await sourceContainerDropdown.innerText(); + const selectedTargetDatabase = await targetDatabaseDropdown.innerText(); + const selectedTargetContainer = await targetContainerDropdown.innerText(); + expectedCopyJobNameInitial = `${truncateName(selectedSourceDatabase)}.${truncateName( + selectedSourceContainer, + )}_${truncateName(selectedTargetDatabase)}.${truncateName(selectedTargetContainer)}`; + + await panel.getByRole("button", { name: "Next" }).click(); + + // Error should disappear and preview should be visible + await expect(errorContainer).not.toBeVisible(); + await expect(panel.getByTestId("Panel:PreviewCopyJob")).toBeVisible(); + + // Verify job preview details + const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); + await expect(previewContainer).toBeVisible(); + await expect(previewContainer.getByTestId("source-subscription-name")).toHaveText(expectedSubscriptionName); + await expect(previewContainer.getByTestId("source-account-name")).toHaveText(expectedAccountName); + + const jobNameInput = previewContainer.getByTestId("job-name-textfield"); + await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial)); + + const primaryBtn = panel.getByRole("button", { name: "Copy", exact: true }); + await expect(primaryBtn).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Test invalid job name validation (spaces not allowed) + await jobNameInput.fill("test job name"); + await expect(primaryBtn).toHaveClass(/(^|\s)is-disabled(\s|$)/); + + // Test duplicate job name error handling + const duplicateJobName = "test-job-name-1"; + await jobNameInput.fill(duplicateJobName); + + const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); + const expectedErrorMessage = `Duplicate job name '${duplicateJobName}'`; + + await interceptAndInspectApiRequest( + page, + `${expectedAccountName}/dataTransferJobs/${duplicateJobName}`, + "PUT", + new Error(expectedErrorMessage), + (url?: string) => url?.includes(duplicateJobName) ?? false, + ); + + let errorThrown = false; + try { + await copyButton.click(); + await page.waitForTimeout(2000); + } catch (error: any) { + errorThrown = true; + expect(error.message).toContain("not allowed"); + } + + if (!errorThrown) { + const errorContainer = panel.getByTestId("Panel:ErrorContainer"); + await expect(errorContainer).toBeVisible(); + await expect(errorContainer).toHaveText(new RegExp(expectedErrorMessage, "i")); + } + + await expect(panel).toBeVisible(); + + // Test successful job creation with valid job name + const validJobName = expectedJobName; + + const copyJobCreationPromise = waitForApiResponse( + page, + `${expectedAccountName}/dataTransferJobs/${validJobName}`, + "PUT", + ); + + await jobNameInput.fill(validJobName); + await expect(copyButton).not.toHaveClass(/(^|\s)is-disabled(\s|$)/); + + await copyButton.click(); + + const response = await copyJobCreationPromise; + expect(response.ok()).toBe(true); + + // Verify panel closes and job appears in the list + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(validJobName); + + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); + await jobsListContainer.waitFor({ state: "visible" }); + + const jobItem = jobsListContainer.getByText(validJobName); + await jobItem.waitFor({ state: "visible" }); + await expect(jobItem).toBeVisible(); + }); +}); diff --git a/test/sql/containercopy/onlineMigration.spec.ts b/test/sql/containercopy/onlineMigration.spec.ts new file mode 100644 index 000000000..e11b3decd --- /dev/null +++ b/test/sql/containercopy/onlineMigration.spec.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { + ContainerCopy, + getAccountName, + getDropdownItemByNameOrPosition, + TestAccount, + waitForApiResponse, +} from "../../fx"; +import { createMultipleTestContainers } from "../../testData"; + +test.describe("Container Copy - Online Migration", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let targetAccountName: string; + + test.beforeEach("Setup for online migration test", async ({ browser }) => { + await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 }); + + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after online migration test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Successfully create and manage online migration copy job", async () => { + expect(wrapper).not.toBeNull(); + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible" }); + + // Open Create Copy Job panel + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + + // Reduced wait time for better performance + await page.waitForTimeout(1000); + + // Enable online migration mode + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify permissions screen is shown for online migration + const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); + await expect(permissionScreen).toBeVisible(); + await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); + + // Skip permissions setup and proceed to container selection + await panel.getByRole("button", { name: "Next" }).click(); + + // Configure source and target containers for online migration + const sourceDatabaseDropdown = panel.getByTestId("source-databaseDropdown"); + await sourceDatabaseDropdown.click(); + const sourceDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await sourceDbDropdownItem.click(); + + const sourceContainerDropdown = panel.getByTestId("source-containerDropdown"); + await sourceContainerDropdown.click(); + const sourceContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Container" }, + ); + await sourceContainerDropdownItem.click(); + + const targetDatabaseDropdown = panel.getByTestId("target-databaseDropdown"); + await targetDatabaseDropdown.click(); + const targetDbDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 0 }, + { ariaLabel: "Database" }, + ); + await targetDbDropdownItem.click(); + + const targetContainerDropdown = panel.getByTestId("target-containerDropdown"); + await targetContainerDropdown.click(); + const targetContainerDropdownItem = await getDropdownItemByNameOrPosition( + frame, + { position: 1 }, + { ariaLabel: "Container" }, + ); + await targetContainerDropdownItem.click(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify job preview and create the online migration job + const previewContainer = panel.getByTestId("Panel:PreviewCopyJob"); + await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName); + + const jobNameInput = previewContainer.getByTestId("job-name-textfield"); + const onlineMigrationJobName = await jobNameInput.inputValue(); + + const copyButton = panel.getByRole("button", { name: "Copy", exact: true }); + + const copyJobCreationPromise = waitForApiResponse( + page, + `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`, + "PUT", + ); + await copyButton.click(); + await page.waitForTimeout(1000); // Reduced wait time + + const response = await copyJobCreationPromise; + expect(response.ok()).toBe(true); + + // Verify panel closes and job appears in the list + await expect(panel).not.toBeVisible(); + + const filterTextField = wrapper.getByTestId("CopyJobsList/FilterTextField"); + await filterTextField.waitFor({ state: "visible" }); + await filterTextField.fill(onlineMigrationJobName); + + const jobsListContainer = wrapper.locator(".CopyJobListContainer .ms-DetailsList-contentWrapper .ms-List-page"); + await jobsListContainer.waitFor({ state: "visible" }); + + let jobRow, statusCell, actionMenuButton; + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await jobRow.waitFor({ state: "visible" }); + + // Verify job status changes to queued state + await expect(statusCell).toContainText(/running|queued|pending/i); + + // Test job lifecycle management through action menu + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + + // Test pause functionality + const pauseAction = frame.locator(".ms-ContextualMenu-list button:has-text('Pause')"); + await pauseAction.click(); + + const pauseResponse = await waitForApiResponse( + page, + `${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`, + "POST", + ); + expect(pauseResponse.ok()).toBe(true); + + // Verify job status changes to paused + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + await jobRow.waitFor({ state: "visible", timeout: 5000 }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await expect(statusCell).toContainText(/paused/i, { timeout: 5000 }); + await page.waitForTimeout(1000); + + // Test cancel job functionality + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click(); + + // Verify cancellation confirmation dialog + await expect(frame.locator(".ms-Dialog-main")).toBeVisible({ timeout: 2000 }); + await expect(frame.locator(".ms-Dialog-main")).toContainText(onlineMigrationJobName); + + const cancelDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Cancel"); + await expect(cancelDialogButton).toBeVisible(); + await cancelDialogButton.click(); + await expect(frame.locator(".ms-Dialog-main")).not.toBeVisible(); + + actionMenuButton = wrapper.getByTestId(`CopyJobActionMenu/Button:${onlineMigrationJobName}`); + await actionMenuButton.click(); + await frame.locator(".ms-ContextualMenu-list button:has-text('Cancel')").click(); + + const confirmDialogButton = frame.locator(".ms-Dialog-main").getByTestId("DialogButton:Confirm"); + await expect(confirmDialogButton).toBeVisible(); + await confirmDialogButton.click(); + + // Verify final job status is cancelled + jobRow = jobsListContainer.locator(".ms-DetailsRow", { hasText: onlineMigrationJobName }); + statusCell = jobRow.locator("[data-automationid='DetailsRowCell'][data-automation-key='CopyJobStatus']"); + await expect(statusCell).toContainText(/cancelled/i, { timeout: 5000 }); + }); +}); diff --git a/test/sql/containercopy/permissionsScreen.spec.ts b/test/sql/containercopy/permissionsScreen.spec.ts new file mode 100644 index 000000000..f592bf4c7 --- /dev/null +++ b/test/sql/containercopy/permissionsScreen.spec.ts @@ -0,0 +1,270 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { expect, Frame, Locator, Page, test } from "@playwright/test"; +import { set } from "lodash"; +import { ContainerCopy, getAccountName, TestAccount } from "../../fx"; + +const VISIBLE_TIMEOUT_MS = 30 * 1000; + +test.describe("Container Copy - Permission Screen Verification", () => { + let page: Page; + let wrapper: Locator; + let panel: Locator; + let frame: Frame; + let targetAccountName: string; + let expectedSourceAccountName: string; + + test.beforeEach("Setup for each test", async ({ browser }) => { + page = await browser.newPage(); + ({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly)); + targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly); + }); + + test.afterEach("Cleanup after each test", async () => { + await page.unroute(/.*/, (route) => route.continue()); + await page.close(); + }); + + test("Verify online container copy permissions panel functionality", async () => { + expect(wrapper).not.toBeNull(); + + // Verify all command bar buttons are visible + await wrapper.locator(".commandBarContainer").waitFor({ state: "visible", timeout: VISIBLE_TIMEOUT_MS }); + + const createCopyJobButton = wrapper.getByTestId("CommandBar/Button:Create Copy Job"); + await expect(createCopyJobButton).toBeVisible(); + await expect(wrapper.getByTestId("CommandBar/Button:Refresh")).toBeVisible(); + await expect(wrapper.getByTestId("CommandBar/Button:Feedback")).toBeVisible(); + + // Open the Create Copy Job panel + await createCopyJobButton.click(); + panel = frame.getByTestId("Panel:Create copy job"); + await expect(panel).toBeVisible(); + await expect(panel.getByRole("heading", { name: "Create copy job" })).toBeVisible(); + + // Select a different account for cross-account testing + const accountDropdown = panel.getByTestId("account-dropdown"); + await accountDropdown.click(); + + const dropdownItemsWrapper = frame.locator("div.ms-Dropdown-items"); + expect(await dropdownItemsWrapper.getAttribute("aria-label")).toEqual("Account"); + + const allDropdownItems = await dropdownItemsWrapper.locator(`button.ms-Dropdown-item[role='option']`).all(); + + const filteredItems = []; + for (const item of allDropdownItems) { + const testContent = (await item.textContent()) ?? ""; + if (testContent.trim() !== targetAccountName.trim()) { + filteredItems.push(item); + } + } + + if (filteredItems.length > 0) { + const firstDropdownItem = filteredItems[0]; + expectedSourceAccountName = (await firstDropdownItem.textContent()) ?? ""; + await firstDropdownItem.click(); + } else { + throw new Error("No dropdown items available after filtering"); + } + + // Enable online migration mode + const migrationTypeContainer = panel.getByTestId("migration-type"); + const onlineCopyRadioButton = migrationTypeContainer.getByRole("radio", { name: /Online mode/i }); + await onlineCopyRadioButton.click({ force: true }); + await expect(migrationTypeContainer.getByTestId("migration-type-description-online")).toBeVisible(); + + await panel.getByRole("button", { name: "Next" }).click(); + + // Verify Assign Permissions panel for online copy + const permissionScreen = panel.getByTestId("Panel:AssignPermissionsContainer"); + await expect(permissionScreen).toBeVisible(); + await expect(permissionScreen.getByText("Online container copy", { exact: true })).toBeVisible(); + await expect(permissionScreen.getByText("Cross-account container copy", { exact: true })).toBeVisible(); + + // Setup API mocking for the source account + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + if (route.request().method() === "GET") { + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + // Verify Point-in-Time Restore functionality + const expandedOnlineAccordionHeader = permissionScreen + .getByTestId("permission-group-container-onlineConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedOnlineAccordionHeader).toBeVisible(); + + const accordionItem = expandedOnlineAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const accordionPanel = accordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + // Install clock mock and test PITR functionality + await page.clock.install({ time: new Date("2024-01-01T10:00:00Z") }); + + const pitrBtn = accordionPanel.getByTestId("pointInTimeRestore:PrimaryBtn"); + await expect(pitrBtn).toBeVisible(); + await pitrBtn.click({ force: true }); + + // Verify new page opens with correct URL pattern + page.context().on("page", async (newPage) => { + const expectedUrlEndPattern = new RegExp( + `/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${expectedSourceAccountName}/backupRestore`, + ); + expect(newPage.url()).toMatch(expectedUrlEndPattern); + await newPage.close(); + }); + + const loadingOverlay = frame.locator("[data-test='loading-overlay']"); + await expect(loadingOverlay).toBeVisible(); + + const refreshBtn = accordionPanel.getByTestId("pointInTimeRestore:RefreshBtn"); + await expect(refreshBtn).not.toBeVisible(); + + // Fast forward time by 11 minutes + await page.clock.fastForward(11 * 60 * 1000); + + await expect(refreshBtn).toBeVisible({ timeout: 5000 }); + await expect(pitrBtn).not.toBeVisible(); + + // Setup additional API mocks for role assignments and permissions + await page.route( + `**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + principalId: "00-11-22-33", + roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`, + }, + ], + }), + }); + }, + ); + + await page.route("**/Microsoft.DocumentDB/databaseAccounts/*/77-88-99**", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + value: [ + { + name: "00000000-0000-0000-0000-000000000001", + }, + ], + }), + }); + }); + + await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => { + const mockData = { + identity: { + type: "SystemAssigned", + principalId: "00-11-22-33", + }, + properties: { + defaultIdentity: "SystemAssignedIdentity", + backupPolicy: { + type: "Continuous", + }, + capabilities: [{ name: "EnableOnlineContainerCopy" }], + }, + }; + + if (route.request().method() === "PATCH") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ status: "Succeeded" }), + }); + } else if (route.request().method() === "GET") { + const response = await route.fetch(); + const actualData = await response.json(); + const mergedData = { ...actualData }; + set(mergedData, "identity", mockData.identity); + set(mergedData, "properties.defaultIdentity", mockData.properties.defaultIdentity); + set(mergedData, "properties.backupPolicy", mockData.properties.backupPolicy); + set(mergedData, "properties.capabilities", mockData.properties.capabilities); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(mergedData), + }); + } else { + await route.continue(); + } + }); + + // Verify cross-account permissions functionality + const expandedCrossAccordionHeader = permissionScreen + .getByTestId("permission-group-container-crossAccountConfigs") + .locator("button[aria-expanded='true']"); + await expect(expandedCrossAccordionHeader).toBeVisible(); + + const crossAccordionItem = expandedCrossAccordionHeader + .locator("xpath=ancestor::*[contains(@class, 'fui-AccordionItem') or contains(@data-test, 'accordion-item')]") + .first(); + + const crossAccordionPanel = crossAccordionItem + .locator("[role='tabpanel'], .fui-AccordionPanel, [data-test*='panel']") + .first(); + + const toggleButton = crossAccordionPanel.getByTestId("btn-toggle"); + await expect(toggleButton).toBeVisible(); + await toggleButton.click({ force: true }); + + // Verify popover functionality + const popover = frame.locator("[data-test='popover-container']"); + await expect(popover).toBeVisible(); + + const yesButton = popover.getByRole("button", { name: /Yes/i }); + const noButton = popover.getByRole("button", { name: /No/i }); + await expect(yesButton).toBeVisible(); + await expect(noButton).toBeVisible(); + + await yesButton.click({ force: true }); + + // Verify loading states + await expect(loadingOverlay).toBeVisible(); + await expect(loadingOverlay).toBeHidden({ timeout: 10 * 1000 }); + await expect(popover).toBeHidden({ timeout: 10 * 1000 }); + + // Cancel the panel to clean up + await panel.getByRole("button", { name: "Cancel" }).click({ force: true }); + }); +}); diff --git a/test/sql/document.spec.ts b/test/sql/document.spec.ts index 5d17c22c3..a093da376 100644 --- a/test/sql/document.spec.ts +++ b/test/sql/document.spec.ts @@ -136,9 +136,7 @@ test.describe.serial("Upload Item", () => { if (existsSync(uploadDocumentDirPath)) { rmdirSync(uploadDocumentDirPath); } - if (!process.env.CI) { - await context?.dispose(); - } + await context?.dispose(); }); test.afterEach("Close Upload Items panel if still open", async () => { diff --git a/test/sql/indexAdvisor.spec.ts b/test/sql/indexAdvisor.spec.ts index 4d9ac6aa2..dc6ee978c 100644 --- a/test/sql/indexAdvisor.spec.ts +++ b/test/sql/indexAdvisor.spec.ts @@ -10,7 +10,7 @@ let CONTAINER_ID: string; // Set up test database and container with data before all tests test.beforeAll(async () => { - testContainer = await createTestSQLContainer(true); + testContainer = await createTestSQLContainer({ includeTestData: true }); DATABASE_ID = testContainer.database.id; CONTAINER_ID = testContainer.container.id; }); diff --git a/test/sql/query.spec.ts b/test/sql/query.spec.ts index f9dfc80f9..6368c4327 100644 --- a/test/sql/query.spec.ts +++ b/test/sql/query.spec.ts @@ -30,12 +30,9 @@ test.beforeEach("Open new query tab", async ({ page }) => { await explorer.frame.getByTestId("NotificationConsole/Contents").waitFor(); }); -// Delete database only if not running in CI -if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); -} +test.afterAll("Delete Test Database", async () => { + await context?.dispose(); +}); test("Query results", async () => { // Run the query and verify the results diff --git a/test/sql/scaleAndSettings/changePartitionKey.spec.ts b/test/sql/scaleAndSettings/changePartitionKey.spec.ts index 1f23d3154..b92d65ee7 100644 --- a/test/sql/scaleAndSettings/changePartitionKey.spec.ts +++ b/test/sql/scaleAndSettings/changePartitionKey.spec.ts @@ -23,12 +23,9 @@ test.describe("Change Partition Key", () => { await PartitionKeyTab.click(); }); - // Delete database only if not running in CI - if (!process.env.CI) { - test.afterEach("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterEach("Delete Test Database", async () => { + await context?.dispose(); + }); test("Change partition key path", async ({ page }) => { await expect(explorer.frame.getByText("/partitionKey")).toBeVisible(); diff --git a/test/sql/scaleAndSettings/dataMasking.spec.ts b/test/sql/scaleAndSettings/dataMasking.spec.ts new file mode 100644 index 000000000..0c076554f --- /dev/null +++ b/test/sql/scaleAndSettings/dataMasking.spec.ts @@ -0,0 +1,127 @@ +import { expect, test, type Page } from "@playwright/test"; +import { DataExplorer, TestAccount } from "../../fx"; +import { createTestSQLContainer, TestContainerContext } from "../../testData"; + +/** + * Tests for Dynamic Data Masking (DDM) feature. + * + * Prerequisites: + * - Test account must have the EnableDynamicDataMasking capability enabled + * - If the capability is not enabled, the DataMaskingTab will not be visible and tests will be skipped + * + * Important Notes: + * - Tests focus on enabling DDM and modifying the masking policy configuration + */ + +let testContainer: TestContainerContext; +let DATABASE_ID: string; +let CONTAINER_ID: string; + +test.beforeAll(async () => { + testContainer = await createTestSQLContainer(); + DATABASE_ID = testContainer.database.id; + CONTAINER_ID = testContainer.container.id; +}); + +// Clean up test database after all tests +test.afterAll(async () => { + if (testContainer) { + await testContainer.dispose(); + } +}); + +// Helper function to navigate to Data Masking tab +async function navigateToDataMaskingTab(page: Page, explorer: DataExplorer): Promise { + // Refresh the tree to see the newly created database + const refreshButton = explorer.frame.getByTestId("Sidebar/RefreshButton"); + await refreshButton.click(); + await page.waitForTimeout(3000); + + // Expand database and container nodes + const databaseNode = await explorer.waitForNode(DATABASE_ID); + await databaseNode.expand(); + await page.waitForTimeout(2000); + + const containerNode = await explorer.waitForNode(`${DATABASE_ID}/${CONTAINER_ID}`); + await containerNode.expand(); + await page.waitForTimeout(1000); + + // Click Scale & Settings or Settings (depending on container type) + let settingsNode = explorer.frame.getByTestId(`TreeNode:${DATABASE_ID}/${CONTAINER_ID}/Scale & Settings`); + const isScaleAndSettings = await settingsNode.isVisible().catch(() => false); + + if (!isScaleAndSettings) { + settingsNode = explorer.frame.getByTestId(`TreeNode:${DATABASE_ID}/${CONTAINER_ID}/Settings`); + } + + await settingsNode.click(); + await page.waitForTimeout(2000); + + // Check if Data Masking tab is available + const dataMaskingTab = explorer.frame.getByTestId("settings-tab-header/DataMaskingTab"); + const isTabVisible = await dataMaskingTab.isVisible().catch(() => false); + + if (!isTabVisible) { + return false; + } + + await dataMaskingTab.click(); + await page.waitForTimeout(1000); + return true; +} + +test.describe("Data Masking under Scale & Settings", () => { + test("Data Masking tab should be visible and show JSON editor", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + // Verify the Data Masking editor is visible + const dataMaskingEditor = explorer.frame.locator(".settingsV2Editor"); + await expect(dataMaskingEditor).toBeVisible(); + }); + + test("Data Masking editor should contain default policy structure", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + // Verify the editor contains the expected JSON structure fields + const editorContent = explorer.frame.locator(".settingsV2Editor"); + await expect(editorContent).toBeVisible(); + + // Check that the editor contains key policy fields (default policy has empty arrays) + await expect(editorContent).toContainText("includedPaths"); + await expect(editorContent).toContainText("excludedPaths"); + }); + + test("Data Masking editor should have correct default policy values", async ({ page }) => { + const explorer = await DataExplorer.open(page, TestAccount.SQL); + const isTabAvailable = await navigateToDataMaskingTab(page, explorer); + + if (!isTabAvailable) { + test.skip( + true, + "Data Masking tab is not available. Test account may not have EnableDynamicDataMasking capability.", + ); + } + + const editorContent = explorer.frame.locator(".settingsV2Editor"); + await expect(editorContent).toBeVisible(); + + // Default policy should have empty includedPaths and excludedPaths arrays + await expect(editorContent).toContainText("[]"); + }); +}); diff --git a/test/sql/scaleAndSettings/scale.spec.ts b/test/sql/scaleAndSettings/scale.spec.ts index d12db999c..d886b2def 100644 --- a/test/sql/scaleAndSettings/scale.spec.ts +++ b/test/sql/scaleAndSettings/scale.spec.ts @@ -118,7 +118,5 @@ async function openScaleTab(browser: Browser): Promise { } async function cleanup({ context }: Partial) { - if (!process.env.CI) { - await context?.dispose(); - } + await context?.dispose(); } diff --git a/test/sql/scaleAndSettings/settings.spec.ts b/test/sql/scaleAndSettings/settings.spec.ts index 3f14422eb..f60889574 100644 --- a/test/sql/scaleAndSettings/settings.spec.ts +++ b/test/sql/scaleAndSettings/settings.spec.ts @@ -17,12 +17,9 @@ test.describe("Settings under Scale & Settings", () => { await settingsTab.click(); }); - // Delete database only if not running in CI - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Update TTL to On (no default)", async () => { const ttlOnNoDefaultRadioButton = explorer.frame.getByRole("radio", { name: "ttl-on-no-default-option" }); diff --git a/test/sql/scaleAndSettings/sharedThroughput.spec.ts b/test/sql/scaleAndSettings/sharedThroughput.spec.ts new file mode 100644 index 000000000..d1c7d4c90 --- /dev/null +++ b/test/sql/scaleAndSettings/sharedThroughput.spec.ts @@ -0,0 +1,229 @@ +import { Locator, expect, test } from "@playwright/test"; +import { + CommandBarButton, + DataExplorer, + ONE_MINUTE_MS, + TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K, + TEST_MANUAL_THROUGHPUT_RU, + TestAccount, +} from "../../fx"; +import { TestDatabaseContext, createTestDB } from "../../testData"; + +test.describe("Database with Shared Throughput", () => { + let dbContext: TestDatabaseContext = null!; + let explorer: DataExplorer = null!; + const containerId = "sharedcontainer"; + + // Helper methods + const getThroughputInput = (type: "manual" | "autopilot"): Locator => { + return explorer.frame.getByTestId(`${type}-throughput-input`); + }; + + test.afterEach("Delete Test Database", async () => { + await dbContext?.dispose(); + }); + + test.describe("Manual Throughput Tests", () => { + test.beforeEach(async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test("Create database with shared manual throughput and verify Scale node in UI", async () => { + test.setTimeout(120000); // 2 minutes timeout + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Verify database node appears in the tree + const databaseNode = await explorer.waitForNode(dbContext.database.id); + expect(databaseNode).toBeDefined(); + + // Expand the database node to see child nodes + await databaseNode.expand(); + + // Verify that "Scale" node appears under the database + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + expect(scaleNode).toBeDefined(); + await expect(scaleNode.element).toBeVisible(); + }); + + test("Add container to shared database without dedicated throughput", async () => { + // Create database with shared manual throughput + dbContext = await createTestDB({ throughput: 400 }); + + // Wait for the database to appear in the tree + await explorer.waitForNode(dbContext.database.id); + + // Add a container to the shared database via UI + const newContainerButton = await explorer.globalCommandButton("New Container"); + await newContainerButton.click(); + + await explorer.whilePanelOpen( + "New Container", + async (panel, okButton) => { + // Select "Use existing" database + const useExistingRadio = panel.getByRole("radio", { name: /Use existing/i }); + await useExistingRadio.click(); + + // Select the database from dropdown using the new data-testid + const databaseDropdown = panel.getByRole("combobox", { name: "Choose an existing database" }); + await databaseDropdown.click(); + + await explorer.frame.getByRole("option", { name: dbContext.database.id }).click(); + // Now you can target the specific database option by its data-testid + //await panel.getByTestId(`database-option-${dbContext.database.id}`).click(); + // Fill container id + await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId); + + // Fill partition key + await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk"); + + // Ensure "Provision dedicated throughput" is NOT checked + const dedicatedThroughputCheckbox = panel.getByRole("checkbox", { + name: /Provision dedicated throughput for this container/i, + }); + + if (await dedicatedThroughputCheckbox.isVisible()) { + const isChecked = await dedicatedThroughputCheckbox.isChecked(); + if (isChecked) { + await dedicatedThroughputCheckbox.uncheck(); + } + } + + await okButton.click(); + }, + { closeTimeout: 5 * ONE_MINUTE_MS }, + ); + + // Verify container was created under the database + const containerNode = await explorer.waitForContainerNode(dbContext.database.id, containerId); + expect(containerNode).toBeDefined(); + }); + + test("Scale shared database manual throughput", async () => { + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Navigate to the scale settings by clicking the "Scale" node in the tree + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Update manual throughput from 400 to 800 + await getThroughputInput("manual").fill(TEST_MANUAL_THROUGHPUT_RU.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Scale shared database from manual to autoscale", async () => { + // Create database with shared manual throughput (400 RU/s) + dbContext = await createTestDB({ throughput: 400 }); + + // Open database settings by clicking the "Scale" node + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Switch to Autoscale + const autoscaleRadio = explorer.frame.getByText("Autoscale", { exact: true }); + await autoscaleRadio.click(); + + // Set autoscale max throughput to 1000 + //await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_THROUGHPUT_RU.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + }); + + test.describe("Autoscale Throughput Tests", () => { + test.beforeEach(async ({ page }) => { + explorer = await DataExplorer.open(page, TestAccount.SQL); + }); + + test("Create database with shared autoscale throughput and verify Scale node in UI", async () => { + test.setTimeout(120000); // 2 minutes timeout + + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Verify database node appears + const databaseNode = await explorer.waitForNode(dbContext.database.id); + expect(databaseNode).toBeDefined(); + + // Expand the database node to see child nodes + await databaseNode.expand(); + + // Verify that "Scale" node appears under the database + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + expect(scaleNode).toBeDefined(); + await expect(scaleNode.element).toBeVisible(); + }); + + test("Scale shared database autoscale throughput", async () => { + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Open database settings + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Update autoscale max throughput from 1000 to 4000 + await getThroughputInput("autopilot").fill(TEST_AUTOSCALE_MAX_THROUGHPUT_RU_4K.toString()); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { + timeout: 2 * ONE_MINUTE_MS, + }, + ); + }); + + test("Scale shared database from autoscale to manual", async () => { + // Create database with shared autoscale throughput (max 1000 RU/s) + dbContext = await createTestDB({ maxThroughput: 1000 }); + + // Open database settings + const databaseNode = await explorer.waitForNode(dbContext.database.id); + await databaseNode.expand(); + const scaleNode = await explorer.waitForNode(`${dbContext.database.id}/Scale`); + await scaleNode.element.click(); + + // Switch to Manual + const manualRadio = explorer.frame.getByText("Manual", { exact: true }); + await manualRadio.click(); + + // Save changes + await explorer.commandBarButton(CommandBarButton.Save).click(); + + // Verify success message + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated offer for database ${dbContext.database.id}`, + { timeout: 2 * ONE_MINUTE_MS }, + ); + }); + }); +}); diff --git a/test/sql/scripts/storedProcedure.spec.ts b/test/sql/scripts/storedProcedure.spec.ts index 35fb4e0f8..9b53f384d 100644 --- a/test/sql/scripts/storedProcedure.spec.ts +++ b/test/sql/scripts/storedProcedure.spec.ts @@ -43,7 +43,7 @@ test.describe("Stored Procedures", () => { ); // Execute stored procedure - const executeButton = explorer.commandBarButton(CommandBarButton.Execute); + const executeButton = explorer.commandBarButton(CommandBarButton.Execute).first(); await executeButton.click(); const executeSidePanelButton = explorer.frame.getByTestId("Panel/OkButton"); await executeSidePanelButton.click(); diff --git a/test/sql/scripts/trigger.spec.ts b/test/sql/scripts/trigger.spec.ts index 6874c2aac..9792466d5 100644 --- a/test/sql/scripts/trigger.spec.ts +++ b/test/sql/scripts/trigger.spec.ts @@ -26,11 +26,9 @@ test.describe("Triggers", () => { explorer = await DataExplorer.open(page, TestAccount.SQL); }); - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Add and delete trigger", async ({ page }, testInfo) => { // Open container context menu and click New Trigger diff --git a/test/sql/scripts/userDefinedFunction.spec.ts b/test/sql/scripts/userDefinedFunction.spec.ts index 911b1f4ce..c46b19989 100644 --- a/test/sql/scripts/userDefinedFunction.spec.ts +++ b/test/sql/scripts/userDefinedFunction.spec.ts @@ -19,11 +19,9 @@ test.describe("User Defined Functions", () => { explorer = await DataExplorer.open(page, TestAccount.SQL); }); - if (!process.env.CI) { - test.afterAll("Delete Test Database", async () => { - await context?.dispose(); - }); - } + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); test("Add, execute, and delete user defined function", async ({ page }, testInfo) => { // Open container context menu and click New UDF diff --git a/test/testData.ts b/test/testData.ts index 7e5a1f26c..6d892cc60 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -82,6 +82,75 @@ export class TestContainerContext { } } +export class TestDatabaseContext { + constructor( + public armClient: CosmosDBManagementClient, + public client: CosmosClient, + public database: Database, + ) {} + + async dispose() { + await this.database.delete(); + } +} + +export interface CreateTestDBOptions { + throughput?: number; + maxThroughput?: number; // For autoscale +} + +// Helper function to create ARM client and Cosmos client for SQL account +async function createCosmosClientForSQLAccount( + accountType: TestAccount.SQL | TestAccount.SQLContainerCopyOnly = TestAccount.SQL, +): Promise<{ armClient: CosmosDBManagementClient; client: CosmosClient }> { + const credentials = getAzureCLICredentials(); + const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); + const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); + const accountName = getAccountName(accountType); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + + const clientOptions: CosmosClientOptions = { + endpoint: account.documentEndpoint!, + }; + + const rbacToken = + accountType === TestAccount.SQL + ? process.env.NOSQL_TESTACCOUNT_TOKEN + : accountType === TestAccount.SQLContainerCopyOnly + ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN + : ""; + + if (rbacToken) { + clientOptions.tokenProvider = async (): Promise => { + const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; + const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; + return authorizationToken; + }; + } else { + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + clientOptions.key = keys.primaryMasterKey; + } + + const client = new CosmosClient(clientOptions); + + return { armClient, client }; +} + +export async function createTestDB(options?: CreateTestDBOptions): Promise { + const databaseId = generateUniqueName("db"); + const { armClient, client } = await createCosmosClientForSQLAccount(); + + // Create database with provisioned throughput (shared throughput) + // This checks the "Provision database throughput" option + const { database } = await client.databases.create({ + id: databaseId, + throughput: options?.throughput, // Manual throughput (e.g., 400) + maxThroughput: options?.maxThroughput, // Autoscale max throughput (e.g., 1000) + }); + + return new TestDatabaseContext(armClient, client, database); +} + type createTestSqlContainerConfig = { includeTestData?: boolean; partitionKey?: string; @@ -104,34 +173,7 @@ export async function createMultipleTestContainers({ const creationPromises: Promise[] = []; const databaseId = databaseName ? databaseName : generateUniqueName("db"); - const credentials = getAzureCLICredentials(); - const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); - const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); - const accountName = getAccountName(accountType); - const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); - - const clientOptions: CosmosClientOptions = { - endpoint: account.documentEndpoint!, - }; - - const rbacToken = - accountType === TestAccount.SQL - ? process.env.NOSQL_TESTACCOUNT_TOKEN - : accountType === TestAccount.SQLContainerCopyOnly - ? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN - : ""; - if (rbacToken) { - clientOptions.tokenProvider = async (): Promise => { - const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; - const authorizationToken = `${AUTH_PREFIX}${rbacToken}`; - return authorizationToken; - }; - } else { - const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); - clientOptions.key = keys.primaryMasterKey; - } - - const client = new CosmosClient(clientOptions); + const { armClient, client } = await createCosmosClientForSQLAccount(accountType); const { database } = await client.databases.createIfNotExists({ id: databaseId }); try { @@ -158,29 +200,8 @@ export async function createTestSQLContainer({ }: createTestSqlContainerConfig = {}) { const databaseId = databaseName ? databaseName : generateUniqueName("db"); const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique - const credentials = getAzureCLICredentials(); - const adaptedCredentials = new AzureIdentityCredentialAdapter(credentials); - const armClient = new CosmosDBManagementClient(adaptedCredentials, subscriptionId); - const accountName = getAccountName(TestAccount.SQL); - const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + const { armClient, client } = await createCosmosClientForSQLAccount(); - const clientOptions: CosmosClientOptions = { - endpoint: account.documentEndpoint!, - }; - - const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN; - if (nosqlAccountRbacToken) { - clientOptions.tokenProvider = async (): Promise => { - const AUTH_PREFIX = `type=aad&ver=1.0&sig=`; - const authorizationToken = `${AUTH_PREFIX}${nosqlAccountRbacToken}`; - return authorizationToken; - }; - } else { - const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); - clientOptions.key = keys.primaryMasterKey; - } - - const client = new CosmosClient(clientOptions); const { database } = await client.databases.createIfNotExists({ id: databaseId }); try { const { container } = await database.containers.createIfNotExists({