From cd1b8da36e102767f5c2d6a56599f552da765e36 Mon Sep 17 00:00:00 2001 From: Asier Isayas Date: Wed, 12 Aug 2026 16:42:11 -0400 Subject: [PATCH] Add E2E connection string login tests for SQL, Gremlin, and Tables --- test/gremlin/connectionStringLogin.spec.ts | 90 +++++++++++++++++++++ test/sql/connectionStringLogin.spec.ts | 91 ++++++++++++++++++++++ test/tables/connectionStringLogin.spec.ts | 89 +++++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 test/gremlin/connectionStringLogin.spec.ts create mode 100644 test/sql/connectionStringLogin.spec.ts create mode 100644 test/tables/connectionStringLogin.spec.ts diff --git a/test/gremlin/connectionStringLogin.spec.ts b/test/gremlin/connectionStringLogin.spec.ts new file mode 100644 index 000000000..f404693c7 --- /dev/null +++ b/test/gremlin/connectionStringLogin.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test"; + +import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; +import { CosmosClient, Database } from "@azure/cosmos"; +import { + DataExplorer, + Editor, + ONE_MINUTE_MS, + TestAccount, + generateUniqueName, + getAccountName, + getAzureCLICredentials, + resourceGroupName, + subscriptionId, +} from "../fx"; + +const gremlinAccountRbacToken = process.env.GREMLIN_TESTACCOUNT_TOKEN ?? ""; +const databaseId = generateUniqueName("db"); +const graphId = "testgraph"; +const vertexId = "testvertex"; + +test.describe("Gremlin account using connection string login", () => { + let database: Database = null!; + + test.beforeAll("Seed Test Database", async () => { + if (gremlinAccountRbacToken.length > 0) { + return; + } + const credentials = getAzureCLICredentials(); + const armClient = new CosmosDBManagementClient(credentials, subscriptionId); + const accountName = getAccountName(TestAccount.Gremlin); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + + // Gremlin graphs are stored as documents, so seed a vertex via the SQL client using Cosmos' internal graph format. + const client = new CosmosClient({ endpoint: account.documentEndpoint!, key: keys.primaryMasterKey }); + database = (await client.databases.createIfNotExists({ id: databaseId })).database; + const { container } = await database.containers.createIfNotExists({ + id: graphId, + partitionKey: { paths: ["/pk"] }, + }); + await container.items.upsert({ id: vertexId, label: "person", pk: "pk1" }); + }); + + test.afterAll("Delete Test Database", async () => { + await database?.delete(); + }); + + test("reads a vertex after connection string login", async ({ page }) => { + // Connection string (account key) login is not supported when local auth is disabled for data plane RBAC. + test.skip(gremlinAccountRbacToken.length > 0); + + const credentials = getAzureCLICredentials(); + const armClient = new CosmosDBManagementClient(credentials, subscriptionId); + const accountName = getAccountName(TestAccount.Gremlin); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + + // Gremlin signs data-plane requests client-side with the account key, so no encrypted token is issued. + const connectionString = `AccountEndpoint=${account.documentEndpoint};AccountKey=${keys.primaryMasterKey};ApiKind=Gremlin;`; + + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString); + await page.getByRole("button", { name: "Connect" }).click(); + + const explorer = await DataExplorer.waitForExplorer(page); + const graphNode = await explorer.waitForContainerNode(databaseId, graphId); + await graphNode.expand(); + + // Open the Graph node to load the graph explorer, then run the default query to read the seeded vertex. + const graphDataNode = await explorer.waitForNode(`${databaseId}/${graphId}/Graph`); + await graphDataNode.element.click(); + + await explorer.frame.getByRole("button", { name: "Execute Gremlin Query" }).click(); + + // Results open in the Graph view; switch to the JSON view to read the vertex document. + const jsonResultsTab = explorer.frame.getByRole("tab", { name: "JSON" }); + await jsonResultsTab.waitFor({ timeout: ONE_MINUTE_MS }); + await jsonResultsTab.click(); + + const graphJsonEditor = new Editor( + explorer.frame, + explorer.frame.locator(".graphJsonEditor").getByTestId("EditorReact/Host/Loaded"), + ); + await expect.poll(async () => await graphJsonEditor.text(), { timeout: ONE_MINUTE_MS }).toContain(vertexId); + }); +}); diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts new file mode 100644 index 000000000..ac474cb20 --- /dev/null +++ b/test/sql/connectionStringLogin.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; + +import { DataExplorer, getAccountName, ONE_MINUTE_MS, resourceGroupName, TestAccount } from "../fx"; +import { createTestSQLContainer, TestContainerContext } from "../testData"; + +const nosqlAccountRbacToken = process.env.NOSQL_TESTACCOUNT_TOKEN ?? ""; +const documentId = "testdoc1"; + +test.describe("SQL account using connection string login", () => { + let context: TestContainerContext = null!; + + test.beforeAll("Seed Test Database", async () => { + if (nosqlAccountRbacToken.length > 0) { + return; + } + context = await createTestSQLContainer({ partitionKey: "/id" }); + await context.container.items.upsert({ id: documentId }); + }); + + test.afterAll("Delete Test Database", async () => { + await context?.dispose(); + }); + + test("reads a document after connection string login", async ({ page }) => { + // Connection string (account key) login is not supported when local auth is disabled for data plane RBAC. + test.skip(nosqlAccountRbacToken.length > 0); + + const accountName = getAccountName(TestAccount.SQL); + const account = await context.armClient.databaseAccounts.get(resourceGroupName, accountName); + const keys = await context.armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + + // SQL signs data-plane requests client-side with the account key, so no encrypted token is issued. + const connectionString = `AccountEndpoint=${account.documentEndpoint};AccountKey=${keys.primaryMasterKey};`; + + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString); + await page.getByRole("button", { name: "Connect" }).click(); + + const explorer = await DataExplorer.waitForExplorer(page); + const collectionNode = await explorer.waitForContainerNode(context.database.id, context.container.id); + await expect(collectionNode.element).toBeAttached(); + await collectionNode.expand(); + + // Open the Items node to load the Documents tab and read the seeded document through the data plane. + const itemsNode = await explorer.waitForContainerItemsNode(context.database.id, context.container.id); + await itemsNode.element.click(); + + const documentsTab = explorer.documentsTab("tab0"); + await documentsTab.documentsFilter.waitFor(); + await documentsTab.documentsListPane.waitFor(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: ONE_MINUTE_MS }); + + const documentRow = documentsTab.documentsListPane.getByText(documentId, { exact: true }).nth(0); + await documentRow.waitFor(); + await documentRow.click(); + await expect(documentsTab.resultsEditor.locator).toBeAttached({ timeout: ONE_MINUTE_MS }); + + const resultText = await documentsTab.resultsEditor.text(); + expect(resultText).not.toBeNull(); + const resultData = JSON.parse(resultText!); + expect(resultData?.id).toEqual(documentId); + }); + + test("shows an error when the connection string has the wrong account key", async ({ page }) => { + // Connection string (account key) login is not supported when local auth is disabled for data plane RBAC. + test.skip(nosqlAccountRbacToken.length > 0); + + const accountName = getAccountName(TestAccount.SQL); + const account = await context.armClient.databaseAccounts.get(resourceGroupName, accountName); + + // A well-formed but incorrect base64 account key: the endpoint is valid, so the Cosmos client reaches + // the account but the data-plane request is rejected with 401 Unauthorized. + const wrongKey = Buffer.alloc(64).toString("base64"); + const connectionString = `AccountEndpoint=${account.documentEndpoint};AccountKey=${wrongKey};`; + + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString); + await page.getByRole("button", { name: "Connect" }).click(); + + // The connect form stays visible and surfaces the connectivity error instead of opening the explorer. + await expect(page.locator(".errorDetails")).toContainText("Unable to connect to the account", { + timeout: ONE_MINUTE_MS, + }); + }); +}); diff --git a/test/tables/connectionStringLogin.spec.ts b/test/tables/connectionStringLogin.spec.ts new file mode 100644 index 000000000..132382457 --- /dev/null +++ b/test/tables/connectionStringLogin.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from "@playwright/test"; + +import { CosmosDBManagementClient } from "@azure/arm-cosmosdb"; +import { Container, CosmosClient } from "@azure/cosmos"; +import { + DataExplorer, + ONE_MINUTE_MS, + TestAccount, + generateUniqueName, + getAccountName, + getAzureCLICredentials, + resourceGroupName, + subscriptionId, +} from "../fx"; + +const tableAccountRbacToken = process.env.TABLE_TESTACCOUNT_TOKEN ?? ""; + +// Tables API accounts store tables in a fixed "TablesDB" database, with each table as a container. +const databaseId = "TablesDB"; +const tableId = generateUniqueName("table"); +const partitionKey = "testpartition"; +const rowKey = "testrow"; + +test.describe("Tables account using connection string login", () => { + let container: Container = null!; + + test.beforeAll("Seed Test Table", async () => { + if (tableAccountRbacToken.length > 0) { + return; + } + const credentials = getAzureCLICredentials(); + const armClient = new CosmosDBManagementClient(credentials, subscriptionId); + const accountName = getAccountName(TestAccount.Tables); + const account = await armClient.databaseAccounts.get(resourceGroupName, accountName); + const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName); + + const client = new CosmosClient({ endpoint: account.documentEndpoint!, key: keys.primaryMasterKey }); + const { database } = await client.databases.createIfNotExists({ id: databaseId }); + container = ( + await database.containers.createIfNotExists({ + id: tableId, + partitionKey: { paths: ["/'$pk'"] }, + }) + ).container; + await container.items.upsert({ $pk: partitionKey, id: rowKey, $id: rowKey }); + }); + + test.afterAll("Delete Test Table", async () => { + // Only remove the table we created; the fixed "TablesDB" database is shared by every table in the + // account, so deleting it would destroy unrelated tables. + await container?.delete(); + }); + + test("reads an entity after connection string login", async ({ page }) => { + // Connection string (account key) login is not supported when local auth is disabled for data plane RBAC. + test.skip(tableAccountRbacToken.length > 0); + + const credentials = getAzureCLICredentials(); + const armClient = new CosmosDBManagementClient(credentials, subscriptionId); + const accountName = getAccountName(TestAccount.Tables); + const { connectionStrings = [] } = await armClient.databaseAccounts.listConnectionStrings( + resourceGroupName, + accountName, + ); + + // Tables sign data-plane requests client-side with the account key, so no encrypted token is issued. + const connectionString = connectionStrings.find((cs) => cs.type === "Table")?.connectionString; + + await page.goto("https://localhost:1234/hostedExplorer.html"); + const switchConnectionLink = page.getByTestId("Link:SwitchConnectionType"); + await switchConnectionLink.waitFor(); + await switchConnectionLink.click(); + await page.getByPlaceholder("Please enter a connection string").fill(connectionString!); + await page.getByRole("button", { name: "Connect" }).click(); + + const explorer = await DataExplorer.waitForExplorer(page); + const tableNode = await explorer.waitForContainerNode(databaseId, tableId); + await tableNode.expand(); + + // Open the Entities node to load the table entities grid and read the seeded entity through the data plane. + const entitiesNode = await explorer.waitForNode(`${databaseId}/${tableId}/Entities`); + await entitiesNode.element.click(); + + const entitiesGrid = explorer.frame.locator("#storageTable"); + await expect(entitiesGrid).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(entitiesGrid.getByText(rowKey, { exact: true }).first()).toBeVisible({ timeout: ONE_MINUTE_MS }); + await expect(entitiesGrid.getByText(partitionKey, { exact: true }).first()).toBeVisible(); + }); +});