mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-19 09:02:41 +01:00
147 lines
6.9 KiB
TypeScript
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,
|
|
generateUniqueName,
|
|
getAccountName,
|
|
getAzureCLICredentials,
|
|
getConnectionStringAccountName,
|
|
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 = getConnectionStringAccountName(TestAccount.SQL);
|
|
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);
|
|
});
|
|
});
|