mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-08-30 10:18:40 +01:00
4d9921180d
* Add client-side connection-string login for SQL, Tables, and Gremlin SQL, Tables, and Gremlin now sign data-plane requests client-side with the account key and skip the Portal Backend proxy (generatetoken/accessinputmetadata/authorizationtokens). Adds client-side host/account validation mirroring the backend ValidateHostAndAccount, plus a real CosmosClient connectivity probe that gates opening the Data Explorer. Mongo and Cassandra continue to use the encrypted-token proxy path. * Localize connection-string login validation and connectivity messages Move the hardcoded SQL/Tables/Gremlin connection-string login strings into en/Resources.json and reference them via the type-safe Keys object (t(Keys.connectExplorer.errors.*)). * Drop unused mongodb:// branch from endpoint host extraction * Remove Data Explorer references from connectivity probe comment * Reword proxy reference in ConnectionString comment * Fix trailing whitespace in connectivity probe comment * Send connection string in Authorization header for Mongo/Cassandra token request * Add E2E connection string login tests for SQL, Gremlin, and Tables * Add wrong account key test for SQL connection string login * Add access token to authorization header for encrypted token flow * Wire connection string login E2E tests to dedicated connstring accounts in CI Add TestAuthType and fold connection-string account resolution into getAccountName; seed and target the dedicated *-connstring accounts in CI while falling back to the standard per-API account locally. * Handle connection string account types in getTestExplorerUrl switch * Move globalThis.crypto polyfill to fx.ts so it runs for all specs * Remove connection string validation for connection string login - Remove validateDirectConnectionStringLogin and its helpers (extractEndpointHostFromConnectionString, extractHostToken, directLoginAllowlistedEndpointZones) - Remove old extractMasterKeyfromConnectionString (Gremlin-specific), rename extractAccountKeyFromConnectionString to extractMasterKeyfromConnectionString - Change validateDirectConnectionStringConnectivity to throw on error instead of returning string|undefined - Simplify direct-login flow: submit connection string to CosmosClient as-is, no format or endpoint validation - Keep connectivity pre-check (throws if CosmosClient cannot reach account) - Remove 6 unused localization keys for validation errors - Update tests to match new behavior * Send authorization header for connection string login backend calls * Simplify connection string login error handling - Lift the login error state into HostedExplorer so failures from the connect form and from a postMessage login share one source of truth - Show the message returned by the service instead of falling back to a generic unreachable message - Move isAuthorizationError into AuthorizationUtils alongside the other shared auth helpers - Widen getErrorMessage to accept unknown so catch variables no longer need a cast - Show the connect screen error tooltip beside the icon and widen it so long service messages fit - Consolidate the repeated account lookup and login steps in the SQL connection string spec * Consolidate hosted login account metadata into a single state Encrypted-token and direct connection-string logins now write to one accountMetadata state instead of two, which also fixes the connect form staying mounted after a successful SQL/Tables/Gremlin login. Deletes the now-unused useTokenMetadata hook and moves fetchAccessData to Platform/Hosted/Helpers/PortalAccessData.ts. * Accept connection string logins without validating them SQL, Tables, and Gremlin logins probed the account before signing in, so a user whose key was wrong or whose account was unreachable was blocked at the connect form. The probe is now gone: the connection string is accepted as-is and any bad key surfaces on the data-plane requests made from inside the explorer. Removes validateDirectConnectionStringConnectivity, isAuthorizationError, and the connectExplorer.errors.connectFailed string along with their tests. Also takes documentEndpoint straight from the AccountEndpoint in SQL and Gremlin connection strings instead of rebuilding it from the account name and a hardcoded DNS zone. Tables still derives it, since a Tables connection string only carries the table endpoint. * Keep the connect form login error local to ConnectExplorer The error state was lifted into HostedExplorer so a postMessage login could report failures, which meant the connect screen needed a second copy of the error markup for the AAD view. That copy fell outside the connectExplorerContent selector the tooltip styles are scoped to, so it rendered the message as unstyled inline text. Move the state back into ConnectExplorer and let connectWithConnectionString log the failure as it did before. A failed postMessage login still leaves the user on the connect screen either way. * Move Portal Backend calls into a PortalBackendClient helper fetchEncryptedToken and isAccountRestrictedForConnectionStringLogin lived in ConnectExplorer.tsx, so HostedExplorer imported a network call from a React component. Merge them with fetchAccessData into src/Platform/Hosted/Helpers/PortalBackendClient.ts along with the PortalBackendError type, leaving ConnectExplorer as UI only. * Leave the encrypted token login path as it is on master The direct connection string login work does not change how Mongo and Cassandra logins fail, so drop the 401/403 handling this branch added around fetchEncryptedToken along with the now unused isAuthorizationError helper. * Move the Portal Backend connection string calls out of Helpers Helpers holds pure string utilities, and every other *Client in the repo sits at its domain root, so name the module for the endpoints it wraps and place it beside HostedUtils. Also drop the branch's ConnectScreen.less tooltip restyle, which was unrelated to connection string login. * Move the Portal Backend client to Common The module is a plain service client with no dependency on the hosted platform, and CosmosClient and MetricEvents already hand roll their own Portal Backend calls, so Common is where a shared client belongs. * Add an E2E test that SQL connection string login skips the Portal Backend Nothing asserted the defining behavior of the direct login path, so reverting the short-circuit in connectWithConnectionString would have gone unnoticed. The listener filters on the connectionstring route so it covers both generatetoken and accessinputmetadata, and leaves the account restriction check alone since that still runs for every API. * Refer to the Table API as Table in comments The comments added by this branch alternated between Table and Tables when naming the API alongside SQL and Gremlin. TablesDB is left alone since that is the literal database name, as is the plural noun where it refers to actual tables. --------- Co-authored-by: Asier Isayas <aisayas@microsoft.com>
300 lines
8.8 KiB
TypeScript
300 lines
8.8 KiB
TypeScript
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
|
|
import {
|
|
BulkOperationType,
|
|
Container,
|
|
CosmosClient,
|
|
CosmosClientOptions,
|
|
Database,
|
|
ErrorResponse,
|
|
JSONObject,
|
|
} from "@azure/cosmos";
|
|
import { Buffer } from "node:buffer";
|
|
import {
|
|
generateUniqueName,
|
|
getAccountName,
|
|
getAzureCLICredentials,
|
|
resourceGroupName,
|
|
subscriptionId,
|
|
TestAccount,
|
|
} from "./fx";
|
|
|
|
export interface TestItem {
|
|
id: string;
|
|
partitionKey: string;
|
|
randomData: string;
|
|
}
|
|
|
|
export interface DocumentTestCase {
|
|
name: string;
|
|
databaseId: string;
|
|
containerId: string;
|
|
documents: TestDocument[];
|
|
}
|
|
|
|
export interface TestDocument {
|
|
documentId: string;
|
|
partitionKeys?: PartitionKey[];
|
|
skipCreateDelete?: boolean;
|
|
}
|
|
|
|
export interface PartitionKey {
|
|
key: string;
|
|
value: string | null;
|
|
}
|
|
|
|
export const partitionCount = 4;
|
|
|
|
// If we increase this number, we need to split bulk creates into multiple batches.
|
|
// Bulk operations are limited to 100 items per partition.
|
|
export const itemsPerPartition = 100;
|
|
|
|
function createTestItems(): TestItem[] {
|
|
const items: TestItem[] = [];
|
|
for (let i = 0; i < partitionCount; i++) {
|
|
for (let j = 0; j < itemsPerPartition; j++) {
|
|
const id = createSafeRandomString(32);
|
|
items.push({
|
|
id,
|
|
partitionKey: `partition_${i}`,
|
|
randomData: createSafeRandomString(32),
|
|
});
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// Document IDs cannot contain '/', '\', or '#'
|
|
function createSafeRandomString(byteLength: number): string {
|
|
const bytes = new Uint8Array(byteLength);
|
|
crypto.getRandomValues(bytes);
|
|
return Buffer.from(bytes)
|
|
.toString("base64")
|
|
.replace(/[/\\#]/g, "_");
|
|
}
|
|
|
|
export const TestData: TestItem[] = createTestItems();
|
|
|
|
export class TestContainerContext {
|
|
constructor(
|
|
public armClient: CosmosDBManagementClient,
|
|
public client: CosmosClient,
|
|
public database: Database,
|
|
public container: Container,
|
|
public testData: Map<string, TestItem>,
|
|
) {}
|
|
|
|
async dispose() {
|
|
try {
|
|
await this.database.delete();
|
|
} catch (error) {
|
|
if (error instanceof ErrorResponse && error.code === 404) {
|
|
return; // Resource already deleted, ignore
|
|
}
|
|
throw error; // Re-throw other errors
|
|
}
|
|
}
|
|
}
|
|
|
|
export class TestDatabaseContext {
|
|
constructor(
|
|
public armClient: CosmosDBManagementClient,
|
|
public client: CosmosClient,
|
|
public database: Database,
|
|
) {}
|
|
|
|
async dispose() {
|
|
await this.database.delete();
|
|
}
|
|
}
|
|
|
|
export interface CreateTestDBOptions {
|
|
throughput?: number;
|
|
maxThroughput?: number; // For autoscale
|
|
}
|
|
|
|
// Helper function to create ARM client and Cosmos client for SQL account
|
|
async function createCosmosClientForSQLAccount(
|
|
accountType: TestAccount.SQL | TestAccount.SQLContainerCopyOnly = TestAccount.SQL,
|
|
): Promise<{ armClient: CosmosDBManagementClient; client: CosmosClient }> {
|
|
const credentials = getAzureCLICredentials();
|
|
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
|
|
const accountName = getAccountName(accountType);
|
|
const account = await armClient.databaseAccounts.get(resourceGroupName, accountName);
|
|
|
|
const clientOptions: CosmosClientOptions = {
|
|
endpoint: account.documentEndpoint!,
|
|
};
|
|
|
|
const rbacToken =
|
|
accountType === TestAccount.SQL
|
|
? process.env.NOSQL_TESTACCOUNT_TOKEN
|
|
: accountType === TestAccount.SQLContainerCopyOnly
|
|
? process.env.NOSQL_CONTAINERCOPY_TESTACCOUNT_TOKEN
|
|
: "";
|
|
|
|
if (rbacToken) {
|
|
clientOptions.tokenProvider = async (): Promise<string> => {
|
|
const AUTH_PREFIX = `type=aad&ver=1.0&sig=`;
|
|
const authorizationToken = `${AUTH_PREFIX}${rbacToken}`;
|
|
return authorizationToken;
|
|
};
|
|
} else {
|
|
const keys = await armClient.databaseAccounts.listKeys(resourceGroupName, accountName);
|
|
clientOptions.key = keys.primaryMasterKey;
|
|
}
|
|
|
|
const client = new CosmosClient(clientOptions);
|
|
|
|
return { armClient, client };
|
|
}
|
|
|
|
export async function createTestDB(options?: CreateTestDBOptions): Promise<TestDatabaseContext> {
|
|
const databaseId = generateUniqueName("db");
|
|
const { armClient, client } = await createCosmosClientForSQLAccount();
|
|
|
|
// Create database with provisioned throughput (shared throughput)
|
|
// This checks the "Provision database throughput" option
|
|
const { database } = await client.databases.create({
|
|
id: databaseId,
|
|
throughput: options?.throughput, // Manual throughput (e.g., 400)
|
|
maxThroughput: options?.maxThroughput, // Autoscale max throughput (e.g., 1000)
|
|
});
|
|
|
|
return new TestDatabaseContext(armClient, client, database);
|
|
}
|
|
|
|
type createTestSqlContainerConfig = {
|
|
includeTestData?: boolean;
|
|
partitionKey?: string;
|
|
databaseName?: string;
|
|
};
|
|
|
|
type createMultipleTestSqlContainerConfig = {
|
|
containerCount?: number;
|
|
partitionKey?: string;
|
|
databaseName?: string;
|
|
accountType: TestAccount.SQLContainerCopyOnly | TestAccount.SQL;
|
|
};
|
|
|
|
export async function createMultipleTestContainers({
|
|
partitionKey = "/partitionKey",
|
|
databaseName = "",
|
|
containerCount = 1,
|
|
accountType = TestAccount.SQL,
|
|
}: createMultipleTestSqlContainerConfig): Promise<TestContainerContext[]> {
|
|
const creationPromises: Promise<TestContainerContext>[] = [];
|
|
|
|
const databaseId = databaseName ? databaseName : generateUniqueName("db");
|
|
const { armClient, client } = await createCosmosClientForSQLAccount(accountType);
|
|
const { database } = await client.databases.createIfNotExists({ id: databaseId });
|
|
|
|
try {
|
|
for (let i = 0; i < containerCount; i++) {
|
|
const containerId = `testcontainer_${Date.now()}_${Math.random().toString(36).substring(6)}_${i}`;
|
|
creationPromises.push(
|
|
database.containers.createIfNotExists({ id: containerId, partitionKey }).then(({ container }) => {
|
|
return new TestContainerContext(armClient, client, database, container, new Map<string, TestItem>());
|
|
}),
|
|
);
|
|
}
|
|
const contexts = await Promise.all(creationPromises);
|
|
return contexts;
|
|
} catch (e) {
|
|
await database.delete();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function createTestSQLContainer({
|
|
includeTestData = false,
|
|
partitionKey = "/partitionKey",
|
|
databaseName = "",
|
|
}: createTestSqlContainerConfig = {}) {
|
|
const databaseId = databaseName ? databaseName : generateUniqueName("db");
|
|
const containerId = "testcontainer"; // A unique container name isn't needed because the database is unique
|
|
const { armClient, client } = await createCosmosClientForSQLAccount();
|
|
|
|
const { database } = await client.databases.createIfNotExists({ id: databaseId });
|
|
try {
|
|
const { container } = await database.containers.createIfNotExists(
|
|
{
|
|
id: containerId,
|
|
partitionKey,
|
|
},
|
|
{
|
|
offerThroughput: 4000,
|
|
},
|
|
);
|
|
if (includeTestData) {
|
|
const batchCount = TestData.length / 100;
|
|
for (let i = 0; i < batchCount; i++) {
|
|
const batchItems = TestData.slice(i * 100, i * 100 + 100);
|
|
await container.items.bulk(
|
|
batchItems.map((item) => ({
|
|
operationType: BulkOperationType.Create,
|
|
resourceBody: item as unknown as JSONObject,
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
|
|
const testDataMap = new Map<string, TestItem>();
|
|
TestData.forEach((item) => testDataMap.set(item.id, item));
|
|
|
|
return new TestContainerContext(armClient, client, database, container, testDataMap);
|
|
} catch (e) {
|
|
await database.delete();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export const setPartitionKeys = (partitionKeys: PartitionKey[]) => {
|
|
const result: Record<string, unknown> = {};
|
|
|
|
partitionKeys.forEach((partitionKey) => {
|
|
const { key: keyPath, value: keyValue } = partitionKey;
|
|
const cleanPath = keyPath.startsWith("/") ? keyPath.slice(1) : keyPath;
|
|
const keys = cleanPath.split("/").map((segment) => {
|
|
// Strip enclosing double quotes from partition key path segments
|
|
// e.g., '"partition-key"' -> 'partition-key'
|
|
if (segment.length >= 2 && segment.charAt(0) === '"' && segment.charAt(segment.length - 1) === '"') {
|
|
return segment.slice(1, -1);
|
|
}
|
|
return segment;
|
|
});
|
|
let current = result;
|
|
|
|
keys.forEach((key, index) => {
|
|
if (index === keys.length - 1) {
|
|
current[key] = keyValue;
|
|
} else {
|
|
current[key] = current[key] || {};
|
|
current = current[key] as Record<string, unknown>;
|
|
}
|
|
});
|
|
});
|
|
|
|
return result;
|
|
};
|
|
|
|
export const serializeMongoToJson = (text: string) => {
|
|
const normalized = text.replace(/ObjectId\("([0-9a-fA-F]{24})"\)/g, '"$1"');
|
|
return JSON.parse(normalized);
|
|
};
|
|
|
|
export async function retry<T>(fn: () => Promise<T>, retries = 3, delayMs = 1000): Promise<T> {
|
|
let lastError: unknown;
|
|
for (let i = 0; i < retries; i++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
lastError = error;
|
|
console.warn(`Retry ${i + 1}/${retries} failed: ${(error as Error).message}`);
|
|
if (i < retries - 1) {
|
|
await new Promise((res) => setTimeout(res, delayMs));
|
|
}
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|