Add client-side connection-string login for SQL, Tables, and Gremlin (#2559)

* 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>
This commit is contained in:
asier-isayas
2026-08-20 07:59:52 -07:00
committed by GitHub
parent 086c17ecff
commit 4d9921180d
23 changed files with 655 additions and 128 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ export class EndpointsRegex {
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com";
public static readonly table = "TableEndpoint=https://(.*).table.cosmosdb.azure.com";
public static readonly table = "TableEndpoint=https://(.*).table.cosmos(?:db)?.azure.com";
}
export class ApiEndpoints {
+1
View File
@@ -143,6 +143,7 @@ export async function getTokenFromAuthService(
headers: {
"content-type": "application/json",
"x-ms-encrypted-auth-token": userContext.accessToken,
authorization: userContext.accessToken,
},
body: JSON.stringify({
verb,
+4 -1
View File
@@ -22,7 +22,10 @@ const defaultHeaders = {
function authHeaders() {
if (userContext.authType === AuthType.EncryptedToken) {
return { [HttpHeaders.guestAccessToken]: userContext.accessToken };
return {
[HttpHeaders.guestAccessToken]: userContext.accessToken,
[HttpHeaders.authorization]: userContext.accessToken,
};
} else {
const headers: { [key: string]: string } = {
[HttpHeaders.authorization]: userContext.authorizationToken,
+45
View File
@@ -0,0 +1,45 @@
import { configContext } from "../ConfigContext";
import { AccessInputMetadata } from "../Contracts/DataModels";
import { HttpHeaders } from "./Constants";
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
headers.append(HttpHeaders.authorization, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
export async function fetchEncryptedToken(connectionString: string): Promise<string> {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
headers.append(HttpHeaders.authorization, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw response;
}
const encryptedTokenResponse: string = await response.json();
return decodeURIComponent(encryptedTokenResponse);
}
export async function isAccountRestrictedForConnectionStringLogin(connectionString: string): Promise<boolean> {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw response;
}
return (await response.text()).toLowerCase() === "true";
}
+4
View File
@@ -552,6 +552,10 @@ export class CassandraAPIDataClient extends TableDataClient {
const authorizationHeaderMetadata: ViewModels.AuthorizationTokenHeaderMetadata = getAuthorizationHeader();
xhr.setRequestHeader(authorizationHeaderMetadata.header, authorizationHeaderMetadata.token);
if (userContext.authType === AuthType.EncryptedToken) {
xhr.setRequestHeader(Constants.HttpHeaders.authorization, userContext.accessToken);
}
if (isDataplaneRbacEnabledForProxyApi(userContext)) {
xhr.setRequestHeader(Constants.HttpHeaders.entraIdToken, userContext.aadToken);
}
+7 -7
View File
@@ -1,6 +1,6 @@
jest.mock("./hooks/useAADAuth");
jest.mock("./hooks/useConfig");
jest.mock("./hooks/usePortalAccessToken");
jest.mock("./Common/PortalBackendClient");
jest.mock("./Platform/Hosted/Components/ConnectExplorer");
jest.mock("./Shared/appInsights");
jest.mock("./Platform/Hosted/Components/AccountSwitcher", () => ({
@@ -25,11 +25,11 @@ jest.mock("./Platform/Hosted/Components/AadAuthorizationFailure", () => ({
import "@testing-library/jest-dom";
import { act, render } from "@testing-library/react";
import React from "react";
import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
import { App } from "./HostedExplorer";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
@@ -54,7 +54,7 @@ beforeEach(() => {
jest.clearAllMocks();
(useAADAuth as jest.Mock).mockReturnValue(defaultAADAuth);
(useConfig as jest.Mock).mockReturnValue({});
(useTokenMetadata as jest.Mock).mockReturnValue(undefined);
(fetchAccessData as jest.Mock).mockResolvedValue(undefined);
mockFetchEncryptedToken.mockResolvedValue("encrypted-token");
});
@@ -81,7 +81,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(validConnStr);
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("accepts a valid Mongo connection string from an allowed origin", async () => {
@@ -129,7 +129,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(tableConnStr);
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("accepts a valid Gremlin connection string from an allowed origin", async () => {
@@ -145,7 +145,7 @@ describe("HostedExplorer tryCosmosDB postMessage handler", () => {
await Promise.resolve();
});
expect(mockFetchEncryptedToken).toHaveBeenCalledWith(gremlinConnStr);
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
});
it("rejects messages from a disallowed origin", async () => {
+58 -29
View File
@@ -1,5 +1,6 @@
import { initializeIcons } from "@fluentui/react";
import { useBoolean } from "@fluentui/react-hooks";
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
import * as React from "react";
import { render } from "react-dom";
@@ -7,11 +8,12 @@ import ChevronRight from "../images/chevron-right.svg";
import "../less/hostedexplorer.less";
import { AuthType } from "./AuthType";
import { logError } from "./Common/Logger";
import { DatabaseAccount } from "./Contracts/DataModels";
import { fetchAccessData, fetchEncryptedToken } from "./Common/PortalBackendClient";
import { AccessInputMetadata, DatabaseAccount } from "./Contracts/DataModels";
import "./Explorer/Menus/NavBar/MeControlComponent.less";
import { HostedExplorerChildFrame } from "./HostedExplorerChildFrame";
import { AccountSwitcher } from "./Platform/Hosted/Components/AccountSwitcher";
import { ConnectExplorer, fetchEncryptedToken } from "./Platform/Hosted/Components/ConnectExplorer";
import { ConnectExplorer } from "./Platform/Hosted/Components/ConnectExplorer";
import { DirectoryPickerPanel } from "./Platform/Hosted/Components/DirectoryPickerPanel";
import { FeedbackCommandButton } from "./Platform/Hosted/Components/FeedbackCommandButton";
import { MeControl } from "./Platform/Hosted/Components/MeControl";
@@ -19,12 +21,14 @@ import { SignInButton } from "./Platform/Hosted/Components/SignInButton";
import "./Platform/Hosted/ConnectScreen.less";
import { parseConnectionString } from "./Platform/Hosted/Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "./Platform/Hosted/Helpers/ResourceTokenUtils";
import { extractMasterKeyfromConnectionString } from "./Platform/Hosted/HostedUtils";
import {
extractMasterKeyFromDirectLoginConnectionString,
isDirectConnectionStringLoginApi,
} from "./Platform/Hosted/HostedUtils";
import "./Shared/appInsights";
import { allowedHostedExplorerEndpoints } from "./Utils/EndpointUtils";
import { useAADAuth } from "./hooks/useAADAuth";
import { useConfig } from "./hooks/useConfig";
import { useTokenMetadata } from "./hooks/usePortalAccessToken";
initializeIcons();
@@ -32,7 +36,15 @@ const App: React.FunctionComponent = () => {
// For handling encrypted portal tokens sent via query paramter
const params = new URLSearchParams(window.location.search);
const [encryptedToken, setEncryptedToken] = React.useState<string>(params && params.get("key"));
const encryptedTokenMetadata = useTokenMetadata(encryptedToken);
// Encrypted token logins resolve the account metadata through the Portal Backend, while SQL/Table/Gremlin
// connection-string logins derive it client-side, so both paths write to the same value.
const [accountMetadata, setAccountMetadata] = React.useState<AccessInputMetadata>();
React.useEffect(() => {
if (encryptedToken) {
fetchAccessData(encryptedToken).then(setAccountMetadata);
}
}, [encryptedToken]);
// For showing/hiding panel
const [isOpen, { setTrue: openPanel, setFalse: dismissPanel }] = useBoolean(false);
@@ -54,19 +66,29 @@ const App: React.FunctionComponent = () => {
setConnectionString(connStr);
if (isResourceTokenConnectionString(connStr)) {
setAuthType(AuthType.ResourceToken);
} else {
fetchEncryptedToken(connStr)
.then((token) => {
setEncryptedToken(token);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
logError(
`Failed to connect with connection string: ${error}`,
"HostedExplorer/connectWithConnectionString",
);
});
return;
}
const metadata = parseConnectionString(connStr);
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so we skip
// the Portal Backend proxy and use the metadata derived from the connection string directly.
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
fetchEncryptedToken(connStr)
.then((token) => {
setEncryptedToken(token);
setAuthType(AuthType.ConnectionString);
})
.catch((error) => {
logError(
`Failed to connect with connection string: ${getErrorMessage(error)}`,
"HostedExplorer/connectWithConnectionString",
);
});
},
[authType],
);
@@ -114,14 +136,14 @@ const App: React.FunctionComponent = () => {
frameWindow.hostedConfig = {
authType: AuthType.EncryptedToken,
encryptedToken,
encryptedTokenMetadata,
encryptedTokenMetadata: accountMetadata,
};
} else if (authType === AuthType.ConnectionString) {
frameWindow.hostedConfig = {
authType: AuthType.ConnectionString,
encryptedToken,
encryptedTokenMetadata,
masterKey: extractMasterKeyfromConnectionString(connectionString),
encryptedTokenMetadata: accountMetadata,
masterKey: extractMasterKeyFromDirectLoginConnectionString(connectionString),
};
} else if (authType === AuthType.ResourceToken) {
frameWindow.hostedConfig = {
@@ -140,7 +162,7 @@ const App: React.FunctionComponent = () => {
const showExplorer =
(config && isLoggedIn && databaseAccount && !connectionString) ||
(encryptedTokenMetadata && encryptedTokenMetadata) ||
accountMetadata ||
(authType === AuthType.ResourceToken && connectionString);
return (
@@ -157,7 +179,7 @@ const App: React.FunctionComponent = () => {
Microsoft Azure
</span>
<span className="accontSplitter" /> <span className="serviceTitle">Cosmos DB</span>
{(isLoggedIn || encryptedTokenMetadata?.accountName) && (
{(isLoggedIn || accountMetadata?.accountName) && (
<img className="chevronRight" src={ChevronRight} alt="account separator" />
)}
{isLoggedIn && !connectionString && (
@@ -165,9 +187,9 @@ const App: React.FunctionComponent = () => {
<AccountSwitcher armToken={armToken} setDatabaseAccount={setDatabaseAccount} />
</span>
)}
{(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && (
{(!isLoggedIn || connectionString) && accountMetadata?.accountName && (
<span className="accountSwitchComponentContainer">
<span className="accountNameHeader">{encryptedTokenMetadata?.accountName}</span>
<span className="accountNameHeader">{accountMetadata?.accountName}</span>
</span>
)}
</div>
@@ -188,9 +210,7 @@ const App: React.FunctionComponent = () => {
// It's possible this can be changed once all knockout code has been removed.
<iframe
// Setting key is needed so React will re-render this element on any account change
key={
authType ? `${authType}-${encryptedTokenMetadata?.accountName || connectionString}` : databaseAccount?.id
}
key={authType ? `${authType}-${accountMetadata?.accountName || connectionString}` : databaseAccount?.id}
ref={ref}
data-test="DataExplorerFrame"
id="explorerMenu"
@@ -200,8 +220,17 @@ const App: React.FunctionComponent = () => {
src="explorer.html?v=1.0.1&platform=Hosted"
></iframe>
)}
{!isLoggedIn && !encryptedTokenMetadata && (
<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />
{!isLoggedIn && !accountMetadata && (
<ConnectExplorer
{...{
login,
setEncryptedToken,
setAuthType,
connectionString,
setConnectionString,
setAccountMetadata,
}}
/>
)}
{isLoggedIn && authFailure && <AadAuthorizationFailure {...{ authFailure }} />}
{isLoggedIn && !authFailure && (
+5 -3
View File
@@ -14,10 +14,12 @@ export interface AAD {
export interface ConnectionString {
authType: AuthType.ConnectionString;
// Connection string uses still use encrypted token for Cassandra/Mongo APIs as they us the portal backend proxy
encryptedToken: string;
// SQL, Table, and Gremlin sign data-plane requests client-side with the master key and do not need the
// proxies, so they carry no encrypted token. Mongo and Cassandra still use the encrypted
// token because their operations go through the proxies.
encryptedToken?: string;
encryptedTokenMetadata: AccessInputMetadata;
// Master key is currently only used by Graph API. All other APIs use encrypted tokens and proxy with connection string
// Master key is used for the client-side signing path (SQL, Table, Gremlin). Mongo/Cassandra leave it undefined.
masterKey?: string;
}
+1 -1
View File
@@ -1175,4 +1175,4 @@
}
}
}
}
}
@@ -12,8 +12,20 @@ it("shows the connect form", () => {
const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn();
const setAuthType = jest.fn();
const setAccountMetadata = jest.fn();
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
render(
<ConnectExplorer
{...{
login,
setEncryptedToken,
setAuthType,
connectionString,
setConnectionString,
setAccountMetadata,
}}
/>,
);
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeNull();
fireEvent.click(screen.getByText("Connect to your account with connection string"));
expect(screen.queryByPlaceholderText("Please enter a connection string")).toBeDefined();
@@ -25,6 +37,7 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
const setConnectionString = jest.fn();
const setEncryptedToken = jest.fn();
const setAuthType = jest.fn();
const setAccountMetadata = jest.fn();
const oldFeatures = userContext.features;
const params = new URLSearchParams({
@@ -34,7 +47,18 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
const testFeatures = extractFeatures(params);
updateUserContext({ features: testFeatures });
render(<ConnectExplorer {...{ login, setEncryptedToken, setAuthType, connectionString, setConnectionString }} />);
render(
<ConnectExplorer
{...{
login,
setEncryptedToken,
setAuthType,
connectionString,
setConnectionString,
setAccountMetadata,
}}
/>,
);
expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull();
updateUserContext({ features: oldFeatures });
@@ -1,12 +1,15 @@
import { useBoolean } from "@fluentui/react-hooks";
import { getErrorMessage } from "Common/ErrorHandlingUtils";
import { userContext } from "UserContext";
import * as React from "react";
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
import ErrorImage from "../../../../images/error.svg";
import { AuthType } from "../../../AuthType";
import { HttpHeaders } from "../../../Common/Constants";
import { configContext } from "../../../ConfigContext";
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
import { AccessInputMetadata } from "../../../Contracts/DataModels";
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
import { isResourceTokenConnectionString } from "../Helpers/ResourceTokenUtils";
import { isDirectConnectionStringLoginApi } from "../HostedUtils";
interface Props {
connectionString: string;
@@ -14,39 +17,16 @@ interface Props {
setEncryptedToken: (token: string) => void;
setConnectionString: (connectionString: string) => void;
setAuthType: (authType: AuthType) => void;
setAccountMetadata: (metadata: AccessInputMetadata) => void;
}
export const fetchEncryptedToken = async (connectionString: string): Promise<string> => {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/connectionstring/token/generatetoken";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw response;
}
const encryptedTokenResponse: string = await response.json();
return decodeURIComponent(encryptedTokenResponse);
};
export const isAccountRestrictedForConnectionStringLogin = async (connectionString: string): Promise<boolean> => {
const headers = new Headers();
headers.append(HttpHeaders.connectionString, connectionString);
const url = configContext.PORTAL_BACKEND_ENDPOINT + "/api/guest/accountrestrictions/checkconnectionstringlogin";
const response = await fetch(url, { headers, method: "POST" });
if (!response.ok) {
throw response;
}
return (await response.text()).toLowerCase() === "true";
};
export const ConnectExplorer: React.FunctionComponent<Props> = ({
setEncryptedToken,
login,
setAuthType,
connectionString,
setConnectionString,
setAccountMetadata,
}: Props) => {
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
const [errorMessage, setErrorMessage] = React.useState("");
@@ -67,10 +47,15 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
event.preventDefault();
setErrorMessage("");
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
);
try {
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
setErrorMessage(
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
);
return;
}
} catch (error) {
setErrorMessage(getErrorMessage(error));
return;
}
@@ -79,6 +64,15 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
return;
}
const metadata = parseConnectionString(connectionString);
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so
// we skip the Portal Backend proxy and use the metadata parsed from the connection string.
setAccountMetadata(metadata);
setAuthType(AuthType.ConnectionString);
return;
}
const encryptedToken = await fetchEncryptedToken(connectionString);
setEncryptedToken(encryptedToken);
setAuthType(AuthType.ConnectionString);
@@ -12,6 +12,18 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
});
it("should keep the document endpoint given by the connection string", () => {
// The endpoint is taken from the connection string rather than rebuilt from the account name, so a
// string that omits the port keeps it omitted.
const metadata = parseConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com/;AccountKey=${mockMasterKey};`,
);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
});
it("should parse a valid mongo account connection string", () => {
@@ -39,6 +51,8 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
});
it("should parse a valid table account connection string", () => {
@@ -48,6 +62,20 @@ describe("ConnectionStringParser", () => {
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
// Table data operations go through the document endpoint, which is constructed from the account name.
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
});
it("should parse a valid table account connection string using the cosmos.azure.com zone", () => {
const metadata = parseConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`,
);
expect(metadata.accountName).toBe(mockAccountName);
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
expect(metadata.apiEndpoint).toBeUndefined();
});
it("should parse a valid cassandra account connection string", () => {
@@ -1,6 +1,13 @@
import * as Constants from "../../../Common/Constants";
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's
// accessinputmetadata API constructs from the account name when the connection string does not already
// contain the endpoint.
const DocumentEndpointZone = "documents.azure.com";
const GremlinEndpointZone = "gremlin.cosmos.azure.com";
const DnsPort = "443";
export function parseConnectionString(connectionString: string): AccessInputMetadata {
if (connectionString) {
try {
@@ -11,6 +18,9 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
accessInput.apiKind = ApiKind.SQL;
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
// given instead of rebuilding it from the account name.
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
accessInput.accountName = matches && matches.length > 1 && matches[2];
@@ -38,6 +48,17 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
return undefined;
}
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
// operations go through has to be derived from the account name. Gremlin accounts additionally
// need the Gremlin endpoint, which is never part of the connection string.
if (accessInput.accountName) {
if (accessInput.apiKind === ApiKind.Table) {
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
} else if (accessInput.apiKind === ApiKind.Graph) {
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
}
}
return accessInput;
} catch (error) {
return undefined;
+61 -2
View File
@@ -1,5 +1,9 @@
import { AccessInputMetadata } from "../../Contracts/DataModels";
import { getDatabaseAccountPropertiesFromMetadata } from "./HostedUtils";
import { AccessInputMetadata, ApiKind } from "../../Contracts/DataModels";
import {
extractMasterKeyFromDirectLoginConnectionString,
getDatabaseAccountPropertiesFromMetadata,
isDirectConnectionStringLoginApi,
} from "./HostedUtils";
describe("getDatabaseAccountPropertiesFromMetadata", () => {
it("should only return an object with the mongoEndpoint key if the apiKind is mongoCompute (5)", () => {
@@ -30,3 +34,58 @@ describe("getDatabaseAccountPropertiesFromMetadata", () => {
});
});
});
describe("extractMasterKeyFromDirectLoginConnectionString", () => {
const mockAccountName = "Test";
const mockKey = "abc123+/=someKey==";
it("extracts the account key from a SQL connection string", () => {
expect(
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};`,
),
).toBe(mockKey);
});
it("extracts the account key from a Table connection string", () => {
expect(
extractMasterKeyFromDirectLoginConnectionString(
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
),
).toBe(mockKey);
});
it("extracts the account key from a Gremlin connection string", () => {
expect(
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockKey};ApiKind=Gremlin;`,
),
).toBe(mockKey);
});
it("returns undefined when there is no account key", () => {
expect(
extractMasterKeyFromDirectLoginConnectionString(
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;`,
),
).toBeUndefined();
});
it("returns undefined for an empty connection string", () => {
expect(extractMasterKeyFromDirectLoginConnectionString("")).toBeUndefined();
});
});
describe("isDirectConnectionStringLoginApi", () => {
it("returns true for SQL, Table, and Graph", () => {
expect(isDirectConnectionStringLoginApi(ApiKind.SQL)).toBe(true);
expect(isDirectConnectionStringLoginApi(ApiKind.Table)).toBe(true);
expect(isDirectConnectionStringLoginApi(ApiKind.Graph)).toBe(true);
});
it("returns false for Mongo and Cassandra, which require the Portal Backend proxy", () => {
expect(isDirectConnectionStringLoginApi(ApiKind.MongoDB)).toBe(false);
expect(isDirectConnectionStringLoginApi(ApiKind.MongoDBCompute)).toBe(false);
expect(isDirectConnectionStringLoginApi(ApiKind.Cassandra)).toBe(false);
});
});
+9 -3
View File
@@ -40,8 +40,14 @@ export function getDatabaseAccountKindFromExperience(apiExperience: typeof userC
return AccountKind.GlobalDocumentDB;
}
export function extractMasterKeyfromConnectionString(connectionString: string): string | undefined {
// Only Gremlin uses the actual master key for connection to cosmos
const matchedParts = connectionString.match("AccountKey=(.*);ApiKind=Gremlin;$");
// Returns the master key carried by SQL, Table, and Gremlin connection strings.
export function extractMasterKeyFromDirectLoginConnectionString(connectionString: string): string | undefined {
const matchedParts = connectionString?.match(/AccountKey=([^;]*)/);
return (matchedParts && matchedParts.length > 1 && matchedParts[1]) || undefined;
}
// SQL, Table, and Gremlin can sign data-plane requests client-side with the account key, so they do
// not need the Portal Backend proxy for connection-string login. Mongo and Cassandra still require the proxy.
export function isDirectConnectionStringLoginApi(apiKind: ApiKind): boolean {
return apiKind === ApiKind.SQL || apiKind === ApiKind.Table || apiKind === ApiKind.Graph;
}
+18 -7
View File
@@ -467,13 +467,24 @@ function configureHostedWithConnectionString(config: ConnectionString): Explorer
properties: getDatabaseAccountPropertiesFromMetadata(config.encryptedTokenMetadata),
tags: {},
};
updateUserContext({
// For legacy reasons lots of code expects a connection string login to look and act like an encrypted token login
authType: AuthType.EncryptedToken,
accessToken: encodeURIComponent(config.encryptedToken),
databaseAccount,
masterKey: config.masterKey,
});
if (config.masterKey && !config.encryptedToken) {
// Direct client-side signing path (SQL, Table, Gremlin). Requests are signed locally with the
// account key via the Cosmos client's tokenProvider, so no Portal Backend proxy or encrypted token
// is required.
updateUserContext({
authType: AuthType.ConnectionString,
databaseAccount,
masterKey: config.masterKey,
});
} else {
// Legacy encrypted-token proxy path (Mongo, Cassandra).
updateUserContext({
authType: AuthType.EncryptedToken,
accessToken: encodeURIComponent(config.encryptedToken),
databaseAccount,
masterKey: config.masterKey,
});
}
const explorer = new Explorer();
return explorer;
}
-30
View File
@@ -1,30 +0,0 @@
import { useEffect, useState } from "react";
import { HttpHeaders } from "../Common/Constants";
import { configContext } from "../ConfigContext";
import { AccessInputMetadata } from "../Contracts/DataModels";
export async function fetchAccessData(portalToken: string): Promise<AccessInputMetadata> {
const headers = new Headers();
// Portal encrypted token API quirk: The token header must be URL encoded
headers.append(HttpHeaders.guestAccessToken, encodeURIComponent(portalToken));
const url: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/connectionstring/runtimeproxy/accessinputmetadata`;
const options = {
method: "GET",
headers: headers,
};
return fetch(url, options)
.then((response) => response.json())
.catch((error) => console.error(error));
}
export function useTokenMetadata(token: string): AccessInputMetadata | undefined {
const [state, setState] = useState<AccessInputMetadata | undefined>();
useEffect(() => {
if (token) {
fetchAccessData(token).then((response) => setState(response));
}
}, [token]);
return state;
}
+55 -2
View File
@@ -1,8 +1,15 @@
import { DefaultAzureCredential } from "@azure/identity";
import { Frame, Locator, Page, expect } from "@playwright/test";
import crypto from "crypto";
import crypto, { webcrypto } from "crypto";
import { TestContainerContext } from "./testData";
// The @azure/cosmos client signs requests with globalThis.crypto (Web Crypto API).
// In Node.js >= 19 it's already available; only assign the polyfill for older versions.
// This lives in fx.ts (imported by every spec) so the polyfill always runs.
if (!globalThis.crypto) {
Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true });
}
const RETRY_COUNT = 3;
export interface TestNameOptions {
@@ -41,6 +48,14 @@ export enum TestAccount {
SQL = "SQL",
SQLReadOnly = "SQLReadOnly",
SQLContainerCopyOnly = "SQLContainerCopyOnly",
SQLConnectionString = "SQLConnectionString",
TableConnectionString = "TableConnectionString",
GremlinConnectionString = "GremlinConnectionString",
}
export enum TestAuthType {
EntraID = "EntraID",
ConnectionString = "ConnectionString",
}
export function getDefaultAccountName(accountType: TestAccount): string {
@@ -66,6 +81,12 @@ export function getDefaultAccountName(accountType: TestAccount): string {
return `${accountNamePrefix}-de-test-sql-readonly`;
case TestAccount.SQLContainerCopyOnly:
return `${accountNamePrefix}-de-test-sql-containercopy`;
case TestAccount.SQLConnectionString:
return `${accountNamePrefix}-de-test-sql-connstring-1`;
case TestAccount.TableConnectionString:
return `${accountNamePrefix}-de-test-table-connstring-1`;
case TestAccount.GremlinConnectionString:
return `${accountNamePrefix}-de-test-gremlin-connstring-1`;
case TestAccount.SQL: {
const shardIndex = process.env.PLAYWRIGHT_SHARD_INDEX ?? "";
if (!shardIndex) {
@@ -96,7 +117,32 @@ function tryGetStandardName(accountType: TestAccount) {
}
}
export function getAccountName(accountType: TestAccount) {
// Maps a base API account type to its dedicated connection string (account key) account.
const connectionStringAccountTypes: Partial<Record<TestAccount, TestAccount>> = {
[TestAccount.SQL]: TestAccount.SQLConnectionString,
[TestAccount.Tables]: TestAccount.TableConnectionString,
[TestAccount.Gremlin]: TestAccount.GremlinConnectionString,
};
export function getAccountName(accountType: TestAccount, authType: TestAuthType = TestAuthType.EntraID): string {
// Connection string (account key) login uses dedicated *-connstring accounts that are only
// provisioned in CI (resolved via DE_ACCOUNT_PREFIX). Local runs use DE_TEST_ACCOUNT_PREFIX and
// typically don't have those accounts, so they fall back to the standard API account for the same
// API (which also has key auth enabled).
if (authType === TestAuthType.ConnectionString) {
const connectionStringType = connectionStringAccountTypes[accountType];
if (!connectionStringType) {
throw new Error(`No connection string account defined for account type ${accountType}`);
}
const override = process.env[`DE_TEST_ACCOUNT_NAME_${connectionStringType.toLocaleUpperCase()}`];
if (override) {
return override;
}
if (!process.env.DE_TEST_ACCOUNT_PREFIX) {
return getAccountName(connectionStringType);
}
}
return (
process.env[`DE_TEST_ACCOUNT_NAME_${accountType.toLocaleUpperCase()}`] ??
tryGetStandardName(accountType) ??
@@ -210,6 +256,13 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
params.set("enableaaddataplane", "true");
}
break;
case TestAccount.SQLConnectionString:
case TestAccount.TableConnectionString:
case TestAccount.GremlinConnectionString:
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
// use this iframe test-explorer URL or any RBAC/AAD data-plane token.
break;
}
if (iframeSrc) {
@@ -0,0 +1,84 @@
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,
TestAuthType,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../fx";
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 () => {
const credentials = getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.Gremlin, TestAuthType.ConnectionString);
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 }) => {
const credentials = getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.Gremlin, TestAuthType.ConnectionString);
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);
});
});
+118
View File
@@ -0,0 +1,118 @@
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);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { CosmosDBManagementClient } from "@azure/arm-cosmosdb";
import { Container, CosmosClient } from "@azure/cosmos";
import {
DataExplorer,
ONE_MINUTE_MS,
TestAccount,
TestAuthType,
generateUniqueName,
getAccountName,
getAzureCLICredentials,
resourceGroupName,
subscriptionId,
} from "../fx";
// Table 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 () => {
const credentials = getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.Tables, TestAuthType.ConnectionString);
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 }) => {
const credentials = getAzureCLICredentials();
const armClient = new CosmosDBManagementClient(credentials, subscriptionId);
const accountName = getAccountName(TestAccount.Tables, TestAuthType.ConnectionString);
const { connectionStrings = [] } = await armClient.databaseAccounts.listConnectionStrings(
resourceGroupName,
accountName,
);
// Table accounts 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();
});
});
-7
View File
@@ -9,7 +9,6 @@ import {
JSONObject,
} from "@azure/cosmos";
import { Buffer } from "node:buffer";
import { webcrypto } from "node:crypto";
import {
generateUniqueName,
getAccountName,
@@ -19,12 +18,6 @@ import {
TestAccount,
} from "./fx";
// In Node.js >= 19, globalThis.crypto is already available as a read-only getter.
// Only assign the polyfill for older versions.
if (!globalThis.crypto) {
Object.defineProperty(globalThis, "crypto", { value: webcrypto, writable: true, configurable: true });
}
export interface TestItem {
id: string;
partitionKey: string;
+2 -2
View File
@@ -25,6 +25,7 @@
"./src/Common/ObjectCache.ts",
"./src/Common/OfferUtility.test.ts",
"./src/Common/OfferUtility.ts",
"./src/Common/PortalBackendClient.ts",
"./src/Common/Splitter.ts",
"./src/Common/ThemeUtility.ts",
"./src/Common/UrlUtility.ts",
@@ -107,7 +108,6 @@
"./src/hooks/useDirectories.tsx",
"./src/hooks/useGraphPhoto.tsx",
"./src/hooks/useNotebookSnapshotStore.ts",
"./src/hooks/usePortalAccessToken.tsx",
"./src/hooks/useNotificationConsole.ts",
"./src/hooks/useObservable.ts",
"./src/hooks/useSidePanel.ts",
@@ -137,4 +137,4 @@
"src/Shared/Telemetry/**/*",
"src/Utils/arm/**/*"
]
}
}