Compare commits

..

3 Commits

Author SHA1 Message Date
Bikram Choudhury
129c25c544 fix copyjob playwright tests 2026-03-27 19:18:41 +05:30
Bikram Choudhury
1eba0c4ea2 upgrade RBAC permissions from read only to read-write 2026-03-26 12:59:15 +05:30
Bikram Choudhury
15ad4242d5 feat: Redesign container-copy flow to select destination account and enable cross-account container creation 2026-03-26 11:34:28 +05:30
50 changed files with 1009 additions and 416 deletions

View File

@@ -34,6 +34,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
databaseId: params.databaseId,
databaseLevelThroughput: params.databaseLevelThroughput,
offerThroughput: params.offerThroughput,
targetAccountOverride: params.targetAccountOverride,
};
await createDatabase(createDatabaseParams);
}
@@ -63,7 +64,7 @@ export const createCollection = async (params: DataModels.CreateCollectionParams
};
const createCollectionWithARM = async (params: DataModels.CreateCollectionParams): Promise<DataModels.Collection> => {
if (!params.createNewDatabase) {
if (!params.createNewDatabase && !params.targetAccountOverride) {
const isValid = await useDatabases.getState().validateCollectionId(params.databaseId, params.collectionId);
if (!isValid) {
const collectionName = getCollectionName().toLocaleLowerCase();
@@ -122,9 +123,9 @@ const createSqlContainer = async (params: DataModels.CreateCollectionParams): Pr
};
const createResponse = await createUpdateSqlContainer(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.targetAccountOverride?.subscriptionId ?? userContext.subscriptionId,
params.targetAccountOverride?.resourceGroup ?? userContext.resourceGroup,
params.targetAccountOverride?.accountName ?? userContext.databaseAccount.name,
params.databaseId,
params.collectionId,
rpPayload,

View File

@@ -0,0 +1,134 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
jest.mock("../../Utils/arm/generatedClients/cosmos/sqlResources");
import ko from "knockout";
import { AuthType } from "../../AuthType";
import { CreateDatabaseParams, DatabaseAccount } from "../../Contracts/DataModels";
import * as ViewModels from "../../Contracts/ViewModels";
import { useDatabases } from "../../Explorer/useDatabases";
import { updateUserContext } from "../../UserContext";
import { createUpdateSqlDatabase } from "../../Utils/arm/generatedClients/cosmos/sqlResources";
import { SqlDatabaseGetResults } from "../../Utils/arm/generatedClients/cosmos/types";
import { createDatabase } from "./createDatabase";
const mockCreateUpdateSqlDatabase = createUpdateSqlDatabase as jest.MockedFunction<typeof createUpdateSqlDatabase>;
describe("createDatabase", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: { name: "default-account" } as DatabaseAccount,
subscriptionId: "default-subscription",
resourceGroup: "default-rg",
apiType: "SQL",
authType: AuthType.AAD,
});
});
beforeEach(() => {
jest.clearAllMocks();
mockCreateUpdateSqlDatabase.mockResolvedValue({
properties: { resource: { id: "db", _rid: "", _self: "", _ts: 0, _etag: "" } },
} as SqlDatabaseGetResults);
useDatabases.setState({
databases: [],
validateDatabaseId: () => true,
} as unknown as ReturnType<typeof useDatabases.getState>);
});
it("should call ARM createUpdateSqlDatabase when logged in with AAD", async () => {
await createDatabase({ databaseId: "testDb" });
expect(mockCreateUpdateSqlDatabase).toHaveBeenCalled();
});
describe("targetAccountOverride behavior", () => {
it("should use targetAccountOverride subscriptionId, resourceGroup, and accountName for SQL DB creation", async () => {
const params: CreateDatabaseParams = {
databaseId: "testDb",
targetAccountOverride: {
subscriptionId: "override-sub",
resourceGroup: "override-rg",
accountName: "override-account",
},
};
await createDatabase(params);
expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith(
"override-sub",
"override-rg",
"override-account",
"testDb",
expect.any(Object),
);
});
it("should use userContext values when targetAccountOverride is not provided", async () => {
await createDatabase({ databaseId: "testDb" });
expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith(
"default-subscription",
"default-rg",
"default-account",
"testDb",
expect.any(Object),
);
});
it("should skip validateDatabaseId check when targetAccountOverride is provided", async () => {
// Simulate database already existing — validateDatabaseId returns false
useDatabases.setState({
databases: [{ id: ko.observable("testDb") } as unknown as ViewModels.Database],
validateDatabaseId: () => false,
} as unknown as ReturnType<typeof useDatabases.getState>);
const params: CreateDatabaseParams = {
databaseId: "testDb",
targetAccountOverride: {
subscriptionId: "override-sub",
resourceGroup: "override-rg",
accountName: "override-account",
},
};
// Should NOT throw even though the normal duplicate check would fail
await expect(createDatabase(params)).resolves.not.toThrow();
expect(mockCreateUpdateSqlDatabase).toHaveBeenCalled();
});
it("should throw if validateDatabaseId returns false and no targetAccountOverride is set", async () => {
useDatabases.setState({
databases: [{ id: ko.observable("existingDb") } as unknown as ViewModels.Database],
validateDatabaseId: () => false,
} as unknown as ReturnType<typeof useDatabases.getState>);
await expect(createDatabase({ databaseId: "existingDb" })).rejects.toThrow();
expect(mockCreateUpdateSqlDatabase).not.toHaveBeenCalled();
});
it("should pass databaseId in request payload regardless of targetAccountOverride", async () => {
const params: CreateDatabaseParams = {
databaseId: "my-database",
targetAccountOverride: {
subscriptionId: "any-sub",
resourceGroup: "any-rg",
accountName: "any-account",
},
};
await createDatabase(params);
expect(mockCreateUpdateSqlDatabase).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
expect.any(String),
"my-database",
expect.objectContaining({
properties: expect.objectContaining({
resource: expect.objectContaining({ id: "my-database" }),
}),
}),
);
});
});
});

View File

@@ -41,7 +41,7 @@ export async function createDatabase(params: DataModels.CreateDatabaseParams): P
}
async function createDatabaseWithARM(params: DataModels.CreateDatabaseParams): Promise<DataModels.Database> {
if (!useDatabases.getState().validateDatabaseId(params.databaseId)) {
if (!params.targetAccountOverride && !useDatabases.getState().validateDatabaseId(params.databaseId)) {
const databaseName = getDatabaseName().toLocaleLowerCase();
throw new Error(`Create ${databaseName} failed: ${databaseName} with id ${params.databaseId} already exists`);
}
@@ -72,13 +72,10 @@ async function createSqlDatabase(params: DataModels.CreateDatabaseParams): Promi
options,
},
};
const createResponse = await createUpdateSqlDatabase(
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
params.databaseId,
rpPayload,
);
const sub = params.targetAccountOverride?.subscriptionId ?? userContext.subscriptionId;
const rg = params.targetAccountOverride?.resourceGroup ?? userContext.resourceGroup;
const acct = params.targetAccountOverride?.accountName ?? userContext.databaseAccount.name;
const createResponse = await createUpdateSqlDatabase(sub, rg, acct, params.databaseId, rpPayload);
return createResponse && (createResponse.properties.resource as DataModels.Database);
}

View File

@@ -1,11 +1,12 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { updateUserContext } from "../../UserContext";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { readDatabases } from "./readDatabases";
import { readDatabases, readDatabasesForAccount } from "./readDatabases";
describe("readDatabases", () => {
beforeAll(() => {
@@ -42,3 +43,64 @@ describe("readDatabases", () => {
expect(client).toHaveBeenCalled();
});
});
describe("readDatabasesForAccount", () => {
const mockDatabase = { id: "testDb", _rid: "", _self: "", _etag: "", _ts: 0 };
const mockArmResponse = { value: [{ properties: { resource: mockDatabase } }] };
beforeEach(() => {
jest.clearAllMocks();
});
it("should call ARM with a path that includes the provided subscriptionId, resourceGroup, and accountName", async () => {
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesForAccount("test-sub", "test-rg", "test-account");
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringContaining("/subscriptions/test-sub/resourceGroups/test-rg/"),
}),
);
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({
path: expect.stringContaining("/databaseAccounts/test-account/sqlDatabases"),
}),
);
});
it("should return mapped database resources from the response", async () => {
const db1 = { id: "db1", _rid: "r1", _self: "/dbs/db1", _etag: "", _ts: 1 };
const db2 = { id: "db2", _rid: "r2", _self: "/dbs/db2", _etag: "", _ts: 2 };
(armRequest as jest.Mock).mockResolvedValue({
value: [{ properties: { resource: db1 } }, { properties: { resource: db2 } }],
});
const result = await readDatabasesForAccount("sub", "rg", "account");
expect(result).toEqual([db1, db2]);
});
it("should return an empty array when the response is null", async () => {
(armRequest as jest.Mock).mockResolvedValue(null);
const result = await readDatabasesForAccount("sub", "rg", "account");
expect(result).toEqual([]);
});
it("should return an empty array when value is an empty list", async () => {
(armRequest as jest.Mock).mockResolvedValue({ value: [] });
const result = await readDatabasesForAccount("sub", "rg", "account");
expect(result).toEqual([]);
});
it("should throw and propagate errors from the ARM call", async () => {
(armRequest as jest.Mock).mockRejectedValue(new Error("ARM request failed"));
await expect(readDatabasesForAccount("sub", "rg", "account")).rejects.toThrow("ARM request failed");
});
});

View File

@@ -112,3 +112,20 @@ async function readDatabasesWithARM(): Promise<DataModels.Database[]> {
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database);
}
export async function readDatabasesForAccount(
subscriptionId: string,
resourceGroup: string,
accountName: string,
): Promise<DataModels.Database[]> {
const clearMessage = logConsoleProgress(`Querying databases for account ${accountName}`);
try {
const rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName);
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database) ?? [];
} catch (error) {
handleError(error, "ReadDatabasesForAccount", `Error while querying databases for account ${accountName}`);
throw error;
} finally {
clearMessage();
}
}

View File

@@ -404,11 +404,18 @@ export interface AutoPilotOfferSettings {
targetMaxThroughput?: number;
}
export interface AccountOverride {
subscriptionId: string;
resourceGroup: string;
accountName: string;
}
export interface CreateDatabaseParams {
autoPilotMaxThroughput?: number;
databaseId: string;
databaseLevelThroughput?: boolean;
offerThroughput?: number;
targetAccountOverride?: AccountOverride;
}
export interface CreateCollectionParamsBase {
@@ -428,6 +435,7 @@ export interface CreateCollectionParamsBase {
export interface CreateCollectionParams extends CreateCollectionParamsBase {
createNewDatabase: boolean;
collectionId: string;
targetAccountOverride?: AccountOverride;
}
export interface CreateMaterializedViewsParams extends CreateCollectionParamsBase {

View File

@@ -457,13 +457,13 @@ describe("CopyJobActions", () => {
jobName: "test-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
subscriptionId: "sub-123",
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
subscription: {} as any,
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
@@ -498,7 +498,7 @@ describe("CopyJobActions", () => {
);
const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4];
expect(callArgs.properties.source.remoteAccountName).toBeUndefined();
expect(callArgs.properties.destination.remoteAccountName).toBeUndefined();
expect(mockRefreshJobList).toHaveBeenCalled();
expect(mockOnSuccess).toHaveBeenCalled();
@@ -509,13 +509,13 @@ describe("CopyJobActions", () => {
jobName: "cross-account-job",
migrationType: "offline" as any,
source: {
subscription: {} as any,
subscriptionId: "sub-123",
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-456",
subscription: {} as any,
account: { id: "account-2", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
@@ -528,7 +528,7 @@ describe("CopyJobActions", () => {
await submitCreateCopyJob(mockState, mockOnSuccess);
const callArgs = (dataTransferService.create as jest.Mock).mock.calls[0][4];
expect(callArgs.properties.source.remoteAccountName).toBe("source-account");
expect(callArgs.properties.destination.remoteAccountName).toBe("target-account");
expect(mockOnSuccess).toHaveBeenCalled();
});
@@ -537,13 +537,13 @@ describe("CopyJobActions", () => {
jobName: "failing-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
subscriptionId: "sub-123",
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
subscription: {} as any,
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",
@@ -566,13 +566,13 @@ describe("CopyJobActions", () => {
jobName: "test-job",
migrationType: "online" as any,
source: {
subscription: {} as any,
subscriptionId: "sub-123",
account: { id: "account-1", name: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "sub-123",
subscription: {} as any,
account: { id: "account-1", name: "target-account" } as any,
databaseId: "target-db",
containerId: "target-container",

View File

@@ -137,12 +137,12 @@ export const submitCreateCopyJob = async (state: CopyJobContextState, onSuccess:
properties: {
source: {
component: "CosmosDBSql",
...(isSameAccount ? {} : { remoteAccountName: source?.account?.name }),
databaseName: source?.databaseId,
containerName: source?.containerId,
},
destination: {
component: "CosmosDBSql",
...(isSameAccount ? {} : { remoteAccountName: target?.account?.name }),
databaseName: target?.databaseId,
containerName: target?.containerId,
},

View File

@@ -20,11 +20,11 @@ export default {
createCopyJobPanelTitle: "Create copy job",
// Select Account Screen
selectAccountDescription: "Please select a source account from which to copy.",
selectAccountDescription: "Please select a destination account to copy to.",
subscriptionDropdownLabel: "Subscription",
subscriptionDropdownPlaceholder: "Select a subscription",
sourceAccountDropdownLabel: "Account",
sourceAccountDropdownPlaceholder: "Select an account",
destinationAccountDropdownLabel: "Account",
destinationAccountDropdownPlaceholder: "Select an account",
migrationTypeOptions: {
offline: {
title: "Offline mode",
@@ -47,14 +47,17 @@ export default {
databaseDropdownPlaceholder: "Select a database",
containerDropdownLabel: "Container",
containerDropdownPlaceholder: "Select a container",
createNewContainerSubHeading: "Select the properties for your container.",
createNewContainerSubHeading: (accountName?: string) =>
accountName
? `Configure the properties for the new container on destination account "${accountName}".`
: "Configure the properties for the new container.",
createContainerButtonLabel: "Create a new container",
createContainerHeading: "Create new container",
// Preview and Create Screen
jobNameLabel: "Job name",
sourceSubscriptionLabel: "Source subscription",
sourceAccountLabel: "Source account",
destinationSubscriptionLabel: "Destination subscription",
destinationAccountLabel: "Destination account",
sourceDatabaseLabel: "Source database",
sourceContainerLabel: "Source container",
targetDatabaseLabel: "Destination database",
@@ -63,7 +66,7 @@ export default {
// Assign Permissions Screen
assignPermissions: {
crossAccountDescription:
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.",
"To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.",
intraAccountOnlineDescription: (accountName: string) =>
`Follow the steps below to enable online copy on your "${accountName}" account.`,
crossAccountConfiguration: {
@@ -116,18 +119,18 @@ export default {
popoverDescription: (accountName: string) =>
`Assign the system-assigned managed identity as the default for "${accountName}". To confirm, click the "Yes" button. `,
},
readPermissionAssigned: {
title: "Read permissions assigned to the default identity.",
readWritePermissionAssigned: {
title: "Read-write permissions assigned to the default identity.",
description:
"To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.",
"To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.",
tooltip: {
content: "Learn more about",
hrefText: "Read permissions.",
hrefText: "Read-write permissions.",
href: "https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-connect-role-based-access-control",
},
popoverTitle: "Read permissions assigned to default identity.",
popoverTitle: "Assign read-write permissions to default identity.",
popoverDescription:
"Assign read permissions of the source account to the default identity of the destination account. To confirm click the Yes button.",
'Assign read-write permissions on the source account to the default identity of the destination account. To confirm, click the "Yes" button.',
},
pointInTimeRestore: {
title: "Point In Time Restore enabled",

View File

@@ -59,12 +59,6 @@ describe("CopyJobContext", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null,
account: null,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "test-subscription-id",
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
@@ -75,7 +69,13 @@ describe("CopyJobContext", () => {
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
target: {
subscription: null,
account: null,
databaseId: "",
containerId: "",
},
sourceReadWriteAccessFromTarget: false,
});
expect(contextValue.flow).toBeNull();
expect(contextValue.contextError).toBeNull();
@@ -598,8 +598,8 @@ describe("CopyJobContext", () => {
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.source?.subscription?.subscriptionId).toBeUndefined();
expect(contextValue.copyJobState.source?.account?.name).toBeUndefined();
expect(contextValue.copyJobState.source?.subscriptionId).toBe("test-subscription-id");
expect(contextValue.copyJobState.source?.account?.name).toBe("test-account");
});
it("should initialize target with userContext values", () => {
@@ -616,11 +616,11 @@ describe("CopyJobContext", () => {
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.target.subscriptionId).toBe("test-subscription-id");
expect(contextValue.copyJobState.target.account.name).toBe("test-account");
expect(contextValue.copyJobState.target.subscription).toBeNull();
expect(contextValue.copyJobState.target.account).toBeNull();
});
it("should initialize sourceReadAccessFromTarget as false", () => {
it("should initialize sourceReadWriteAccessFromTarget as false", () => {
let contextValue: any;
render(
@@ -634,7 +634,7 @@ describe("CopyJobContext", () => {
</CopyJobContextProvider>,
);
expect(contextValue.copyJobState.sourceReadAccessFromTarget).toBe(false);
expect(contextValue.copyJobState.sourceReadWriteAccessFromTarget).toBe(false);
});
it("should initialize with empty database and container ids", () => {

View File

@@ -23,18 +23,18 @@ const getInitialCopyJobState = (): CopyJobContextState => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null,
account: null,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: userContext.subscriptionId || "",
account: userContext.databaseAccount || null,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
target: {
subscription: null,
account: null,
databaseId: "",
containerId: "",
},
sourceReadWriteAccessFromTarget: false,
};
};

View File

@@ -67,7 +67,7 @@ describe("AddManagedIdentity", () => {
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
};
const mockContextValue = {

View File

@@ -4,7 +4,7 @@ import React from "react";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { CopyJobContext } from "../../../Context/CopyJobContext";
import { CopyJobContextProviderType } from "../../../Types/CopyJobTypes";
import AddReadPermissionToDefaultIdentity from "./AddReadPermissionToDefaultIdentity";
import AddReadWritePermissionToDefaultIdentity from "./AddReadWritePermissionToDefaultIdentity";
jest.mock("../../../../../Common/Logger", () => ({
logError: jest.fn(),
@@ -73,7 +73,7 @@ import { assignRole, RoleAssignmentType } from "../../../../../Utils/arm/RbacUti
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
import useToggle from "./hooks/useToggle";
describe("AddReadPermissionToDefaultIdentity Component", () => {
describe("AddReadWritePermissionToDefaultIdentity Component", () => {
const mockUseToggle = useToggle as jest.MockedFunction<typeof useToggle>;
const mockAssignRole = assignRole as jest.MockedFunction<typeof assignRole>;
const mockGetAccountDetailsFromResourceId = getAccountDetailsFromResourceId as jest.MockedFunction<
@@ -86,7 +86,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "source-sub-id" } as Subscription,
subscriptionId: "source-sub-id",
account: {
id: "/subscriptions/source-sub-id/resourceGroups/source-rg/providers/Microsoft.DocumentDB/databaseAccounts/source-account",
name: "source-account",
@@ -101,7 +101,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
containerId: "source-container",
},
target: {
subscriptionId: "target-sub-id",
subscription: { subscriptionId: "target-sub-id" } as Subscription,
account: {
id: "/subscriptions/target-sub-id/resourceGroups/target-rg/providers/Microsoft.DocumentDB/databaseAccounts/target-account",
name: "target-account",
@@ -119,7 +119,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
},
setCopyJobState: jest.fn(),
setContextError: jest.fn(),
@@ -133,7 +133,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
const renderComponent = (contextValue = mockContextValue) => {
return render(
<CopyJobContext.Provider value={contextValue}>
<AddReadPermissionToDefaultIdentity />
<AddReadWritePermissionToDefaultIdentity />
</CopyJobContext.Provider>,
);
};
@@ -164,12 +164,12 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
expect(container).toMatchSnapshot();
});
it("should render correctly when sourceReadAccessFromTarget is true", () => {
it("should render correctly when sourceReadWriteAccessFromTarget is true", () => {
const contextWithAccess = {
...mockContextValue,
copyJobState: {
...mockContextValue.copyJobState,
sourceReadAccessFromTarget: true,
sourceReadWriteAccessFromTarget: true,
},
};
const { container } = renderComponent(contextWithAccess);
@@ -180,7 +180,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
describe("Component Structure", () => {
it("should display the description text", () => {
renderComponent();
expect(screen.getByText(ContainerCopyMessages.readPermissionAssigned.description)).toBeInTheDocument();
expect(screen.getByText(ContainerCopyMessages.readWritePermissionAssigned.description)).toBeInTheDocument();
});
it("should display the info tooltip", () => {
@@ -212,10 +212,10 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
expect(screen.getByTestId("popover-message")).toBeInTheDocument();
expect(screen.getByTestId("popover-title")).toHaveTextContent(
ContainerCopyMessages.readPermissionAssigned.popoverTitle,
ContainerCopyMessages.readWritePermissionAssigned.popoverTitle,
);
expect(screen.getByTestId("popover-content")).toHaveTextContent(
ContainerCopyMessages.readPermissionAssigned.popoverDescription,
ContainerCopyMessages.readWritePermissionAssigned.popoverDescription,
);
});
@@ -243,7 +243,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
expect(mockOnToggle).toHaveBeenCalledWith(null, false);
});
it("should call handleAddReadPermission when primary button is clicked", async () => {
it("should call handleAddReadWritePermission when primary button is clicked", async () => {
mockGetAccountDetailsFromResourceId.mockReturnValue({
subscriptionId: "source-sub-id",
resourceGroup: "source-rg",
@@ -264,7 +264,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
});
});
describe("handleAddReadPermission Function", () => {
describe("handleAddReadWritePermission Function", () => {
beforeEach(() => {
mockUseToggle.mockReturnValue([true, jest.fn()]);
});
@@ -312,7 +312,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
await waitFor(() => {
expect(mockLogError).toHaveBeenCalledWith(
"Permission denied",
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
"CopyJob/AddReadWritePermissionToDefaultIdentity.handleAddReadWritePermission",
);
});
@@ -336,14 +336,14 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
await waitFor(() => {
expect(mockLogError).toHaveBeenCalledWith(
"Error assigning read permission to default identity. Please try again later.",
"CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission",
"Error assigning read-write permission to default identity. Please try again later.",
"CopyJob/AddReadWritePermissionToDefaultIdentity.handleAddReadWritePermission",
);
});
await waitFor(() => {
expect(mockContextValue.setContextError).toHaveBeenCalledWith(
"Error assigning read permission to default identity. Please try again later.",
"Error assigning read-write permission to default identity. Please try again later.",
);
});
});
@@ -496,7 +496,7 @@ describe("AddReadPermissionToDefaultIdentity Component", () => {
expect(updatedState).toEqual({
...mockContextValue.copyJobState,
sourceReadAccessFromTarget: true,
sourceReadWriteAccessFromTarget: true,
});
});
});

View File

@@ -12,27 +12,29 @@ import useToggle from "./hooks/useToggle";
const TooltipContent = (
<Text>
{ContainerCopyMessages.readPermissionAssigned.tooltip.content} &nbsp;
{ContainerCopyMessages.readWritePermissionAssigned.tooltip.content} &nbsp;
<Link
style={{ color: "var(--colorBrandForeground1)" }}
href={ContainerCopyMessages.readPermissionAssigned.tooltip.href}
href={ContainerCopyMessages.readWritePermissionAssigned.tooltip.href}
target="_blank"
rel="noopener noreferrer"
>
{ContainerCopyMessages.readPermissionAssigned.tooltip.hrefText}
{ContainerCopyMessages.readWritePermissionAssigned.tooltip.hrefText}
</Link>
</Text>
);
type AddReadPermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIdentityProps> = () => {
type AddReadWritePermissionToDefaultIdentityProps = Partial<PermissionSectionConfig>;
const AddReadWritePermissionToDefaultIdentity: React.FC<AddReadWritePermissionToDefaultIdentityProps> = () => {
const [loading, setLoading] = React.useState(false);
const { copyJobState, setCopyJobState, setContextError } = useCopyJobContext();
const [readPermissionAssigned, onToggle] = useToggle(false);
const [readWritePermissionAssigned, onToggle] = useToggle(copyJobState.sourceReadWriteAccessFromTarget ?? false);
const handleAddReadPermission = async () => {
const handleAddReadWritePermission = async () => {
const { source, target } = copyJobState;
const selectedSourceAccount = source?.account;
try {
const {
subscriptionId: sourceSubscriptionId,
@@ -47,16 +49,17 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
sourceAccountName,
target?.account?.identity?.principalId ?? "",
);
if (assignedRole) {
setCopyJobState((prevState) => ({
...prevState,
sourceReadAccessFromTarget: true,
sourceReadWriteAccessFromTarget: true,
}));
}
} catch (error) {
const errorMessage =
error.message || "Error assigning read permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadPermissionToDefaultIdentity.handleAddReadPermission");
error.message || "Error assigning read-write permission to default identity. Please try again later.";
logError(errorMessage, "CopyJob/AddReadWritePermissionToDefaultIdentity.handleAddReadWritePermission");
setContextError(errorMessage);
} finally {
setLoading(false);
@@ -66,12 +69,12 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
return (
<Stack className="defaultManagedIdentityContainer" tokens={{ childrenGap: 15, padding: "0 0 0 20px" }}>
<Text className="toggle-label">
{ContainerCopyMessages.readPermissionAssigned.description}&ensp;
{ContainerCopyMessages.readWritePermissionAssigned.description}&ensp;
<InfoTooltip content={TooltipContent} />
</Text>
<Toggle
data-test="btn-toggle"
checked={readPermissionAssigned}
checked={readWritePermissionAssigned}
onText={ContainerCopyMessages.toggleBtn.onText}
offText={ContainerCopyMessages.toggleBtn.offText}
onChange={onToggle}
@@ -83,15 +86,15 @@ const AddReadPermissionToDefaultIdentity: React.FC<AddReadPermissionToDefaultIde
/>
<PopoverMessage
isLoading={loading}
visible={readPermissionAssigned}
title={ContainerCopyMessages.readPermissionAssigned.popoverTitle}
visible={readWritePermissionAssigned}
title={ContainerCopyMessages.readWritePermissionAssigned.popoverTitle}
onCancel={() => onToggle(null, false)}
onPrimary={handleAddReadPermission}
onPrimary={handleAddReadWritePermission}
>
{ContainerCopyMessages.readPermissionAssigned.popoverDescription}
{ContainerCopyMessages.readWritePermissionAssigned.popoverDescription}
</PopoverMessage>
</Stack>
);
};
export default AddReadPermissionToDefaultIdentity;
export default AddReadWritePermissionToDefaultIdentity;

View File

@@ -43,12 +43,12 @@ jest.mock("./AddManagedIdentity", () => {
return MockAddManagedIdentity;
});
jest.mock("./AddReadPermissionToDefaultIdentity", () => {
const MockAddReadPermissionToDefaultIdentity = () => {
return <div data-testid="add-read-permission">Add Read Permission Component</div>;
jest.mock("./AddReadWritePermissionToDefaultIdentity", () => {
const MockAddReadWritePermissionToDefaultIdentity = () => {
return <div data-testid="add-read-write-permission">Add Read-Write Permission Component</div>;
};
MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
return MockAddReadPermissionToDefaultIdentity;
MockAddReadWritePermissionToDefaultIdentity.displayName = "MockAddReadWritePermissionToDefaultIdentity";
return MockAddReadWritePermissionToDefaultIdentity;
});
jest.mock("./DefaultManagedIdentity", () => {
@@ -85,18 +85,18 @@ describe("AssignPermissions Component", () => {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "source-sub" } as any,
subscriptionId: "source-sub",
account: { id: "source-account", name: "Source Account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-sub",
subscription: { subscriptionId: "target-sub" } as any,
account: { id: "target-account", name: "Target Account" } as any,
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
...overrides,
});
@@ -164,13 +164,13 @@ describe("AssignPermissions Component", () => {
const copyJobState = createMockCopyJobState({
migrationType: CopyJobMigrationType.Online,
source: {
subscription: { subscriptionId: "same-sub" } as any,
subscriptionId: "same-sub",
account: { id: "same-account", name: "Same Account" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "same-sub",
subscription: { subscriptionId: "same-sub" } as any,
account: { id: "same-account", name: "Same Account" } as any,
databaseId: "target-db",
containerId: "target-container",
@@ -201,7 +201,7 @@ describe("AssignPermissions Component", () => {
completed: true,
},
{
id: "readPermissionAssigned",
id: "readWritePermissionAssigned",
title: "Read Permission Assigned",
Component: () => <div data-testid="add-read-permission">Add Read Permission Component</div>,
disabled: false,
@@ -347,7 +347,7 @@ describe("AssignPermissions Component", () => {
it("should handle missing account names", () => {
const copyJobState = createMockCopyJobState({
source: {
subscription: { subscriptionId: "source-sub" } as any,
subscriptionId: "source-sub",
account: { id: "source-account" } as any,
databaseId: "source-db",
containerId: "source-container",

View File

@@ -50,18 +50,18 @@ describe("PointInTimeRestore", () => {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" },
subscriptionId: "test-sub",
account: mockSourceAccount,
databaseId: "test-db",
containerId: "test-container",
},
target: {
subscriptionId: "test-sub",
subscription: { subscriptionId: "test-sub", displayName: "Test Subscription" },
account: mockSourceAccount,
databaseId: "target-db",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
} as CopyJobContextState;
const mockSetCopyJobState = jest.fn();

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Edge Cases should handle missing source account 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -8,7 +8,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -24,7 +24,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>
@@ -63,7 +63,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Edge Cases should handle missing target account identity 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -71,7 +71,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -87,7 +87,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>
@@ -126,7 +126,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Edge Cases should handle m
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when sourceReadAccessFromTarget is true 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Rendering should render correctly when sourceReadWriteAccessFromTarget is true 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -134,7 +134,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -150,7 +150,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>
@@ -189,7 +189,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Rendering should render correctly when toggle is on 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -197,7 +197,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -213,7 +213,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>
@@ -255,12 +255,12 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
<div
data-testid="popover-title"
>
Read permissions assigned to default identity.
Assign read-write permissions to default identity.
</div>
<div
data-testid="popover-content"
>
Assign read permissions of the source account to the default identity of the destination account. To confirm click the Yes button.
Assign read-write permissions on the source account to the default identity of the destination account. To confirm, click the "Yes" button.
</div>
<button
data-testid="popover-cancel"
@@ -277,7 +277,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Rendering should render correctly with default state 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -285,7 +285,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -301,7 +301,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>
@@ -340,7 +340,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
</div>
`;
exports[`AddReadPermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = `
exports[`AddReadWritePermissionToDefaultIdentity Component Rendering should render correctly with different context states 1`] = `
<div>
<div
class="ms-Stack defaultManagedIdentityContainer css-109"
@@ -348,7 +348,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
<span
class="toggle-label css-110"
>
To allow data copy from source to the destination container, provide read access of the source account to the default identity of the destination account.
To allow data copy from source to the destination container, provide read-write access on the source account to the default identity of the destination account.
<div
data-testid="info-tooltip"
@@ -364,7 +364,7 @@ exports[`AddReadPermissionToDefaultIdentity Component Rendering should render co
rel="noopener noreferrer"
target="_blank"
>
Read permissions.
Read-write permissions.
</a>
</span>
</div>

View File

@@ -9,7 +9,7 @@ exports[`AssignPermissions Component Accordion Behavior should render accordion
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
class="ms-Stack css-111"
@@ -212,7 +212,7 @@ exports[`AssignPermissions Component Edge Cases should calculate correct indent
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
class="ms-Stack css-111"
@@ -618,7 +618,7 @@ exports[`AssignPermissions Component Edge Cases should handle missing account na
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
class="ms-Stack css-111"
@@ -1153,7 +1153,7 @@ exports[`AssignPermissions Component Permission Groups should render permission
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
class="ms-Stack css-111"
@@ -1307,7 +1307,7 @@ exports[`AssignPermissions Component Rendering should render without crashing wi
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
data-testid="shimmer-tree"
@@ -1329,7 +1329,7 @@ exports[`AssignPermissions Component Rendering should render without crashing wi
<span
class="css-110"
>
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read access to the source account by completing the following steps.
To copy data from the source to the destination container, ensure that the managed identity of the destination account has read-write access to the source account by completing the following steps.
</span>
<div
data-testid="shimmer-tree"

View File

@@ -13,7 +13,7 @@ import {
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import * as CopyJobPrerequisitesCacheModule from "../../../Utils/useCopyJobPrerequisitesCache";
import usePermissionSections, {
checkTargetHasReaderRoleOnSource,
checkTargetHasReadWriteRoleOnSource,
PermissionGroupConfig,
SECTION_IDS,
} from "./usePermissionsSection";
@@ -40,12 +40,12 @@ jest.mock("../AddManagedIdentity", () => {
return MockAddManagedIdentity;
});
jest.mock("../AddReadPermissionToDefaultIdentity", () => {
const MockAddReadPermissionToDefaultIdentity = () => {
return <div data-testid="add-read-permission">AddReadPermissionToDefaultIdentity</div>;
jest.mock("../AddReadWritePermissionToDefaultIdentity", () => {
const MockAddReadWritePermissionToDefaultIdentity = () => {
return <div data-testid="add-read-write-permission">AddReadWritePermissionToDefaultIdentity</div>;
};
MockAddReadPermissionToDefaultIdentity.displayName = "MockAddReadPermissionToDefaultIdentity";
return MockAddReadPermissionToDefaultIdentity;
MockAddReadWritePermissionToDefaultIdentity.displayName = "MockAddReadWritePermissionToDefaultIdentity";
return MockAddReadWritePermissionToDefaultIdentity;
});
jest.mock("../DefaultManagedIdentity", () => {
@@ -133,7 +133,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscription: undefined,
subscriptionId: "",
databaseId: "",
containerId: "",
},
@@ -152,7 +152,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -193,7 +193,7 @@ describe("usePermissionsSection", () => {
expect(capturedResult[0].sections.map((s) => s.id)).toEqual([
SECTION_IDS.addManagedIdentity,
SECTION_IDS.defaultManagedIdentity,
SECTION_IDS.readPermissionAssigned,
SECTION_IDS.readWritePermissionAssigned,
]);
});
@@ -208,7 +208,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscription: undefined,
subscriptionId: "",
databaseId: "",
containerId: "",
},
@@ -222,7 +222,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -299,7 +299,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -337,7 +337,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -358,16 +358,17 @@ describe("usePermissionsSection", () => {
expect(defaultManagedIdentitySection?.completed).toBe(true);
});
it("should validate readPermissionAssigned section with reader role", async () => {
it("should validate readWritePermissionAssigned section with contributor role", async () => {
const mockRoleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Custom Role",
name: "00000000-0000-0000-0000-000000000002",
permissions: [
{
dataActions: [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/write",
],
},
],
@@ -398,7 +399,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -407,7 +408,9 @@ describe("usePermissionsSection", () => {
render(<TestWrapper state={state} onResult={noop} />);
await waitFor(() => {
expect(screen.getByTestId(`section-${SECTION_IDS.readPermissionAssigned}-completed`)).toHaveTextContent("true");
expect(screen.getByTestId(`section-${SECTION_IDS.readWritePermissionAssigned}-completed`)).toHaveTextContent(
"true",
);
});
expect(mockedRbacUtils.fetchRoleAssignments).toHaveBeenCalledWith(
@@ -435,7 +438,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscription: undefined,
subscriptionId: "",
databaseId: "",
containerId: "",
},
@@ -476,7 +479,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscription: undefined,
subscriptionId: "",
databaseId: "",
containerId: "",
},
@@ -546,7 +549,7 @@ describe("usePermissionsSection", () => {
type: "",
kind: "",
},
subscriptionId: "",
subscription: undefined,
databaseId: "",
containerId: "",
},
@@ -568,12 +571,12 @@ describe("usePermissionsSection", () => {
});
});
describe("checkTargetHasReaderRoleOnSource", () => {
it("should return true for built-in Reader role", () => {
describe("checkTargetHasReadWriteRoleOnSource", () => {
it("should return true for built-in Contributor role", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "00000000-0000-0000-0000-000000000001",
name: "00000000-0000-0000-0000-000000000002",
permissions: [],
assignableScopes: [],
resourceGroup: "",
@@ -583,20 +586,21 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
const result = checkTargetHasReadWriteRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
it("should return true for custom role with required data actions", () => {
it("should return true for custom role with read-write data actions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
name: "Custom Reader Role",
name: "Custom Contributor Role",
permissions: [
{
dataActions: [
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read",
"Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/write",
],
},
],
@@ -608,7 +612,7 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
const result = checkTargetHasReadWriteRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
@@ -630,12 +634,12 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
const result = checkTargetHasReadWriteRoleOnSource(roleDefinitions);
expect(result).toBe(false);
});
it("should return false for empty role definitions", () => {
const result = checkTargetHasReaderRoleOnSource([]);
const result = checkTargetHasReadWriteRoleOnSource([]);
expect(result).toBe(false);
});
@@ -653,11 +657,11 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
const result = checkTargetHasReadWriteRoleOnSource(roleDefinitions);
expect(result).toBe(false);
});
it("should handle multiple roles and return true if any has sufficient permissions", () => {
it("should handle multiple roles and return true if any has sufficient read-write permissions", () => {
const roleDefinitions: RbacUtils.RoleDefinitionType[] = [
{
id: "role-1",
@@ -675,7 +679,7 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
{
id: "role-2",
name: "00000000-0000-0000-0000-000000000001",
name: "00000000-0000-0000-0000-000000000002",
permissions: [],
assignableScopes: [],
resourceGroup: "",
@@ -685,7 +689,7 @@ describe("checkTargetHasReaderRoleOnSource", () => {
},
];
const result = checkTargetHasReaderRoleOnSource(roleDefinitions);
const result = checkTargetHasReadWriteRoleOnSource(roleDefinitions);
expect(result).toBe(true);
});
});

View File

@@ -12,7 +12,7 @@ import {
import { CopyJobContextState } from "../../../../Types/CopyJobTypes";
import { useCopyJobPrerequisitesCache } from "../../../Utils/useCopyJobPrerequisitesCache";
import AddManagedIdentity from "../AddManagedIdentity";
import AddReadPermissionToDefaultIdentity from "../AddReadPermissionToDefaultIdentity";
import AddReadWritePermissionToDefaultIdentity from "../AddReadWritePermissionToDefaultIdentity";
import DefaultManagedIdentity from "../DefaultManagedIdentity";
import OnlineCopyEnabled from "../OnlineCopyEnabled";
import PointInTimeRestore from "../PointInTimeRestore";
@@ -36,11 +36,13 @@ export interface PermissionGroupConfig {
export const SECTION_IDS = {
addManagedIdentity: "addManagedIdentity",
defaultManagedIdentity: "defaultManagedIdentity",
readPermissionAssigned: "readPermissionAssigned",
readWritePermissionAssigned: "readWritePermissionAssigned",
pointInTimeRestore: "pointInTimeRestore",
onlineCopyEnabled: "onlineCopyEnabled",
} as const;
const COSMOS_DB_BUILT_IN_DATA_CONTRIBUTOR_ROLE_ID = "00000000-0000-0000-0000-000000000002";
const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
{
id: SECTION_IDS.addManagedIdentity,
@@ -66,9 +68,9 @@ const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
},
},
{
id: SECTION_IDS.readPermissionAssigned,
title: ContainerCopyMessages.readPermissionAssigned.title,
Component: AddReadPermissionToDefaultIdentity,
id: SECTION_IDS.readWritePermissionAssigned,
title: ContainerCopyMessages.readWritePermissionAssigned.title,
Component: AddReadWritePermissionToDefaultIdentity,
disabled: true,
validate: async (state: CopyJobContextState) => {
const principalId = state?.target?.account?.identity?.principalId;
@@ -87,7 +89,7 @@ const PERMISSION_SECTIONS_CONFIG: PermissionSectionConfig[] = [
);
const roleDefinitions = await fetchRoleDefinitions(rolesAssigned ?? []);
return checkTargetHasReaderRoleOnSource(roleDefinitions ?? []);
return checkTargetHasReadWriteRoleOnSource(roleDefinitions ?? []);
},
},
];
@@ -119,18 +121,34 @@ const PERMISSION_SECTIONS_FOR_ONLINE_JOBS: PermissionSectionConfig[] = [
];
/**
* Checks if the user has the Reader role based on role definitions.
* Checks if the user has contributor-style read-write access on the source account.
*/
export function checkTargetHasReaderRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
return roleDefinitions?.some(
(role) =>
role.name === "00000000-0000-0000-0000-000000000001" ||
role.permissions.some(
(permission) =>
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata") &&
permission.dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read"),
),
);
export function checkTargetHasReadWriteRoleOnSource(roleDefinitions: RoleDefinitionType[]): boolean {
return roleDefinitions?.some((role) => {
if (role.name === COSMOS_DB_BUILT_IN_DATA_CONTRIBUTOR_ROLE_ID) {
return true;
}
const dataActions = role.permissions?.flatMap((permission) => permission.dataActions ?? []) ?? [];
const hasAccountWildcard = dataActions.includes("Microsoft.DocumentDB/databaseAccounts/*");
const hasContainerWildcard =
hasAccountWildcard || dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/*");
const hasItemsWildcard =
hasContainerWildcard ||
dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/*");
const hasAccountReadMetadata =
hasAccountWildcard || dataActions.includes("Microsoft.DocumentDB/databaseAccounts/readMetadata");
const hasItemRead =
hasItemsWildcard ||
dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/read");
const hasItemWrite =
hasItemsWildcard ||
dataActions.includes("Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers/items/write");
return hasAccountReadMetadata && hasItemRead && hasItemWrite;
});
}
/**

View File

@@ -81,7 +81,7 @@ describe("AddCollectionPanelWrapper", () => {
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: null,
@@ -109,7 +109,7 @@ describe("AddCollectionPanelWrapper", () => {
expect(container.querySelector(".addCollectionPanelWrapper")).toBeInTheDocument();
expect(container.querySelector(".addCollectionPanelHeader")).toBeInTheDocument();
expect(container.querySelector(".addCollectionPanelBody")).toBeInTheDocument();
expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading)).toBeInTheDocument();
expect(screen.getByText(ContainerCopyMessages.createNewContainerSubHeading())).toBeInTheDocument();
expect(screen.getByTestId("add-collection-panel")).toBeInTheDocument();
});

View File

@@ -1,11 +1,14 @@
import { Stack, Text } from "@fluentui/react";
import { IDropdownOption, MessageBar, MessageBarType, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
import { readDatabasesForAccount } from "Common/dataAccess/readDatabases";
import { AccountOverride } from "Contracts/DataModels";
import Explorer from "Explorer/Explorer";
import { useSidePanel } from "hooks/useSidePanel";
import { produce } from "immer";
import React, { useCallback, useEffect } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { AddCollectionPanel } from "../../../../Panes/AddCollectionPanel/AddCollectionPanel";
import ContainerCopyMessages from "../../../ContainerCopyMessages";
import { useCopyJobContext } from "../../../Context/CopyJobContext";
import { getAccountDetailsFromResourceId } from "../../../CopyJobUtils";
type AddCollectionPanelWrapperProps = {
explorer?: Explorer;
@@ -13,7 +16,26 @@ type AddCollectionPanelWrapperProps = {
};
const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapperProps> = ({ explorer, goBack }) => {
const { setCopyJobState } = useCopyJobContext();
const { setCopyJobState, copyJobState } = useCopyJobContext();
const [destinationDatabases, setDestinationDatabases] = useState<IDropdownOption[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [permissionError, setPermissionError] = useState<string | null>(null);
const targetAccountOverride: AccountOverride | undefined = useMemo(() => {
const accountId = copyJobState?.target?.account?.id;
if (!accountId) {
return undefined;
}
const details = getAccountDetailsFromResourceId(accountId);
if (!details?.subscriptionId || !details?.resourceGroup || !details?.accountName) {
return undefined;
}
return {
subscriptionId: details.subscriptionId,
resourceGroup: details.resourceGroup,
accountName: details.accountName,
};
}, [copyJobState?.target?.account?.id]);
useEffect(() => {
const sidePanelStore = useSidePanel.getState();
@@ -25,6 +47,52 @@ const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapp
};
}, []);
useEffect(() => {
if (!targetAccountOverride) {
setIsLoading(false);
return undefined;
}
let cancelled = false;
const fetchDatabases = async () => {
setIsLoading(true);
setPermissionError(null);
try {
const databases = await readDatabasesForAccount(
targetAccountOverride.subscriptionId,
targetAccountOverride.resourceGroup,
targetAccountOverride.accountName,
);
if (!cancelled) {
setDestinationDatabases(databases.map((db) => ({ key: db.id, text: db.id })));
}
} catch (error) {
if (!cancelled) {
const message = error?.message || String(error);
if (message.includes("AuthorizationFailed") || message.includes("403")) {
setPermissionError(
`You do not have sufficient permissions to access the destination account "${targetAccountOverride.accountName}". ` +
"Please ensure you have at least Contributor or Owner access to create databases and containers.",
);
} else {
setPermissionError(
`Failed to load databases from the destination account "${targetAccountOverride.accountName}": ${message}`,
);
}
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
fetchDatabases();
return () => {
cancelled = true;
};
}, [targetAccountOverride]);
const handleAddCollectionSuccess = useCallback(
(collectionData: { databaseId: string; collectionId: string }) => {
setCopyJobState(
@@ -38,13 +106,37 @@ const AddCollectionPanelWrapper: React.FunctionComponent<AddCollectionPanelWrapp
[goBack],
);
if (isLoading) {
return (
<Stack horizontalAlign="center" verticalAlign="center" styles={{ root: { padding: 20 } }}>
<Spinner size={SpinnerSize.large} label="Loading destination account databases..." />
</Stack>
);
}
if (permissionError) {
return (
<Stack styles={{ root: { padding: 20 } }}>
<MessageBar messageBarType={MessageBarType.error}>{permissionError}</MessageBar>
</Stack>
);
}
return (
<Stack className="addCollectionPanelWrapper">
<Stack.Item className="addCollectionPanelHeader">
<Text className="themeText">{ContainerCopyMessages.createNewContainerSubHeading}</Text>
<Text className="themeText">
{ContainerCopyMessages.createNewContainerSubHeading(targetAccountOverride?.accountName)}
</Text>
</Stack.Item>
<Stack.Item className="addCollectionPanelBody">
<AddCollectionPanel explorer={explorer} isCopyJobFlow={true} onSubmitSuccess={handleAddCollectionSuccess} />
<AddCollectionPanel
explorer={explorer}
isCopyJobFlow={true}
onSubmitSuccess={handleAddCollectionSuccess}
targetAccountOverride={targetAccountOverride}
externalDatabaseOptions={destinationDatabases}
/>
</Stack.Item>
</Stack>
);

View File

@@ -3,19 +3,19 @@
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
class="ms-Stack addCollectionPanelWrapper css-115"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
class="ms-StackItem addCollectionPanelHeader css-116"
>
<span
class="themeText css-111"
class="themeText css-117"
>
Select the properties for your container.
Configure the properties for the new container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
class="ms-StackItem addCollectionPanelBody css-116"
>
<div
data-testid="add-collection-panel"
@@ -44,19 +44,19 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot 1`]
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with both props 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
class="ms-Stack addCollectionPanelWrapper css-115"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
class="ms-StackItem addCollectionPanelHeader css-116"
>
<span
class="themeText css-111"
class="themeText css-117"
>
Select the properties for your container.
Configure the properties for the new container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
class="ms-StackItem addCollectionPanelBody css-116"
>
<div
data-testid="add-collection-panel"
@@ -85,19 +85,19 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with explorer prop 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
class="ms-Stack addCollectionPanelWrapper css-115"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
class="ms-StackItem addCollectionPanelHeader css-116"
>
<span
class="themeText css-111"
class="themeText css-117"
>
Select the properties for your container.
Configure the properties for the new container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
class="ms-StackItem addCollectionPanelBody css-116"
>
<div
data-testid="add-collection-panel"
@@ -126,19 +126,19 @@ exports[`AddCollectionPanelWrapper Component Rendering should match snapshot wit
exports[`AddCollectionPanelWrapper Component Rendering should match snapshot with goBack prop 1`] = `
<div>
<div
class="ms-Stack addCollectionPanelWrapper css-109"
class="ms-Stack addCollectionPanelWrapper css-115"
>
<div
class="ms-StackItem addCollectionPanelHeader css-110"
class="ms-StackItem addCollectionPanelHeader css-116"
>
<span
class="themeText css-111"
class="themeText css-117"
>
Select the properties for your container.
Configure the properties for the new container.
</span>
</div>
<div
class="ms-StackItem addCollectionPanelBody css-110"
class="ms-StackItem addCollectionPanelBody css-116"
>
<div
data-testid="add-collection-panel"

View File

@@ -87,18 +87,18 @@ describe("PreviewCopyJob", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: mockSubscription,
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "source-database",
containerId: "source-container",
},
target: {
subscriptionId: "test-subscription-id",
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "target-database",
containerId: "target-container",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
...overrides,
};
@@ -146,7 +146,7 @@ describe("PreviewCopyJob", () => {
it("should render with missing source subscription information", () => {
const mockContext = createMockContext({
source: {
subscription: undefined,
subscriptionId: "",
account: mockDatabaseAccount,
databaseId: "source-database",
containerId: "source-container",
@@ -165,7 +165,7 @@ describe("PreviewCopyJob", () => {
it("should render with missing source account information", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
subscriptionId: "test-subscription-id",
account: null,
databaseId: "source-database",
containerId: "source-container",
@@ -184,13 +184,13 @@ describe("PreviewCopyJob", () => {
it("should render with undefined database and container names", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "test-subscription-id",
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "",
containerId: "",
@@ -219,7 +219,7 @@ describe("PreviewCopyJob", () => {
const mockContext = createMockContext({
source: {
subscription: longNameSubscription,
subscriptionId: longNameSubscription.subscriptionId,
account: longNameAccount,
databaseId: "long-database-name-for-testing-purposes",
containerId: "long-container-name-for-testing-purposes",
@@ -253,13 +253,13 @@ describe("PreviewCopyJob", () => {
it("should handle special characters in database and container names", () => {
const mockContext = createMockContext({
source: {
subscription: mockSubscription,
subscriptionId: "test-subscription-id",
account: mockDatabaseAccount,
databaseId: "test-db_with@special#chars",
containerId: "test-container_with@special#chars",
},
target: {
subscriptionId: "test-subscription-id",
subscription: mockSubscription,
account: mockDatabaseAccount,
databaseId: "target-db_with@special#chars",
containerId: "target-container_with@special#chars",
@@ -285,12 +285,12 @@ describe("PreviewCopyJob", () => {
const mockContext = createMockContext({
target: {
subscriptionId: "target-subscription-id",
subscription: mockSubscription,
account: targetAccount,
databaseId: "target-database",
containerId: "target-container",
},
sourceReadAccessFromTarget: true,
sourceReadWriteAccessFromTarget: true,
});
const { container } = render(
@@ -360,7 +360,7 @@ describe("PreviewCopyJob", () => {
);
expect(getByText(/Job name/i)).toBeInTheDocument();
expect(getByText(/Source subscription/i)).toBeInTheDocument();
expect(getByText(/Source account/i)).toBeInTheDocument();
expect(getByText(/Destination subscription/i)).toBeInTheDocument();
expect(getByText(/Destination account/i)).toBeInTheDocument();
});
});

View File

@@ -36,15 +36,15 @@ const PreviewCopyJob: React.FC = () => {
<TextField data-test="job-name-textfield" value={jobName} onChange={onJobNameChange} />
</FieldRow>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceSubscriptionLabel}</Text>
<Text data-test="source-subscription-name" className="themeText">
{copyJobState.source?.subscription?.displayName}
<Text className="bold themeText">{ContainerCopyMessages.destinationSubscriptionLabel}</Text>
<Text data-test="destination-subscription-name" className="themeText">
{copyJobState.target?.subscription?.displayName}
</Text>
</Stack>
<Stack>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text data-test="source-account-name" className="themeText">
{copyJobState.source?.account?.name}
<Text className="bold themeText">{ContainerCopyMessages.destinationAccountLabel}</Text>
<Text data-test="destination-account-name" className="themeText">
{copyJobState.target?.account?.name}
</Text>
</Stack>
<Stack>

View File

@@ -49,11 +49,11 @@ exports[`PreviewCopyJob should handle special characters in database and contain
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -64,11 +64,11 @@ exports[`PreviewCopyJob should handle special characters in database and contain
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>
@@ -371,11 +371,11 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -386,13 +386,13 @@ exports[`PreviewCopyJob should render component with cross-subscription setup 1`
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
target-account
</span>
</div>
<div
@@ -693,11 +693,11 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -708,11 +708,11 @@ exports[`PreviewCopyJob should render with default state and empty job name 1`]
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>
@@ -1015,13 +1015,13 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
This is a very long subscription name that might cause display issues if not handled properly
Test Subscription
</span>
</div>
<div
@@ -1030,13 +1030,13 @@ exports[`PreviewCopyJob should render with long subscription and account names 1
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
this-is-a-very-long-database-account-name-that-might-cause-display-issues
test-account
</span>
</div>
<div
@@ -1337,11 +1337,11 @@ exports[`PreviewCopyJob should render with missing source account information 1`
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -1352,7 +1352,13 @@ exports[`PreviewCopyJob should render with missing source account information 1`
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="destination-account-name"
>
test-account
</span>
</div>
<div
@@ -1653,7 +1659,13 @@ exports[`PreviewCopyJob should render with missing source subscription informati
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="destination-subscription-name"
>
Test Subscription
</span>
</div>
<div
@@ -1662,11 +1674,11 @@ exports[`PreviewCopyJob should render with missing source subscription informati
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>
@@ -1969,11 +1981,11 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -1984,11 +1996,11 @@ exports[`PreviewCopyJob should render with online migration type 1`] = `
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>
@@ -2291,11 +2303,11 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -2306,11 +2318,11 @@ exports[`PreviewCopyJob should render with pre-filled job name 1`] = `
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>
@@ -2613,11 +2625,11 @@ exports[`PreviewCopyJob should render with undefined database and container name
<span
class="bold themeText css-125"
>
Source subscription
Destination subscription
</span>
<span
class="themeText css-125"
data-test="source-subscription-name"
data-test="destination-subscription-name"
>
Test Subscription
</span>
@@ -2628,11 +2640,11 @@ exports[`PreviewCopyJob should render with undefined database and container name
<span
class="bold themeText css-125"
>
Source account
Destination account
</span>
<span
class="themeText css-125"
data-test="source-account-name"
data-test="destination-account-name"
>
test-account
</span>

View File

@@ -38,6 +38,12 @@ describe("AccountDropdown", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscriptionId: "",
account: null,
databaseId: "",
containerId: "",
},
target: {
subscription: {
subscriptionId: "test-subscription-id",
displayName: "Test Subscription",
@@ -46,13 +52,7 @@ describe("AccountDropdown", () => {
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
} as CopyJobContextState;
const mockCopyJobContextValue = {
@@ -129,11 +129,11 @@ describe("AccountDropdown", () => {
renderWithContext();
expect(
screen.getByText(`${ContainerCopyMessages.sourceAccountDropdownLabel}:`, { exact: true }),
screen.getByText(`${ContainerCopyMessages.destinationAccountDropdownLabel}:`, { exact: true }),
).toBeInTheDocument();
expect(screen.getByRole("combobox")).toHaveAttribute(
"aria-label",
ContainerCopyMessages.sourceAccountDropdownLabel,
ContainerCopyMessages.destinationAccountDropdownLabel,
);
});
@@ -202,7 +202,7 @@ describe("AccountDropdown", () => {
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction(mockCopyJobState);
expect(newState.source.account).toEqual({
expect(newState.target.account).toEqual({
...mockDatabaseAccount1,
id: normalizeAccountId(mockDatabaseAccount1.id),
});
@@ -226,20 +226,21 @@ describe("AccountDropdown", () => {
const stateUpdateFunction = mockSetCopyJobState.mock.calls[0][0];
const newState = stateUpdateFunction(mockCopyJobState);
expect(newState.source.account).toEqual({
expect(newState.target.account).toEqual({
...mockDatabaseAccount2,
id: normalizeAccountId(mockDatabaseAccount2.id),
});
});
it("should keep current account if it exists in the filtered list", async () => {
const normalizedAccount1 = { ...mockDatabaseAccount1, id: normalizeAccountId(mockDatabaseAccount1.id) };
const contextWithSelectedAccount = {
...mockCopyJobContextValue,
copyJobState: {
...mockCopyJobState,
source: {
...mockCopyJobState.source,
account: mockDatabaseAccount1,
target: {
...mockCopyJobState.target,
account: normalizedAccount1,
},
},
};
@@ -256,12 +257,9 @@ describe("AccountDropdown", () => {
const newState = stateUpdateFunction(contextWithSelectedAccount.copyJobState);
expect(newState).toEqual({
...contextWithSelectedAccount.copyJobState,
source: {
...contextWithSelectedAccount.copyJobState.source,
account: {
...mockDatabaseAccount1,
id: normalizeAccountId(mockDatabaseAccount1.id),
},
target: {
...contextWithSelectedAccount.copyJobState.target,
account: normalizedAccount1,
},
});
});
@@ -297,8 +295,8 @@ describe("AccountDropdown", () => {
...mockCopyJobContextValue,
copyJobState: {
...mockCopyJobState,
source: {
...mockCopyJobState.source,
target: {
...mockCopyJobState.target,
account: portalAccount,
},
},
@@ -323,8 +321,8 @@ describe("AccountDropdown", () => {
...mockCopyJobContextValue,
copyJobState: {
...mockCopyJobState,
source: {
...mockCopyJobState.source,
target: {
...mockCopyJobState.target,
account: hostedAccount,
},
},
@@ -361,8 +359,8 @@ describe("AccountDropdown", () => {
...mockCopyJobContextValue,
copyJobState: {
...mockCopyJobState,
source: {
...mockCopyJobState.source,
target: {
...mockCopyJobState.target,
subscription: null,
},
} as CopyJobContextState,
@@ -376,13 +374,13 @@ describe("AccountDropdown", () => {
});
it("should not update state if account is already selected and the same", async () => {
const selectedAccount = mockDatabaseAccount1;
const selectedAccount = { ...mockDatabaseAccount1, id: normalizeAccountId(mockDatabaseAccount1.id) };
const contextWithSelectedAccount = {
...mockCopyJobContextValue,
copyJobState: {
...mockCopyJobState,
source: {
...mockCopyJobState.source,
target: {
...mockCopyJobState.target,
account: selectedAccount,
},
},
@@ -409,7 +407,7 @@ describe("AccountDropdown", () => {
renderWithContext();
const dropdown = screen.getByRole("combobox");
expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.sourceAccountDropdownLabel);
expect(dropdown).toHaveAttribute("aria-label", ContainerCopyMessages.destinationAccountDropdownLabel);
});
it("should have required attribute", () => {

View File

@@ -25,7 +25,7 @@ export const normalizeAccountId = (id: string = "") => {
export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
const { copyJobState, setCopyJobState } = useCopyJobContext();
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const selectedSubscriptionId = copyJobState?.target?.subscription?.subscriptionId;
const allAccounts: DatabaseAccount[] = useDatabaseAccounts(selectedSubscriptionId);
const sqlApiOnlyAccounts = (allAccounts || [])
.filter((account) => apiType(account) === "SQL")
@@ -36,11 +36,11 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
const updateCopyJobState = (newAccount: DatabaseAccount) => {
setCopyJobState((prevState) => {
if (prevState.source?.account?.id !== newAccount.id) {
if (prevState.target?.account?.id !== newAccount.id) {
return {
...prevState,
source: {
...prevState.source,
target: {
...prevState.target,
account: newAccount,
},
};
@@ -51,13 +51,13 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
useEffect(() => {
if (sqlApiOnlyAccounts && sqlApiOnlyAccounts.length > 0 && selectedSubscriptionId) {
const currentAccountId = copyJobState?.source?.account?.id;
const currentAccountId = copyJobState?.target?.account?.id;
const predefinedAccountId = normalizeAccountId(userContext.databaseAccount?.id);
const selectedAccountId = currentAccountId || predefinedAccountId;
const targetAccount: DatabaseAccount | null =
const matchedAccount: DatabaseAccount | null =
sqlApiOnlyAccounts.find((account) => account.id === selectedAccountId) || null;
updateCopyJobState(targetAccount || sqlApiOnlyAccounts[0]);
updateCopyJobState(matchedAccount || sqlApiOnlyAccounts[0]);
}
}, [sqlApiOnlyAccounts?.length, selectedSubscriptionId]);
@@ -77,13 +77,13 @@ export const AccountDropdown: React.FC<AccountDropdownProps> = () => {
};
const isAccountDropdownDisabled = !selectedSubscriptionId || accountOptions.length === 0;
const selectedAccountId = normalizeAccountId(copyJobState?.source?.account?.id ?? "");
const selectedAccountId = normalizeAccountId(copyJobState?.target?.account?.id ?? "");
return (
<FieldRow label={ContainerCopyMessages.sourceAccountDropdownLabel}>
<FieldRow label={ContainerCopyMessages.destinationAccountDropdownLabel}>
<Dropdown
placeholder={ContainerCopyMessages.sourceAccountDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.sourceAccountDropdownLabel}
placeholder={ContainerCopyMessages.destinationAccountDropdownPlaceholder}
ariaLabel={ContainerCopyMessages.destinationAccountDropdownLabel}
options={accountOptions}
disabled={isAccountDropdownDisabled}
required

View File

@@ -29,7 +29,7 @@ describe("MigrationType", () => {
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: { currentScreen: "selectAccount" },

View File

@@ -17,11 +17,11 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
const updateCopyJobState = (newSubscription: Subscription) => {
setCopyJobState((prevState) => {
if (prevState.source?.subscription?.subscriptionId !== newSubscription.subscriptionId) {
if (prevState.target?.subscription?.subscriptionId !== newSubscription.subscriptionId) {
return {
...prevState,
source: {
...prevState.source,
target: {
...prevState.target,
subscription: newSubscription,
account: null,
},
@@ -33,7 +33,7 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
useEffect(() => {
if (subscriptions && subscriptions.length > 0) {
const currentSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const currentSubscriptionId = copyJobState?.target?.subscription?.subscriptionId;
const predefinedSubscriptionId = userContext.subscriptionId;
const selectedSubscriptionId = currentSubscriptionId || predefinedSubscriptionId;
@@ -61,7 +61,7 @@ export const SubscriptionDropdown: React.FC<SubscriptionDropdownProps> = React.m
}
};
const selectedSubscriptionId = copyJobState?.source?.subscription?.subscriptionId;
const selectedSubscriptionId = copyJobState?.target?.subscription?.subscriptionId;
return (
<FieldRow label={ContainerCopyMessages.subscriptionDropdownLabel}>

View File

@@ -30,18 +30,18 @@ describe("SelectAccount", () => {
jobName: "",
migrationType: CopyJobMigrationType.Online,
source: {
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
sourceReadAccessFromTarget: false,
target: {
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
},
sourceReadWriteAccessFromTarget: false,
},
setCopyJobState: mockSetCopyJobState,
flow: { currentScreen: "selectAccount" },
@@ -68,7 +68,7 @@ describe("SelectAccount", () => {
expect(container.firstChild).toHaveAttribute("data-test", "Panel:SelectAccountContainer");
expect(container.firstChild).toHaveClass("selectAccountContainer");
expect(screen.getByText(/Please select a source account from which to copy/i)).toBeInTheDocument();
expect(screen.getByText(/Please select a destination account to copy to/i)).toBeInTheDocument();
expect(screen.getByTestId("subscription-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("account-dropdown")).toBeInTheDocument();

View File

@@ -8,7 +8,7 @@ exports[`SelectAccount Component Rendering should render correctly with snapshot
<span
class="themeText css-110"
>
Please select a source account from which to copy.
Please select a destination account to copy to.
</span>
<div
data-testid="subscription-dropdown"

View File

@@ -7,19 +7,9 @@ import { dropDownChangeHandler } from "./DropDownChangeHandler";
const createMockInitialState = (): CopyJobContextState => ({
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
source: {
subscription: {
subscriptionId: "source-sub-id",
displayName: "Source Subscription",
state: "Enabled",
subscriptionPolicies: {
locationPlacementId: "test",
quotaId: "test",
spendingLimit: "Off",
},
authorizationSource: "test",
},
subscriptionId: "source-sub-id",
account: {
id: "source-account-id",
name: "source-account",
@@ -50,7 +40,17 @@ const createMockInitialState = (): CopyJobContextState => ({
containerId: "source-container",
},
target: {
subscriptionId: "target-sub-id",
subscription: {
subscriptionId: "target-sub-id",
displayName: "Target Subscription",
state: "Enabled",
subscriptionPolicies: {
locationPlacementId: "test",
quotaId: "test",
spendingLimit: "Off",
},
authorizationSource: "test",
},
account: {
id: "target-account-id",
name: "target-account",
@@ -169,7 +169,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.source.databaseId).toBe("new-source-db");
expect(capturedState.source.containerId).toBeUndefined();
expect(capturedState.source.subscription).toEqual(initialState.source.subscription);
expect(capturedState.source.subscriptionId).toEqual(initialState.source.subscriptionId);
expect(capturedState.source.account).toEqual(initialState.source.account);
expect(capturedState.target).toEqual(initialState.target);
});
@@ -181,7 +181,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.jobName).toBe(initialState.jobName);
expect(capturedState.migrationType).toBe(initialState.migrationType);
expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget);
expect(capturedState.sourceReadWriteAccessFromTarget).toBe(initialState.sourceReadWriteAccessFromTarget);
});
});
@@ -193,7 +193,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.source.containerId).toBe("new-source-container");
expect(capturedState.source.databaseId).toBe(initialState.source.databaseId);
expect(capturedState.source.subscription).toEqual(initialState.source.subscription);
expect(capturedState.source.subscriptionId).toEqual(initialState.source.subscriptionId);
expect(capturedState.source.account).toEqual(initialState.source.account);
expect(capturedState.target).toEqual(initialState.target);
});
@@ -215,7 +215,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.target.databaseId).toBe("new-target-db");
expect(capturedState.target.containerId).toBeUndefined();
expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId);
expect(capturedState.target.subscription).toEqual(initialState.target.subscription);
expect(capturedState.target.account).toEqual(initialState.target.account);
expect(capturedState.source).toEqual(initialState.source);
});
@@ -227,7 +227,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.jobName).toBe(initialState.jobName);
expect(capturedState.migrationType).toBe(initialState.migrationType);
expect(capturedState.sourceReadAccessFromTarget).toBe(initialState.sourceReadAccessFromTarget);
expect(capturedState.sourceReadWriteAccessFromTarget).toBe(initialState.sourceReadWriteAccessFromTarget);
});
});
@@ -239,7 +239,7 @@ describe("dropDownChangeHandler", () => {
expect(capturedState.target.containerId).toBe("new-target-container");
expect(capturedState.target.databaseId).toBe(initialState.target.databaseId);
expect(capturedState.target.subscriptionId).toBe(initialState.target.subscriptionId);
expect(capturedState.target.subscription).toEqual(initialState.target.subscription);
expect(capturedState.target.account).toEqual(initialState.target.account);
expect(capturedState.source).toEqual(initialState.source);
});

View File

@@ -73,7 +73,7 @@ describe("SelectSourceAndTargetContainers", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "test-subscription-id" },
subscriptionId: "test-subscription-id",
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
@@ -82,7 +82,7 @@ describe("SelectSourceAndTargetContainers", () => {
containerId: "container1",
},
target: {
subscriptionId: "test-subscription-id",
subscription: { subscriptionId: "test-subscription-id" },
account: {
id: "/subscriptions/test-sub/resourceGroups/test-rg/providers/Microsoft.DocumentDB/databaseAccounts/test-account",
name: "test-account",
@@ -90,7 +90,7 @@ describe("SelectSourceAndTargetContainers", () => {
databaseId: "db2",
containerId: "container2",
},
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
};
const mockMemoizedData = {

View File

@@ -69,15 +69,15 @@ describe("useSourceAndTargetData", () => {
const mockCopyJobState: CopyJobContextState = {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
sourceReadAccessFromTarget: false,
sourceReadWriteAccessFromTarget: false,
source: {
subscription: mockSubscription,
subscriptionId: "source-subscription-id",
account: mockSourceAccount,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-subscription-id",
subscription: mockSubscription,
account: mockTargetAccount,
databaseId: "target-db",
containerId: "target-container",

View File

@@ -86,13 +86,13 @@ describe("useCopyJobNavigation", () => {
jobName: "test-job",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "source-sub-id" } as any,
subscriptionId: "source-sub-id",
account: { id: "source-account-id", name: "Account-1" } as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "target-sub-id",
subscription: { subscriptionId: "target-sub-id" } as any,
account: { id: "target-account-id", name: "Account-2" } as any,
databaseId: "target-db",
containerId: "target-container",

View File

@@ -142,14 +142,14 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: { subscriptionId: "test-sub" } as any,
subscriptionId: "test-sub",
account: { name: "test-account" } as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
subscription: { subscriptionId: "test-sub" } as any,
account: { name: "test-account" } as any,
databaseId: "",
containerId: "",
},
@@ -171,14 +171,14 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
account: { name: "test-account" } as any,
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
account: null as any,
subscription: null as any,
account: { name: "test-account" } as any,
databaseId: "",
containerId: "",
},
@@ -210,13 +210,13 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
subscriptionId: "",
account: null as any,
databaseId: "source-db",
containerId: "source-container",
},
target: {
subscriptionId: "",
subscription: null as any,
account: null as any,
databaseId: "target-db",
containerId: "target-container",
@@ -240,13 +240,13 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "source-container",
},
target: {
subscriptionId: "",
subscription: null as any,
account: null as any,
databaseId: "target-db",
containerId: "target-container",
@@ -288,13 +288,13 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "valid-job-name_123",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
@@ -318,13 +318,13 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "invalid job name with spaces!",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",
@@ -348,13 +348,13 @@ describe("useCreateCopyJobScreensList", () => {
jobName: "",
migrationType: CopyJobMigrationType.Offline,
source: {
subscription: null as any,
subscriptionId: "",
account: null as any,
databaseId: "",
containerId: "",
},
target: {
subscriptionId: "",
subscription: null as any,
account: null as any,
databaseId: "",
containerId: "",

View File

@@ -36,7 +36,7 @@ function useCreateCopyJobScreensList(goBack: () => void): Screen[] {
component: <SelectAccount />,
validations: [
{
validate: (state: CopyJobContextState) => !!state?.source?.subscription && !!state?.source?.account,
validate: (state: CopyJobContextState) => !!state?.target?.subscription && !!state?.target?.account,
message: "Please select a subscription and account to proceed",
},
],

View File

@@ -19,7 +19,7 @@ jest.mock("../../ContainerCopyMessages", () => ({
sourceContainerLabel: "Source Container",
targetDatabaseLabel: "Destination Database",
targetContainerLabel: "Destination Container",
sourceAccountLabel: "Source Account",
destinationAccountLabel: "Destination account",
MonitorJobs: {
Columns: {
lastUpdatedTime: "Date & time",
@@ -102,8 +102,8 @@ describe("CopyJobDetails", () => {
expect(screen.getByText("Date & time")).toBeInTheDocument();
expect(screen.getByText("2024-01-01T10:00:00Z")).toBeInTheDocument();
expect(screen.getByText("Source Account")).toBeInTheDocument();
expect(screen.getByText("sourceAccount")).toBeInTheDocument();
expect(screen.getByText("Destination account")).toBeInTheDocument();
expect(screen.getByText("targetAccount")).toBeInTheDocument();
expect(screen.getByText("Mode")).toBeInTheDocument();
expect(screen.getByText("Offline")).toBeInTheDocument();
@@ -263,7 +263,7 @@ describe("CopyJobDetails", () => {
expect(screen.getByText("complex_source_container_with_underscores")).toBeInTheDocument();
expect(screen.getByText("complex-target-db-with-hyphens")).toBeInTheDocument();
expect(screen.getByText("complex_target_container_with_underscores")).toBeInTheDocument();
expect(screen.getByText("complex.source.account")).toBeInTheDocument();
expect(screen.getByText("complex.target.account")).toBeInTheDocument();
});
});
@@ -322,11 +322,11 @@ describe("CopyJobDetails", () => {
render(<CopyJobDetails job={mockBasicJob} />);
const dateTimeHeading = screen.getByText("Date & time");
const sourceAccountHeading = screen.getByText("Source Account");
const destinationAccountHeading = screen.getByText("Destination account");
const modeHeading = screen.getByText("Mode");
expect(dateTimeHeading).toHaveClass("bold");
expect(sourceAccountHeading).toHaveClass("bold");
expect(destinationAccountHeading).toHaveClass("bold");
expect(modeHeading).toHaveClass("bold");
});
});

View File

@@ -106,8 +106,8 @@ const CopyJobDetails: React.FC<CopyJobDetailsProps> = ({ job }) => {
<Text className="themeText">{job.LastUpdatedTime}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.sourceAccountLabel}</Text>
<Text className="themeText">{job.Source?.remoteAccountName}</Text>
<Text className="bold themeText">{ContainerCopyMessages.destinationAccountLabel}</Text>
<Text className="themeText">{job.Destination?.remoteAccountName}</Text>
</Stack.Item>
<Stack.Item style={sectionCss.verticalAlign}>
<Text className="bold themeText">{ContainerCopyMessages.MonitorJobs.Columns.mode}</Text>

View File

@@ -55,15 +55,15 @@ export interface DatabaseContainerSectionProps {
export interface CopyJobContextState {
jobName: string;
migrationType: CopyJobMigrationType;
sourceReadAccessFromTarget?: boolean;
sourceReadWriteAccessFromTarget?: boolean;
source: {
subscription: Subscription | null;
subscriptionId: string;
account: DatabaseAccount | null;
databaseId: string;
containerId: string;
};
target: {
subscriptionId: string;
subscription: Subscription | null;
account: DatabaseAccount | null;
databaseId: string;
containerId: string;

View File

@@ -0,0 +1,168 @@
jest.mock("Utils/arm/generatedClients/cosmos/databaseAccounts");
jest.mock("Utils/NotificationConsoleUtils", () => ({
logConsoleProgress: jest.fn(() => jest.fn()), // returns a clearMessage fn
logConsoleInfo: jest.fn(),
logConsoleError: jest.fn(),
}));
jest.mock("Shared/Telemetry/TelemetryProcessor");
import { DatabaseAccount } from "../Contracts/DataModels";
import { updateUserContext, userContext } from "../UserContext";
import { update } from "../Utils/arm/generatedClients/cosmos/databaseAccounts";
import Explorer from "./Explorer";
const mockUpdate = update as jest.MockedFunction<typeof update>;
// Capture `useDialog.getState().openDialog` calls
const mockOpenDialog = jest.fn();
const mockCloseDialog = jest.fn();
jest.mock("./Controls/Dialog", () => ({
useDialog: {
getState: jest.fn(() => ({
openDialog: mockOpenDialog,
closeDialog: mockCloseDialog,
})),
},
}));
// Silence useNotebook subscription calls
jest.mock("./Notebook/useNotebook", () => ({
useNotebook: {
subscribe: jest.fn(),
getState: jest.fn().mockReturnValue(
new Proxy(
{},
{
get: () => jest.fn().mockResolvedValue(undefined),
},
),
),
},
}));
describe("Explorer.openEnableSynapseLinkDialog", () => {
let explorer: Explorer;
const baseAccount: DatabaseAccount = {
id: "/subscriptions/ctx-sub/resourceGroups/ctx-rg/providers/Microsoft.DocumentDB/databaseAccounts/ctx-account",
name: "ctx-account",
location: "East US",
type: "Microsoft.DocumentDB/databaseAccounts",
kind: "GlobalDocumentDB",
tags: {},
properties: {
documentEndpoint: "https://ctx-account.documents.azure.com:443/",
capabilities: [],
enableMultipleWriteLocations: false,
},
};
beforeAll(() => {
updateUserContext({
databaseAccount: baseAccount,
subscriptionId: "ctx-sub",
resourceGroup: "ctx-rg",
});
});
beforeEach(() => {
jest.clearAllMocks();
mockUpdate.mockResolvedValue(undefined);
explorer = new Explorer();
});
describe("without targetAccountOverride", () => {
it("should open a dialog when called without override", () => {
explorer.openEnableSynapseLinkDialog();
expect(mockOpenDialog).toHaveBeenCalledTimes(1);
});
it("should use userContext values in the update call on primary button click", async () => {
explorer.openEnableSynapseLinkDialog();
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(mockUpdate).toHaveBeenCalledWith(
"ctx-sub",
"ctx-rg",
"ctx-account",
expect.objectContaining({
properties: { enableAnalyticalStorage: true },
}),
);
});
it("should update userContext.databaseAccount.properties when no override is provided", async () => {
explorer.openEnableSynapseLinkDialog();
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(true);
});
});
describe("with targetAccountOverride", () => {
const override = {
subscriptionId: "override-sub",
resourceGroup: "override-rg",
accountName: "override-account",
};
it("should open a dialog when called with override", () => {
explorer.openEnableSynapseLinkDialog(override);
expect(mockOpenDialog).toHaveBeenCalledTimes(1);
});
it("should use override values in the update call on primary button click", async () => {
explorer.openEnableSynapseLinkDialog(override);
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(mockUpdate).toHaveBeenCalledWith(
"override-sub",
"override-rg",
"override-account",
expect.objectContaining({
properties: { enableAnalyticalStorage: true },
}),
);
});
it("should NOT update userContext.databaseAccount.properties when override is provided", async () => {
// Reset the property first
userContext.databaseAccount.properties.enableAnalyticalStorage = false;
explorer.openEnableSynapseLinkDialog(override);
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
expect(userContext.databaseAccount.properties.enableAnalyticalStorage).toBe(false);
});
it("should use override values — NOT userContext — even when userContext has different values", async () => {
explorer.openEnableSynapseLinkDialog(override);
const dialogProps = mockOpenDialog.mock.calls[0][0];
await dialogProps.onPrimaryButtonClick();
// update should NOT be called with ctx-sub / ctx-rg / ctx-account
expect(mockUpdate).not.toHaveBeenCalledWith("ctx-sub", expect.anything(), expect.anything(), expect.anything());
});
});
describe("secondary button click", () => {
it("should close the dialog on secondary button click", () => {
explorer.openEnableSynapseLinkDialog();
const dialogProps = mockOpenDialog.mock.calls[0][0];
dialogProps.onSecondaryButtonClick();
expect(mockCloseDialog).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -227,7 +227,11 @@ export default class Explorer {
this.refreshNotebookList();
}
public openEnableSynapseLinkDialog(): void {
public openEnableSynapseLinkDialog(targetAccountOverride?: DataModels.AccountOverride): void {
const subscriptionId = targetAccountOverride?.subscriptionId ?? userContext.subscriptionId;
const resourceGroup = targetAccountOverride?.resourceGroup ?? userContext.resourceGroup;
const accountName = targetAccountOverride?.accountName ?? userContext.databaseAccount.name;
const addSynapseLinkDialogProps: DialogProps = {
linkProps: {
linkText: "Learn more",
@@ -249,7 +253,7 @@ export default class Explorer {
useDialog.getState().closeDialog();
try {
await update(userContext.subscriptionId, userContext.resourceGroup, userContext.databaseAccount.name, {
await update(subscriptionId, resourceGroup, accountName, {
properties: {
enableAnalyticalStorage: true,
},
@@ -258,7 +262,9 @@ export default class Explorer {
clearInProgressMessage();
logConsoleInfo("Enabled Azure Synapse Link for this account");
TelemetryProcessor.traceSuccess(Action.EnableAzureSynapseLink, {}, startTime);
userContext.databaseAccount.properties.enableAnalyticalStorage = true;
if (!targetAccountOverride) {
userContext.databaseAccount.properties.enableAnalyticalStorage = true;
}
} catch (error) {
clearInProgressMessage();
logConsoleError(`Enabling Azure Synapse Link for this account failed. ${getErrorMessage(error)}`);

View File

@@ -12,4 +12,56 @@ describe("AddCollectionPanel", () => {
const wrapper = shallow(<AddCollectionPanel {...props} />);
expect(wrapper).toMatchSnapshot();
});
describe("targetAccountOverride prop", () => {
it("should render with targetAccountOverride prop set", () => {
const override = {
subscriptionId: "override-sub",
resourceGroup: "override-rg",
accountName: "override-account",
};
const wrapper = shallow(<AddCollectionPanel {...props} targetAccountOverride={override} />);
expect(wrapper).toBeDefined();
});
it("should pass targetAccountOverride to openEnableSynapseLinkDialog button click", () => {
const mockOpenEnableSynapseLinkDialog = jest.fn();
const explorerWithMock = { ...props.explorer, openEnableSynapseLinkDialog: mockOpenEnableSynapseLinkDialog };
const override = {
subscriptionId: "override-sub",
resourceGroup: "override-rg",
accountName: "override-account",
};
const wrapper = shallow(
<AddCollectionPanel explorer={explorerWithMock as unknown as Explorer} targetAccountOverride={override} />,
);
// isSynapseLinkEnabled section requires specific conditions; verify the component exists
expect(wrapper).toBeDefined();
});
});
describe("externalDatabaseOptions prop", () => {
it("should accept externalDatabaseOptions without error", () => {
const externalOptions = [
{ key: "db1", text: "Database One" },
{ key: "db2", text: "Database Two" },
];
const wrapper = shallow(<AddCollectionPanel {...props} externalDatabaseOptions={externalOptions} />);
expect(wrapper).toBeDefined();
});
});
describe("isCopyJobFlow prop", () => {
it("should render with isCopyJobFlow=true", () => {
const wrapper = shallow(<AddCollectionPanel {...props} isCopyJobFlow={true} />);
expect(wrapper).toBeDefined();
});
it("should render with isCopyJobFlow=false (default behaviour)", () => {
const wrapper = shallow(<AddCollectionPanel {...props} isCopyJobFlow={false} />);
expect(wrapper).toBeDefined();
});
});
});

View File

@@ -21,6 +21,7 @@ import { getNewDatabaseSharedThroughputDefault } from "Common/DatabaseUtility";
import { getErrorMessage, getErrorStack } from "Common/ErrorHandlingUtils";
import { configContext, Platform } from "ConfigContext";
import * as DataModels from "Contracts/DataModels";
import { AccountOverride } from "Contracts/DataModels";
import { FullTextPoliciesComponent } from "Explorer/Controls/FullTextSeach/FullTextPoliciesComponent";
import { VectorEmbeddingPoliciesComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent";
import {
@@ -68,6 +69,8 @@ export interface AddCollectionPanelProps {
isQuickstart?: boolean;
isCopyJobFlow?: boolean;
onSubmitSuccess?: (collectionData: { databaseId: string; collectionId: string }) => void;
targetAccountOverride?: AccountOverride;
externalDatabaseOptions?: IDropdownOption[];
}
export const DefaultVectorEmbeddingPolicy: DataModels.VectorEmbeddingPolicy = {
@@ -876,7 +879,7 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
</Text>
<DefaultButton
text={t(Keys.panes.addCollection.enable)}
onClick={() => this.props.explorer.openEnableSynapseLinkDialog()}
onClick={() => this.props.explorer.openEnableSynapseLinkDialog(this.props.targetAccountOverride)}
style={{ height: 27, width: 80 }}
styles={{ label: { fontSize: 12 } }}
/>
@@ -1061,6 +1064,9 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
}
private getDatabaseOptions(): IDropdownOption[] {
if (this.props.externalDatabaseOptions) {
return this.props.externalDatabaseOptions;
}
return useDatabases.getState().databases?.map((database) => ({
key: database.id(),
text: database.id(),
@@ -1148,6 +1154,10 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
return false;
}
if (this.props.targetAccountOverride) {
return false;
}
const selectedDatabase = useDatabases
.getState()
.databases?.find((database) => database.id() === this.state.selectedDatabaseId);
@@ -1438,13 +1448,16 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
createMongoWildcardIndex: this.state.createMongoWildCardIndex,
vectorEmbeddingPolicy,
fullTextPolicy: this.state.fullTextPolicy,
targetAccountOverride: this.props.targetAccountOverride,
};
this.setState({ isExecuting: true });
try {
await createCollection(createCollectionParams);
await this.props.explorer.refreshAllDatabases();
if (!this.props.isCopyJobFlow) {
await this.props.explorer.refreshAllDatabases();
}
if (this.props.isQuickstart) {
const database = useDatabases.getState().findDatabaseWithId(databaseId);
if (database) {
@@ -1473,7 +1486,13 @@ export class AddCollectionPanel extends React.Component<AddCollectionPanelProps,
useSidePanel.getState().closeSidePanel();
}
} catch (error) {
const errorMessage: string = getErrorMessage(error);
const rawMessage: string = getErrorMessage(error);
const errorMessage =
this.props.isCopyJobFlow && (rawMessage.includes("AuthorizationFailed") || rawMessage.includes("403"))
? `You do not have permission to create databases or containers on the destination account (${
this.props.targetAccountOverride?.accountName ?? "unknown"
}). Please ensure you have Contributor or Owner access.`
: rawMessage;
this.setState({ isExecuting: false, errorMessage, showErrorDetails: true });
const failureTelemetryData = { ...telemetryData, error: errorMessage, errorStack: getErrorStack(error) };
TelemetryProcessor.traceFailure(Action.CreateCollection, failureTelemetryData, startKey);

View File

@@ -93,7 +93,7 @@ export const assignRole = async (
return null;
}
const accountScope = `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroupName}/providers/Microsoft.DocumentDB/databaseAccounts/${accountName}`;
const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000001`;
const roleDefinitionId = `${accountScope}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002`; // Built-in Contributor role definition ID for Cosmos DB
const roleAssignmentName = crypto.randomUUID();
const path = `${accountScope}/sqlRoleAssignments/${roleAssignmentName}`;

View File

@@ -118,6 +118,9 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
params.set("nosqlRbacToken", nosqlRbacToken);
params.set("enableaaddataplane", "true");
}
if (enablecontainercopy) {
params.set("enablecontainercopy", "true");
}
break;
case TestAccount.SQLContainerCopyOnly:

View File

@@ -18,7 +18,7 @@ test.describe("Container Copy - Offline Migration", () => {
let panel: Locator;
let frame: Frame;
let expectedJobName: string;
let targetAccountName: string;
let sourceAccountName: string;
let expectedSubscriptionName: string;
let expectedCopyJobNameInitial: string;
@@ -28,7 +28,7 @@ test.describe("Container Copy - Offline Migration", () => {
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
expectedJobName = `offline_test_job_${Date.now()}`;
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
sourceAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after offline migration test", async () => {
@@ -53,7 +53,7 @@ test.describe("Container Copy - Offline Migration", () => {
// Setup subscription and account
const subscriptionDropdown = panel.getByTestId("subscription-dropdown");
const expectedAccountName = targetAccountName;
const expectedAccountName = sourceAccountName;
expectedSubscriptionName = await subscriptionDropdown.locator("span.ms-Dropdown-title").innerText();
await subscriptionDropdown.click();
@@ -185,8 +185,8 @@ test.describe("Container Copy - Offline Migration", () => {
// 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);
await expect(previewContainer.getByTestId("destination-subscription-name")).toHaveText(expectedSubscriptionName);
await expect(previewContainer.getByTestId("destination-account-name")).toHaveText(expectedAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
await expect(jobNameInput).toHaveValue(new RegExp(expectedCopyJobNameInitial));

View File

@@ -15,14 +15,14 @@ test.describe("Container Copy - Online Migration", () => {
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let targetAccountName: string;
let sourceAccountName: string;
test.beforeEach("Setup for online migration test", async ({ browser }) => {
contexts = await createMultipleTestContainers({ accountType: TestAccount.SQLContainerCopyOnly, containerCount: 2 });
page = await browser.newPage();
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQLContainerCopyOnly));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
sourceAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
});
test.afterEach("Cleanup after online migration test", async () => {
@@ -103,7 +103,7 @@ test.describe("Container Copy - Online Migration", () => {
// Verify job preview and create the online migration job
const previewContainer = panel.getByTestId("Panel:PreviewCopyJob");
await expect(previewContainer.getByTestId("source-account-name")).toHaveText(targetAccountName);
await expect(previewContainer.getByTestId("destination-account-name")).toHaveText(sourceAccountName);
const jobNameInput = previewContainer.getByTestId("job-name-textfield");
const onlineMigrationJobName = await jobNameInput.inputValue();
@@ -112,7 +112,7 @@ test.describe("Container Copy - Online Migration", () => {
const copyJobCreationPromise = waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
`${sourceAccountName}/dataTransferJobs/${onlineMigrationJobName}`,
"PUT",
);
await copyButton.click();
@@ -149,7 +149,7 @@ test.describe("Container Copy - Online Migration", () => {
const pauseResponse = await waitForApiResponse(
page,
`${targetAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`,
`${sourceAccountName}/dataTransferJobs/${onlineMigrationJobName}/pause`,
"POST",
);
expect(pauseResponse.ok()).toBe(true);

View File

@@ -10,13 +10,14 @@ test.describe("Container Copy - Permission Screen Verification", () => {
let wrapper: Locator;
let panel: Locator;
let frame: Frame;
let sourceAccountName: string;
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));
({ wrapper, frame } = await ContainerCopy.open(page, TestAccount.SQL));
targetAccountName = getAccountName(TestAccount.SQLContainerCopyOnly);
sourceAccountName = getAccountName(TestAccount.SQL);
});
test.afterEach("Cleanup after each test", async () => {
@@ -50,22 +51,14 @@ test.describe("Container Copy - Permission Screen Verification", () => {
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 (testContent.trim() === targetAccountName.trim()) {
await item.click();
break;
}
}
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 });
@@ -81,7 +74,7 @@ test.describe("Container Copy - Permission Screen Verification", () => {
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) => {
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${sourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",
@@ -139,7 +132,7 @@ test.describe("Container Copy - Permission Screen Verification", () => {
// 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`,
`/providers/Microsoft.(DocumentDB|DocumentDb)/databaseAccounts/${sourceAccountName}/backupRestore`,
);
expect(newPage.url()).toMatch(expectedUrlEndPattern);
await newPage.close();
@@ -158,8 +151,10 @@ test.describe("Container Copy - Permission Screen Verification", () => {
await expect(pitrBtn).not.toBeVisible();
// Setup additional API mocks for role assignments and permissions
// In the redesigned flow, role assignments are checked on the SOURCE account (current account = sourceAccountName).
// The destination account (selectedAccountName) manages identity; source account holds the role assignments.
await page.route(
`**/Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/sqlRoleAssignments*`,
`**/Microsoft.DocumentDB/databaseAccounts/${sourceAccountName}/sqlRoleAssignments*`,
async (route) => {
await route.fulfill({
status: 200,
@@ -168,7 +163,7 @@ test.describe("Container Copy - Permission Screen Verification", () => {
value: [
{
principalId: "00-11-22-33",
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${expectedSourceAccountName}/77-88-99`,
roleDefinitionId: `Microsoft.DocumentDB/databaseAccounts/${sourceAccountName}/77-88-99`,
},
],
}),
@@ -183,14 +178,15 @@ test.describe("Container Copy - Permission Screen Verification", () => {
body: JSON.stringify({
value: [
{
name: "00000000-0000-0000-0000-000000000001",
// Built-in Cosmos DB Data Contributor role (read-write), required by checkTargetHasReadWriteRoleOnSource
name: "00000000-0000-0000-0000-000000000002",
},
],
}),
});
});
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${targetAccountName}**`, async (route) => {
await page.route(`**/Microsoft.DocumentDB/databaseAccounts/${sourceAccountName}**`, async (route) => {
const mockData = {
identity: {
type: "SystemAssigned",