feat: Redesign container-copy flow to select destination account and enable cross-account container creation (#2436)

* feat: Redesign container-copy flow to select destination account and enable cross-account container creation

* upgrade RBAC permissions from read only to read-write

* fix copyjob playwright tests

* swap source-destination content

* fix formating

* use targetAccountOverride for capability checks in AddCollectionPanel

* feat: localize ContainerCopy hardcoded strings using i18next and refactor readDatabasesWithARM

* removed container copy messages json file
This commit is contained in:
BChoudhury-ms
2026-05-11 17:24:51 +05:30
committed by GitHub
parent 762e12a3f9
commit d374458e1b
81 changed files with 1810 additions and 1076 deletions
+5 -4
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,
@@ -0,0 +1,137 @@
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",
capabilities: [],
},
};
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",
capabilities: [],
},
};
// 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",
capabilities: [],
},
};
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" }),
}),
}),
);
});
});
});
+5 -8
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);
}
+148 -1
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, readDatabasesWithARM } from "./readDatabases";
describe("readDatabases", () => {
beforeAll(() => {
@@ -42,3 +43,149 @@ describe("readDatabases", () => {
expect(client).toHaveBeenCalled();
});
});
describe("readDatabasesWithARM (with accountOverride)", () => {
const mockDatabase = { id: "testDb", _rid: "", _self: "", _etag: "", _ts: 0 };
const mockArmResponse = { value: [{ properties: { resource: mockDatabase } }] };
beforeAll(() => {
updateUserContext({
databaseAccount: { name: "context-account" } as DatabaseAccount,
subscriptionId: "context-sub",
resourceGroup: "context-rg",
apiType: "SQL",
});
});
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 readDatabasesWithARM({ subscriptionId: "test-sub", resourceGroup: "test-rg", accountName: "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 use apiType from accountOverride when provided (SQL)", async () => {
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "account", apiType: "SQL" });
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("/sqlDatabases") }),
);
});
it("should use apiType from accountOverride when provided (Mongo)", async () => {
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesWithARM({
subscriptionId: "sub",
resourceGroup: "rg",
accountName: "account",
apiType: "Mongo",
});
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("/mongodbDatabases") }),
);
});
it("should use apiType from accountOverride when provided (Cassandra)", async () => {
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesWithARM({
subscriptionId: "sub",
resourceGroup: "rg",
accountName: "account",
apiType: "Cassandra",
});
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("/cassandraKeyspaces") }),
);
});
it("should use apiType from accountOverride when provided (Gremlin)", async () => {
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesWithARM({
subscriptionId: "sub",
resourceGroup: "rg",
accountName: "account",
apiType: "Gremlin",
});
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("/gremlinDatabases") }),
);
});
it("should fall back to userContext.apiType when apiType is not in accountOverride", async () => {
updateUserContext({ apiType: "Mongo" });
(armRequest as jest.Mock).mockResolvedValue(mockArmResponse);
await readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "account" });
expect(armRequest).toHaveBeenCalledWith(
expect.objectContaining({ path: expect.stringContaining("/mongodbDatabases") }),
);
updateUserContext({ apiType: "SQL" }); // restore
});
it("should throw for unsupported apiType", async () => {
await expect(
readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "account", apiType: "Tables" }),
).rejects.toThrow("Unsupported default experience type: Tables");
});
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 readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "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 readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "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 readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "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(
readDatabasesWithARM({ subscriptionId: "sub", resourceGroup: "rg", accountName: "account" }),
).rejects.toThrow("ARM request failed");
});
});
+12 -5
View File
@@ -4,7 +4,7 @@ import { AuthType } from "../../AuthType";
import * as DataModels from "../../Contracts/DataModels";
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor";
import { FabricArtifactInfo, userContext } from "../../UserContext";
import { ApiType, FabricArtifactInfo, userContext } from "../../UserContext";
import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/cosmos/gremlinResources";
@@ -96,10 +96,17 @@ export async function readDatabases(): Promise<DataModels.Database[]> {
return databases;
}
async function readDatabasesWithARM(): Promise<DataModels.Database[]> {
export async function readDatabasesWithARM(accountOverride?: {
subscriptionId: string;
resourceGroup: string;
accountName: string;
apiType?: ApiType;
}): Promise<DataModels.Database[]> {
let rpResponse;
const { subscriptionId, resourceGroup, apiType, databaseAccount } = userContext;
const accountName = databaseAccount.name;
const subscriptionId = accountOverride?.subscriptionId ?? userContext.subscriptionId ?? "";
const resourceGroup = accountOverride?.resourceGroup ?? userContext.resourceGroup ?? "";
const accountName = accountOverride?.accountName ?? userContext?.databaseAccount?.name ?? "";
const apiType = accountOverride?.apiType ?? userContext.apiType;
switch (apiType) {
case "SQL":
@@ -118,5 +125,5 @@ async function readDatabasesWithARM(): Promise<DataModels.Database[]> {
throw new Error(`Unsupported default experience type: ${apiType}`);
}
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database);
return rpResponse?.value?.map((database) => database.properties?.resource as DataModels.Database) ?? [];
}