diff --git a/src/Common/Constants.ts b/src/Common/Constants.ts index eb1cb44c8..c4d0e1cd5 100644 --- a/src/Common/Constants.ts +++ b/src/Common/Constants.ts @@ -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 { diff --git a/src/Common/CosmosClient.ts b/src/Common/CosmosClient.ts index 54444d09e..1bff3f80b 100644 --- a/src/Common/CosmosClient.ts +++ b/src/Common/CosmosClient.ts @@ -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, diff --git a/src/Common/MongoProxyClient.ts b/src/Common/MongoProxyClient.ts index b4287e0ee..2e13b434e 100644 --- a/src/Common/MongoProxyClient.ts +++ b/src/Common/MongoProxyClient.ts @@ -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, diff --git a/src/Common/PortalBackendClient.ts b/src/Common/PortalBackendClient.ts new file mode 100644 index 000000000..e2e13afa3 --- /dev/null +++ b/src/Common/PortalBackendClient.ts @@ -0,0 +1,45 @@ +import { configContext } from "../ConfigContext"; +import { AccessInputMetadata } from "../Contracts/DataModels"; +import { HttpHeaders } from "./Constants"; + +export async function fetchAccessData(portalToken: string): Promise { + 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 { + 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 { + 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"; +} diff --git a/src/Explorer/Tables/TableDataClient.ts b/src/Explorer/Tables/TableDataClient.ts index ed9992af5..a88119adc 100644 --- a/src/Explorer/Tables/TableDataClient.ts +++ b/src/Explorer/Tables/TableDataClient.ts @@ -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); } diff --git a/src/HostedExplorer.test.tsx b/src/HostedExplorer.test.tsx index 22629c078..dda571e4f 100644 --- a/src/HostedExplorer.test.tsx +++ b/src/HostedExplorer.test.tsx @@ -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; @@ -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 () => { diff --git a/src/HostedExplorer.tsx b/src/HostedExplorer.tsx index 41863fb7d..ca28376d6 100644 --- a/src/HostedExplorer.tsx +++ b/src/HostedExplorer.tsx @@ -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(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(); + + 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 Cosmos DB - {(isLoggedIn || encryptedTokenMetadata?.accountName) && ( + {(isLoggedIn || accountMetadata?.accountName) && ( account separator )} {isLoggedIn && !connectionString && ( @@ -165,9 +187,9 @@ const App: React.FunctionComponent = () => { )} - {(!isLoggedIn || connectionString) && encryptedTokenMetadata?.accountName && ( + {(!isLoggedIn || connectionString) && accountMetadata?.accountName && ( - {encryptedTokenMetadata?.accountName} + {accountMetadata?.accountName} )} @@ -188,9 +210,7 @@ const App: React.FunctionComponent = () => { // It's possible this can be changed once all knockout code has been removed. )} - {!isLoggedIn && !encryptedTokenMetadata && ( - + {!isLoggedIn && !accountMetadata && ( + )} {isLoggedIn && authFailure && } {isLoggedIn && !authFailure && ( diff --git a/src/HostedExplorerChildFrame.ts b/src/HostedExplorerChildFrame.ts index 2cff6c862..3a38170ac 100644 --- a/src/HostedExplorerChildFrame.ts +++ b/src/HostedExplorerChildFrame.ts @@ -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; } diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 9b1299b06..f0301eb47 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -1175,4 +1175,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx index dc54a5f2a..a98a89ccc 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.test.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.test.tsx @@ -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(); + render( + , + ); 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(); + render( + , + ); expect(screen.queryByPlaceholderText("Connect to your account with connection string")).toBeNull(); updateUserContext({ features: oldFeatures }); diff --git a/src/Platform/Hosted/Components/ConnectExplorer.tsx b/src/Platform/Hosted/Components/ConnectExplorer.tsx index 768fddbb6..99ce638cd 100644 --- a/src/Platform/Hosted/Components/ConnectExplorer.tsx +++ b/src/Platform/Hosted/Components/ConnectExplorer.tsx @@ -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 => { - 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 => { - 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 = ({ 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 = ({ 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 = ({ 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); diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts index 93d76855c..823ebb115 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.test.ts @@ -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", () => { diff --git a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts index 6c8db5f96..44be7e82a 100644 --- a/src/Platform/Hosted/Helpers/ConnectionStringParser.ts +++ b/src/Platform/Hosted/Helpers/ConnectionStringParser.ts @@ -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; diff --git a/src/Platform/Hosted/HostedUtils.test.ts b/src/Platform/Hosted/HostedUtils.test.ts index 1b8ddd666..d199aba0f 100644 --- a/src/Platform/Hosted/HostedUtils.test.ts +++ b/src/Platform/Hosted/HostedUtils.test.ts @@ -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); + }); +}); diff --git a/src/Platform/Hosted/HostedUtils.ts b/src/Platform/Hosted/HostedUtils.ts index f39e318b1..32cc2e452 100644 --- a/src/Platform/Hosted/HostedUtils.ts +++ b/src/Platform/Hosted/HostedUtils.ts @@ -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; +} diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index d4a4f9555..41bec9797 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -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; } diff --git a/src/hooks/usePortalAccessToken.tsx b/src/hooks/usePortalAccessToken.tsx deleted file mode 100644 index bf59dc63f..000000000 --- a/src/hooks/usePortalAccessToken.tsx +++ /dev/null @@ -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 { - 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(); - - useEffect(() => { - if (token) { - fetchAccessData(token).then((response) => setState(response)); - } - }, [token]); - return state; -} diff --git a/test/fx.ts b/test/fx.ts index 880388a33..a3cad859a 100644 --- a/test/fx.ts +++ b/test/fx.ts @@ -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> = { + [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) { diff --git a/test/gremlin/connectionStringLogin.spec.ts b/test/gremlin/connectionStringLogin.spec.ts new file mode 100644 index 000000000..50289d867 --- /dev/null +++ b/test/gremlin/connectionStringLogin.spec.ts @@ -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); + }); +}); diff --git a/test/sql/connectionStringLogin.spec.ts b/test/sql/connectionStringLogin.spec.ts new file mode 100644 index 000000000..8359e6dee --- /dev/null +++ b/test/sql/connectionStringLogin.spec.ts @@ -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 { + 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); + }); +}); diff --git a/test/tables/connectionStringLogin.spec.ts b/test/tables/connectionStringLogin.spec.ts new file mode 100644 index 000000000..02db382a2 --- /dev/null +++ b/test/tables/connectionStringLogin.spec.ts @@ -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(); + }); +}); diff --git a/test/testData.ts b/test/testData.ts index e9ba759f1..57fbfe5a7 100644 --- a/test/testData.ts +++ b/test/testData.ts @@ -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; diff --git a/tsconfig.strict.json b/tsconfig.strict.json index e1021f162..947b073e5 100644 --- a/tsconfig.strict.json +++ b/tsconfig.strict.json @@ -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/**/*" ] -} \ No newline at end of file +}