Files
cosmos-explorer/test/sql/connectionStringLogin.spec.ts
T
Asier Isayas 7649070811 Support sovereign/PPE endpoint zones for connection string login
Move the hardcoded account endpoint suffixes out of Constants and into ConfigContext so connection string login works in sovereign clouds and PPE, and widen ConnectionStringParser to accept every configured zone.

Surface Portal Backend rejections in the hosted connect form: read the body off the Response that fetchEncryptedToken throws, and offer a firewall help link on a 403.

Add an E2E test covering SQL connection string login against an account with public network access disabled.
2026-08-26 13:05:32 -04:00

147 lines
6.9 KiB
TypeScript

import { Page, expect, test } from "@playwright/test";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { CosmosClient, Database } from "@azure/cosmos";
import {
DataExplorer,
ONE_MINUTE_MS,
TestAccount,
TestAuthType,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../fx";
const databaseId = generateUniqueName("db");
const containerId = "testcontainer";
const documentId = "testdoc1";
async function loginWithConnectionString(page: Page, connectionString: string): Promise<void> {
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();
}
test.describe("SQL account using connection string login", () => {
let database: Database = null!;
let documentEndpoint: string = null!;
// SQL signs data-plane requests client-side with the account key, so no encrypted token is issued.
let connectionString: string = null!;
test.beforeAll("Seed Test Database", async () => {
const credentials = getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.SQL, TestAuthType.ConnectionString);
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
documentEndpoint = account.documentEndpoint!;
connectionString = `AccountEndpoint=${documentEndpoint};AccountKey=${keys.primaryMasterKey};`;
const client = new CosmosClient({ endpoint: documentEndpoint, key: keys.primaryMasterKey });
database = (await client.databases.createIfNotExists({ id: databaseId })).database;
const { container } = await database.containers.createIfNotExists({
id: containerId,
partitionKey: { paths: ["/id"] },
});
await container.items.upsert({ id: documentId });
});
test.afterAll("Delete Test Database", async () => {
await database?.delete();
});
test("reads a document after connection string login", async ({ page }) => {
await loginWithConnectionString(page, connectionString);
const explorer = await DataExplorer.waitForExplorer(page);
const collectionNode = await explorer.waitForContainerNode(databaseId, containerId);
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(databaseId, containerId);
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("does not call the Portal Backend during login", async ({ page }) => {
// SQL derives the account metadata from the connection string and signs data-plane requests with the
// account key, so neither the encrypted token nor the access metadata endpoint should be hit.
const portalBackendCalls: string[] = [];
page.on("request", (request) => {
if (request.url().includes("/api/connectionstring/")) {
portalBackendCalls.push(request.url());
}
});
await loginWithConnectionString(page, connectionString);
const explorer = await DataExplorer.waitForExplorer(page);
const collectionNode = await explorer.waitForContainerNode(databaseId, containerId);
await expect(collectionNode.element).toBeAttached();
expect(portalBackendCalls).toEqual([]);
});
test("opens Data Explorer when the connection string has the wrong account key", async ({ page }) => {
// A well-formed but incorrect base64 account key. The login is accepted
// without checking the key against the account, so the user gets into Data Explorer either way and
// only the data-plane requests made from inside the explorer are rejected.
const wrongKey = "A".repeat(86) + "==";
await loginWithConnectionString(page, `AccountEndpoint=${documentEndpoint};AccountKey=${wrongKey};`);
await DataExplorer.waitForExplorer(page);
// The connect form is replaced by the explorer rather than staying up with a login error.
await expect(page.locator("#connectExplorer")).toHaveCount(0);
await expect(page.locator(".errorDetails")).toHaveCount(0);
});
test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => {
// An account that refuses this client's IP.
const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId);
const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled);
const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName);
const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName);
await loginWithConnectionString(
page,
`AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`,
);
const explorer = await DataExplorer.waitForExplorer(page);
// Login is a client-side parse of the connection string, so nothing checks whether the account will
// accept requests from this IP before letting the user in.
await expect(page.locator("#connectExplorer")).toHaveCount(0);
await expect(page.locator(".errorDetails")).toHaveCount(0);
// The rejection surfaces once the tree tries to read the data plane, and only in the console.
const consoleMessages = await explorer.getNotificationConsoleMessages();
await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS });
// The tree is left with the static Home node and no database or container beneath it.
await expect(explorer.treeNode("Home").element).toBeAttached();
await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1);
});
});