mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-09-22 19:22:25 +01:00
Merge branch 'master' of https://github.com/Azure/cosmos-explorer into users/jawelton/remove-phoenix-phase6-082726
This commit is contained in:
@@ -1 +1 @@
|
|||||||
[Preview this branch](https://dataexplorer-preview.azurewebsites.net/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)
|
[Preview this branch](https://dataexplorer-preview.portal.cosmos.azure.com/pull/EDIT_THIS_NUMBER_IN_THE_PR_DESCRIPTION?feature.someFeatureFlagYouMightNeed=true)
|
||||||
|
|||||||
@@ -7,3 +7,12 @@ updates:
|
|||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns: ["*"]
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
cooldown:
|
||||||
|
default-days: 7
|
||||||
|
|||||||
+27
-27
@@ -19,9 +19,9 @@ jobs:
|
|||||||
name: "Log Code Metrics"
|
name: "Log Code Metrics"
|
||||||
if: github.ref == 'refs/heads/master'
|
if: github.ref == 'refs/heads/master'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
@@ -32,9 +32,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Compile TypeScript"
|
name: "Compile TypeScript"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
@@ -44,9 +44,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Check Format"
|
name: "Check Format"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
@@ -55,9 +55,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Lint"
|
name: "Lint"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
@@ -66,9 +66,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Unit Tests"
|
name: "Unit Tests"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
@@ -77,15 +77,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Build"
|
name: "Build"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build:contracts
|
- run: npm run build:contracts
|
||||||
- name: Restore Build Cache
|
- name: Restore Build Cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||||
with:
|
with:
|
||||||
path: .cache
|
path: .cache
|
||||||
key: ${{ runner.os }}-build-cache
|
key: ${{ runner.os }}-build-cache
|
||||||
@@ -94,12 +94,12 @@ jobs:
|
|||||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||||
- run: cp -r ./Contracts ./dist/contracts
|
- run: cp -r ./Contracts ./dist/contracts
|
||||||
- run: cp -r ./configs ./dist/configs
|
- run: cp -r ./configs ./dist/configs
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
path: dist/
|
path: dist/
|
||||||
- name: "Az CLI login"
|
- name: "Az CLI login"
|
||||||
uses: azure/login@v1
|
uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download Dist Folder
|
- name: Download Dist Folder
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
- run: cp ./configs/prod.json config.json
|
- run: cp ./configs/prod.json config.json
|
||||||
@@ -126,7 +126,7 @@ jobs:
|
|||||||
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
||||||
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
||||||
- run: dotnet nuget remove source "ADO"
|
- run: dotnet nuget remove source "ADO"
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
name: Upload package to Artifacts
|
name: Upload package to Artifacts
|
||||||
with:
|
with:
|
||||||
name: prod-package
|
name: prod-package
|
||||||
@@ -142,7 +142,7 @@ jobs:
|
|||||||
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download Dist Folder
|
- name: Download Dist Folder
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
name: dist
|
name: dist
|
||||||
- run: cp ./configs/mpac.json config.json
|
- run: cp ./configs/mpac.json config.json
|
||||||
@@ -151,7 +151,7 @@ jobs:
|
|||||||
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
- run: dotnet pack DataExplorer.proj /p:PackageVersion="2.0.0-github-${GITHUB_SHA}"
|
||||||
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
- run: dotnet nuget push "bin/Release/*.nupkg" --skip-duplicate --api-key Az --source="$NUGET_SOURCE"
|
||||||
- run: dotnet nuget remove source "ADO"
|
- run: dotnet nuget remove source "ADO"
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
name: Upload package to Artifacts
|
name: Upload package to Artifacts
|
||||||
with:
|
with:
|
||||||
name: mpac-package
|
name: mpac-package
|
||||||
@@ -172,22 +172,22 @@ jobs:
|
|||||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
|
||||||
shardTotal: [20]
|
shardTotal: [20]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps
|
- run: npx playwright install --with-deps
|
||||||
- name: "Az CLI login"
|
- name: "Az CLI login"
|
||||||
uses: Azure/login@v2
|
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||||
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||||
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
|
# We can't use MSAL within playwright so we acquire tokens prior to running the tests
|
||||||
- name: "Acquire RBAC tokens for test accounts"
|
- name: "Acquire RBAC tokens for test accounts"
|
||||||
uses: azure/cli@v2
|
uses: azure/cli@9f7ce6f37c31b777ec6c6b6d1dfe7db79f497956 # v2.2.0
|
||||||
env:
|
env:
|
||||||
DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }}
|
DE_ACCOUNT_PREFIX: ${{ secrets.E2ETESTS_ACCOUNT_PREFIX }}
|
||||||
DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
DE_TEST_RESOURCE_GROUP: ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
||||||
@@ -231,7 +231,7 @@ jobs:
|
|||||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --workers=3
|
||||||
- name: "Re-auth for upload (refresh OIDC token)"
|
- name: "Re-auth for upload (refresh OIDC token)"
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
uses: Azure/login@v2
|
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||||
@@ -262,15 +262,15 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: "Az CLI login"
|
- name: "Az CLI login"
|
||||||
uses: Azure/login@v2
|
uses: Azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||||
|
|||||||
@@ -23,16 +23,16 @@ jobs:
|
|||||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
AZURE_SUBSCRIPTION_ID: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||||
E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
E2ETESTS_RESOURCEGROUP_NAME : ${{ secrets.E2ETESTS_RESOURCEGROUP_NAME }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||||
- name: "Az CLI login"
|
- name: "Az CLI login"
|
||||||
uses: azure/login@v2
|
uses: azure/login@7184910d9eb2b1c5e48f7073824a90609bb9b6d6 # v2.3.1
|
||||||
with:
|
with:
|
||||||
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
client-id: ${{ secrets.E2ETESTS_CLIENT_ID }}
|
||||||
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
tenant-id: ${{ secrets.E2ETESTS_TENANT_ID }}
|
||||||
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
subscription-id: ${{ secrets.E2ETESTS_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
- name: Use Node.js 22.x
|
- name: Use Node.js 22.x
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
|||||||
Generated
+9
-9
@@ -2697,9 +2697,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -13020,9 +13020,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/js-yaml": {
|
"node_modules/eslint/node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -13493,9 +13493,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
group = dataexplorer-preview
|
|
||||||
sku = P1v2
|
|
||||||
appserviceplan = dataexplorer-preview
|
|
||||||
location = westus2
|
|
||||||
web = dataexplorer-preview
|
|
||||||
|
|
||||||
+2
-2
@@ -4,8 +4,8 @@ Cosmos Explorer Preview makes it possible to try a working version of any commit
|
|||||||
|
|
||||||
Initial support is for Hosted (Connection string only) or the Azure Portal. Examples:
|
Initial support is for Hosted (Connection string only) or the Azure Portal. Examples:
|
||||||
|
|
||||||
Connection string URLs: https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/hostedExplorer.html
|
Connection string URLs: https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/hostedExplorer.html
|
||||||
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.azurewebsites.net/commit/COMMIT_SHA/explorer.html#home
|
Portal URLs: https://ms.portal.azure.com/?dataExplorerSource=https://dataexplorer-preview.portal.cosmos.azure.com/commit/COMMIT_SHA/explorer.html#home
|
||||||
|
|
||||||
In both cases replace `COMMIT_SHA` with the commit you want to view. It must have already completed its build on GitHub Actions.
|
In both cases replace `COMMIT_SHA` with the commit you want to view. It must have already completed its build on GitHub Actions.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"PROXY_PATH": "/proxy",
|
"PROXY_PATH": "/proxy",
|
||||||
"msalRedirectURI": "https://dataexplorer-preview.azurewebsites.net/"
|
"msalRedirectURI": "https://dataexplorer-preview.portal.cosmos.azure.com/"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ const port = process.env.PORT || 3000;
|
|||||||
const fetch = require("node-fetch");
|
const fetch = require("node-fetch");
|
||||||
|
|
||||||
const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
const backendEndpoint = "https://cdb-ms-mpac-pbe.cosmos.azure.com";
|
||||||
const previewSiteEndpoint = "https://dataexplorer-preview.azurewebsites.net";
|
const previewSiteEndpoint = "https://dataexplorer-preview.portal.cosmos.azure.com";
|
||||||
const previewStorageWebsiteEndpoint = "_REPLACE_STORAGE_WEBSITE_ENDPOINT_";
|
const previewStorageWebsiteEndpoint = "_REPLACE_STORAGE_WEBSITE_ENDPOINT_";
|
||||||
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
|
const githubApiUrl = "https://api.github.com/repos/Azure/cosmos-explorer";
|
||||||
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
const azurePortalMpacEndpoint = "https://ms.portal.azure.com/";
|
||||||
|
|||||||
@@ -1,14 +1,3 @@
|
|||||||
export class EndpointsRegex {
|
|
||||||
public static readonly cassandra = [
|
|
||||||
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
|
|
||||||
"HostName=(.*).cassandra.cosmos.azure.com",
|
|
||||||
];
|
|
||||||
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.cosmos(?:db)?.azure.com";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApiEndpoints {
|
export class ApiEndpoints {
|
||||||
public static runtimeProxy: string = "/api/RuntimeProxy";
|
public static runtimeProxy: string = "/api/RuntimeProxy";
|
||||||
public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy";
|
public static guestRuntimeProxy: string = "/api/guest/RuntimeProxy";
|
||||||
|
|||||||
+34
-1
@@ -29,6 +29,13 @@ export interface ConfigContext {
|
|||||||
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
allowedCassandraProxyEndpoints: ReadonlyArray<string>;
|
||||||
allowedMongoProxyEndpoints: ReadonlyArray<string>;
|
allowedMongoProxyEndpoints: ReadonlyArray<string>;
|
||||||
allowedParentFrameOrigins: ReadonlyArray<string>;
|
allowedParentFrameOrigins: ReadonlyArray<string>;
|
||||||
|
SQL_DNS_ZONES: ReadonlyArray<string>;
|
||||||
|
MONGO_DNS_ZONES: ReadonlyArray<string>;
|
||||||
|
MONGO_COMPUTE_DNS_ZONES: ReadonlyArray<string>;
|
||||||
|
CASSANDRA_DNS_ZONES: ReadonlyArray<string>;
|
||||||
|
TABLE_DNS_ZONES: ReadonlyArray<string>;
|
||||||
|
DOCUMENT_ENDPOINT_ZONES: ReadonlyArray<string>;
|
||||||
|
GREMLIN_ENDPOINT_ZONES: ReadonlyArray<string>;
|
||||||
gitSha?: string;
|
gitSha?: string;
|
||||||
proxyPath?: string;
|
proxyPath?: string;
|
||||||
AAD_ENDPOINT: string;
|
AAD_ENDPOINT: string;
|
||||||
@@ -70,7 +77,7 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
|
`^https:\\/\\/cosmos-db-dataexplorer-germanycentral\\.azurewebsites\\.de$`,
|
||||||
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
`^https:\\/\\/.*\\.fabric\\.microsoft\\.com$`,
|
||||||
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
`^https:\\/\\/.*\\.powerbi\\.com$`,
|
||||||
`^https:\\/\\/dataexplorer-preview\\.azurewebsites\\.net$`,
|
`^https:\\/\\/dataexplorer-preview\\.portal\\.cosmos\\.azure\\.com$`,
|
||||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`,
|
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.fr$`,
|
||||||
`^https:\\/\\/portal\\.sovcloud-azure\\.fr$`,
|
`^https:\\/\\/portal\\.sovcloud-azure\\.fr$`,
|
||||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`,
|
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.de$`,
|
||||||
@@ -78,6 +85,32 @@ let configContext: Readonly<ConfigContext> = {
|
|||||||
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
|
`^https:\\/\\/explorer\\.cosmos\\.sovcloud-api\\.sg$`,
|
||||||
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
|
`^https:\\/\\/portal\\.sovcloud-azure\\.sg$`,
|
||||||
], // Webpack injects this at build time
|
], // Webpack injects this at build time
|
||||||
|
SQL_DNS_ZONES: [
|
||||||
|
"documents.azure.com",
|
||||||
|
"sql.cosmosdb.azure.com",
|
||||||
|
"sql.cosmos.azure.com",
|
||||||
|
"sqlx.cosmos.azure.com",
|
||||||
|
"documents-staging.windows-ppe.net",
|
||||||
|
"sql.cosmosdb.windows-ppe.net",
|
||||||
|
"sql.cosmos.windows-ppe.net",
|
||||||
|
"sqlx.cosmos.windows-ppe.net",
|
||||||
|
],
|
||||||
|
MONGO_DNS_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
|
||||||
|
MONGO_COMPUTE_DNS_ZONES: ["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"],
|
||||||
|
CASSANDRA_DNS_ZONES: [
|
||||||
|
"cassandra.cosmosdb.azure.com",
|
||||||
|
"cassandra.cosmos.azure.com",
|
||||||
|
"cassandra.cosmosdb.windows-ppe.net",
|
||||||
|
"cassandra.cosmos.windows-ppe.net",
|
||||||
|
],
|
||||||
|
TABLE_DNS_ZONES: [
|
||||||
|
"table.cosmosdb.azure.com",
|
||||||
|
"table.cosmos.azure.com",
|
||||||
|
"table.cosmosdb.windows-ppe.net",
|
||||||
|
"table.cosmos.windows-ppe.net",
|
||||||
|
],
|
||||||
|
DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com", "documents-staging.windows-ppe.net"],
|
||||||
|
GREMLIN_ENDPOINT_ZONES: ["gremlin.cosmos.azure.com", "gremlin.cosmos.windows-ppe.net"],
|
||||||
gitSha: process.env.GIT_SHA,
|
gitSha: process.env.GIT_SHA,
|
||||||
hostedExplorerURL: "https://cosmos.azure.com/",
|
hostedExplorerURL: "https://cosmos.azure.com/",
|
||||||
AAD_ENDPOINT: "https://login.microsoftonline.com/",
|
AAD_ENDPOINT: "https://login.microsoftonline.com/",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { initializeIcons } from "@fluentui/react";
|
import { initializeIcons } from "@fluentui/react";
|
||||||
import { useBoolean } from "@fluentui/react-hooks";
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
|
||||||
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
|
import { AadAuthorizationFailure } from "Platform/Hosted/Components/AadAuthorizationFailure";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { render } from "react-dom";
|
import { render } from "react-dom";
|
||||||
@@ -83,9 +82,9 @@ const App: React.FunctionComponent = () => {
|
|||||||
setEncryptedToken(token);
|
setEncryptedToken(token);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(async (error) => {
|
||||||
logError(
|
logError(
|
||||||
`Failed to connect with connection string: ${getErrorMessage(error)}`,
|
`Failed to connect with connection string: ${await (error as Response).text()}`,
|
||||||
"HostedExplorer/connectWithConnectionString",
|
"HostedExplorer/connectWithConnectionString",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
jest.mock("../../../hooks/useDirectories");
|
jest.mock("../../../hooks/useDirectories");
|
||||||
|
jest.mock("../../../Common/PortalBackendClient");
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { extractFeatures } from "Platform/Hosted/extractFeatures";
|
import { extractFeatures } from "Platform/Hosted/extractFeatures";
|
||||||
import { updateUserContext, userContext } from "UserContext";
|
import { updateUserContext, userContext } from "UserContext";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
||||||
import { ConnectExplorer } from "./ConnectExplorer";
|
import { ConnectExplorer } from "./ConnectExplorer";
|
||||||
|
|
||||||
|
const mockFetchEncryptedToken = fetchEncryptedToken as jest.MockedFunction<typeof fetchEncryptedToken>;
|
||||||
|
const mockIsAccountRestricted = isAccountRestrictedForConnectionStringLogin as jest.MockedFunction<
|
||||||
|
typeof isAccountRestrictedForConnectionStringLogin
|
||||||
|
>;
|
||||||
|
|
||||||
|
// fetchEncryptedToken rejects with the raw Response.
|
||||||
|
const rejectWithResponse = (status: number, body: string) =>
|
||||||
|
mockFetchEncryptedToken.mockRejectedValue({ status, text: async () => body } as Response);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
mockIsAccountRestricted.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the connect form", () => {
|
it("shows the connect form", () => {
|
||||||
const connectionString = "fakeConnectionString";
|
const connectionString = "fakeConnectionString";
|
||||||
const login = jest.fn();
|
const login = jest.fn();
|
||||||
@@ -63,3 +79,159 @@ it("hides the connection string link when feature.disableConnectionStringLogin i
|
|||||||
|
|
||||||
updateUserContext({ features: oldFeatures });
|
updateUserContext({ features: oldFeatures });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an unrecognized connection string before token exchange", async () => {
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: "not-a-valid-connection-string",
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockIsAccountRestricted).toHaveBeenCalledWith("not-a-valid-connection-string");
|
||||||
|
expect(mockFetchEncryptedToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows that a connection is in progress", async () => {
|
||||||
|
let finishRestrictionCheck: (restricted: boolean) => void = () => undefined;
|
||||||
|
mockIsAccountRestricted.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishRestrictionCheck = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: "AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=some-key;",
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
const connectButton = screen.getByDisplayValue("Connecting...");
|
||||||
|
expect(connectButton).toBeDisabled();
|
||||||
|
expect(connectButton.closest("form")).toHaveAttribute("aria-busy", "true");
|
||||||
|
|
||||||
|
finishRestrictionCheck(false);
|
||||||
|
expect(await screen.findByDisplayValue("Connect")).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the error when the Portal Backend rejects the connection string", async () => {
|
||||||
|
// Mongo and Cassandra are the APIs that still exchange the connection string for an encrypted token.
|
||||||
|
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
|
||||||
|
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: mongoConnectionString,
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
"Couldn't authenticate with Cosmos DB: Request originated from IP 1.2.3.4 through public internet.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a generic error when the Portal Backend fails without a message", async () => {
|
||||||
|
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
|
||||||
|
// A failure with an empty body leaves nothing worth rendering.
|
||||||
|
rejectWithResponse(500, "");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: mongoConnectionString,
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Failed to connect to the account. Please check the connection string and try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the firewall help link when the Portal Backend is blocked by the account firewall", async () => {
|
||||||
|
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
|
||||||
|
rejectWithResponse(403, "Request originated from IP 1.2.3.4 through public internet.");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: mongoConnectionString,
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Allow access from Azure Portal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not offer the firewall help link for a failure the firewall did not cause", async () => {
|
||||||
|
const mongoConnectionString = "mongodb://test:key@test.documents.azure.com:10255";
|
||||||
|
rejectWithResponse(401, "The connection string is invalid.");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConnectExplorer
|
||||||
|
{...{
|
||||||
|
login: jest.fn(),
|
||||||
|
setEncryptedToken: jest.fn(),
|
||||||
|
setAuthType: jest.fn(),
|
||||||
|
connectionString: mongoConnectionString,
|
||||||
|
setConnectionString: jest.fn(),
|
||||||
|
setAccountMetadata: jest.fn(),
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText("Connect to your account with connection string"));
|
||||||
|
fireEvent.click(screen.getByDisplayValue("Connect"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Couldn't authenticate with Cosmos DB: The connection string is invalid."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Allow access from Azure Portal")).toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { FluentProvider, Link, MessageBar, MessageBarBody, webLightTheme } from "@fluentui/react-components";
|
||||||
import { useBoolean } from "@fluentui/react-hooks";
|
import { useBoolean } from "@fluentui/react-hooks";
|
||||||
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
import { getErrorMessage } from "Common/ErrorHandlingUtils";
|
||||||
import { userContext } from "UserContext";
|
import { userContext } from "UserContext";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
|
import ConnectImage from "../../../../images/HdeConnectCosmosDB.svg";
|
||||||
import ErrorImage from "../../../../images/error.svg";
|
|
||||||
import { AuthType } from "../../../AuthType";
|
import { AuthType } from "../../../AuthType";
|
||||||
|
import { HttpStatusCodes } from "../../../Common/Constants";
|
||||||
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
import { fetchEncryptedToken, isAccountRestrictedForConnectionStringLogin } from "../../../Common/PortalBackendClient";
|
||||||
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
import { AccessInputMetadata } from "../../../Contracts/DataModels";
|
||||||
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
import { parseConnectionString } from "../Helpers/ConnectionStringParser";
|
||||||
@@ -30,6 +31,8 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
const [isFormVisible, { setTrue: showForm }] = useBoolean(false);
|
||||||
const [errorMessage, setErrorMessage] = React.useState("");
|
const [errorMessage, setErrorMessage] = React.useState("");
|
||||||
|
const [isBlockedByFirewall, setIsBlockedByFirewall] = React.useState(false);
|
||||||
|
const [isConnecting, setIsConnecting] = React.useState(false);
|
||||||
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
const enableConnectionStringLogin = !userContext.features.disableConnectionStringLogin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,39 +46,70 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
{isFormVisible && enableConnectionStringLogin ? (
|
{isFormVisible && enableConnectionStringLogin ? (
|
||||||
<form
|
<form
|
||||||
id="connectWithConnectionString"
|
id="connectWithConnectionString"
|
||||||
|
aria-busy={isConnecting}
|
||||||
onSubmit={async (event) => {
|
onSubmit={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (isConnecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setErrorMessage("");
|
setErrorMessage("");
|
||||||
|
setIsBlockedByFirewall(false);
|
||||||
|
setIsConnecting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
if (await isAccountRestrictedForConnectionStringLogin(connectionString)) {
|
||||||
setErrorMessage(
|
setErrorMessage(
|
||||||
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
|
"This account has been blocked from connection-string login. Please go to cosmos.azure.com/aad for AAD based login.",
|
||||||
);
|
);
|
||||||
|
setIsConnecting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorMessage(getErrorMessage(error));
|
setErrorMessage(getErrorMessage(error as Error));
|
||||||
|
setIsConnecting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (isResourceTokenConnectionString(connectionString)) {
|
if (isResourceTokenConnectionString(connectionString)) {
|
||||||
setAuthType(AuthType.ResourceToken);
|
setAuthType(AuthType.ResourceToken);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = parseConnectionString(connectionString);
|
const metadata = parseConnectionString(connectionString);
|
||||||
if (metadata && isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
if (!metadata) {
|
||||||
// SQL, Table, and Gremlin sign data-plane requests client-side with the account key, so
|
setErrorMessage(
|
||||||
// we skip the Portal Backend proxy and use the metadata parsed from the connection string.
|
"We couldn't recognize this connection string. Verify that it is a valid Azure Cosmos DB connection string and try again.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectConnectionStringLoginApi(metadata.apiKind)) {
|
||||||
setAccountMetadata(metadata);
|
setAccountMetadata(metadata);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mongo and Cassandra go through the Portal Backend
|
||||||
const encryptedToken = await fetchEncryptedToken(connectionString);
|
const encryptedToken = await fetchEncryptedToken(connectionString);
|
||||||
setEncryptedToken(encryptedToken);
|
setEncryptedToken(encryptedToken);
|
||||||
setAuthType(AuthType.ConnectionString);
|
setAuthType(AuthType.ConnectionString);
|
||||||
|
} catch (error) {
|
||||||
|
const errorDetails = await (error as Response).text();
|
||||||
|
|
||||||
|
setErrorMessage(
|
||||||
|
errorDetails
|
||||||
|
? `Couldn't authenticate with Cosmos DB: ${errorDetails}`
|
||||||
|
: "Failed to connect to the account. Please check the connection string and try again.",
|
||||||
|
);
|
||||||
|
// A Forbidden usually means the account firewall dropped the request. The connection
|
||||||
|
// string is exchanged by the Portal Backend rather than the browser, so the account has
|
||||||
|
// to allowlist those services.
|
||||||
|
setIsBlockedByFirewall((error as Response).status === HttpStatusCodes.Forbidden);
|
||||||
|
} finally {
|
||||||
|
setIsConnecting(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
|
<p className="connectExplorerContent connectStringText">Connect to your account with connection string</p>
|
||||||
@@ -90,15 +124,33 @@ export const ConnectExplorer: React.FunctionComponent<Props> = ({
|
|||||||
setConnectionString(event.target.value);
|
setConnectionString(event.target.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{errorMessage.length > 0 && (
|
|
||||||
<span className="errorDetailsInfoTooltip">
|
|
||||||
<img className="errorImg" src={ErrorImage} alt="Error notification" />
|
|
||||||
<span className="errorDetails">{errorMessage}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
{errorMessage.length > 0 && (
|
||||||
|
<FluentProvider theme={webLightTheme} className="connectErrorMessageBar">
|
||||||
|
<MessageBar intent="error" layout="multiline">
|
||||||
|
<MessageBarBody>
|
||||||
|
<span className="errorDetails">{errorMessage}</span>
|
||||||
|
{isBlockedByFirewall && (
|
||||||
|
<Link
|
||||||
|
className="errorHelpLink"
|
||||||
|
href="https://learn.microsoft.com/azure/cosmos-db/how-to-configure-firewall#allow-requests-from-the-azure-portal"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Allow access from Azure Portal
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</MessageBarBody>
|
||||||
|
</MessageBar>
|
||||||
|
</FluentProvider>
|
||||||
|
)}
|
||||||
<p className="connectExplorerContent">
|
<p className="connectExplorerContent">
|
||||||
<input className="filterbtnstyle" type="submit" value="Connect" />
|
<input
|
||||||
|
className="filterbtnstyle"
|
||||||
|
type="submit"
|
||||||
|
value={isConnecting ? "Connecting..." : "Connect"}
|
||||||
|
disabled={isConnecting}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p className="switchConnectTypeText" onClick={login}>
|
<p className="switchConnectTypeText" onClick={login}>
|
||||||
Sign In with Azure Account
|
Sign In with Azure Account
|
||||||
|
|||||||
@@ -55,41 +55,19 @@
|
|||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
|
.connectExplorerContainer .connectExplorer .connectExplorerContent .inputToken::placeholder {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip {
|
.connectExplorerContainer .connectExplorer .connectErrorMessageBar {
|
||||||
position: relative;
|
/* Matches the width of the connection string input so the form stays balanced. */
|
||||||
display: inline-block;
|
width: 308px;
|
||||||
padding-left: 4px;
|
margin: 0px auto 8px auto;
|
||||||
vertical-align: top;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip:hover .errorDetails {
|
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorDetails {
|
||||||
visibility: visible;
|
/* Service messages are diagnostics containing unbroken URIs and ids. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails {
|
.connectExplorerContainer .connectExplorer .connectErrorMessageBar .errorHelpLink {
|
||||||
bottom: 24px;
|
display: block;
|
||||||
width: 165px;
|
margin-top: 8px;
|
||||||
visibility: hidden;
|
|
||||||
background-color: #393939;
|
|
||||||
color: #ffffff;
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
left: -10px;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorDetails:after {
|
|
||||||
border-width: 10px 10px 0px 10px;
|
|
||||||
bottom: -8px;
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
right: 100%;
|
|
||||||
border-style: solid;
|
|
||||||
left: 12px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-color: #3b3b3b transparent;
|
|
||||||
}
|
|
||||||
.connectExplorerContainer .connectExplorer .connectExplorerContent .errorDetailsInfoTooltip .errorImg {
|
|
||||||
height: 14px;
|
|
||||||
width: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterbtnstyle {
|
.filterbtnstyle {
|
||||||
|
|||||||
@@ -1,19 +1,75 @@
|
|||||||
|
import { configContext, updateConfigContext } from "../../../ConfigContext";
|
||||||
import * as DataModels from "../../../Contracts/DataModels";
|
import * as DataModels from "../../../Contracts/DataModels";
|
||||||
import { parseConnectionString } from "./ConnectionStringParser";
|
import {
|
||||||
|
buildEndpointsRegex,
|
||||||
|
dnsZoneAlternation,
|
||||||
|
parseConnectionString,
|
||||||
|
selectEndpointZone,
|
||||||
|
} from "./ConnectionStringParser";
|
||||||
|
|
||||||
describe("ConnectionStringParser", () => {
|
describe("ConnectionStringParser", () => {
|
||||||
const mockAccountName = "Test";
|
const mockAccountName = "Test";
|
||||||
const mockMasterKey = "some-key";
|
const mockMasterKey = "some-key";
|
||||||
|
|
||||||
it("should parse a valid sql account connection string", () => {
|
// The shape of each api's connection string, parameterized by the dns zone the account sits in. What
|
||||||
const metadata = parseConnectionString(
|
// these tests are about is the zones, so keeping the shapes here stops them being restated once per zone.
|
||||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};`,
|
const buildConnectionString = {
|
||||||
);
|
sql: (zone: string) => `AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};`,
|
||||||
|
mongo: (zone: string) => `mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.${zone}:10255`,
|
||||||
|
// A cassandra connection string can name the account host under either AccountEndpoint or HostName.
|
||||||
|
cassandra: (zone: string, hostKey = "AccountEndpoint") =>
|
||||||
|
`${hostKey}=${mockAccountName}.${zone};AccountKey=${mockMasterKey};`,
|
||||||
|
table: (zone: string) =>
|
||||||
|
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.${zone}:443/;`,
|
||||||
|
graph: (zone: string) =>
|
||||||
|
`AccountEndpoint=https://${mockAccountName}.${zone}:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionStringsByApiKind: Record<DataModels.ApiKind, string> = {
|
||||||
|
[DataModels.ApiKind.SQL]: buildConnectionString.sql("documents.azure.com"),
|
||||||
|
[DataModels.ApiKind.MongoDB]: buildConnectionString.mongo("documents.azure.com"),
|
||||||
|
[DataModels.ApiKind.MongoDBCompute]: buildConnectionString.mongo("mongo.cosmos.azure.com"),
|
||||||
|
[DataModels.ApiKind.Cassandra]: buildConnectionString.cassandra("cassandra.cosmosdb.azure.com"),
|
||||||
|
[DataModels.ApiKind.Table]: buildConnectionString.table("table.cosmosdb.azure.com"),
|
||||||
|
[DataModels.ApiKind.Graph]: buildConnectionString.graph("documents.azure.com"),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should parse a connection string for every api kind", () => {
|
||||||
|
Object.entries(connectionStringsByApiKind).forEach(([apiKind, connectionString]) => {
|
||||||
|
const metadata = parseConnectionString(connectionString);
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
|
expect(metadata.apiKind).toBe(Number(apiKind));
|
||||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
});
|
||||||
expect(metadata.apiEndpoint).toBeUndefined();
|
});
|
||||||
|
|
||||||
|
// The parameterized tests below iterate the zone lists, so removing a zone would silently shrink the
|
||||||
|
// suite rather than fail it. Pin the expected contents so that stays visible in review.
|
||||||
|
it("should support the expected dns zones", () => {
|
||||||
|
expect(configContext.SQL_DNS_ZONES).toEqual([
|
||||||
|
"documents.azure.com",
|
||||||
|
"sql.cosmosdb.azure.com",
|
||||||
|
"sql.cosmos.azure.com",
|
||||||
|
"sqlx.cosmos.azure.com",
|
||||||
|
"documents-staging.windows-ppe.net",
|
||||||
|
"sql.cosmosdb.windows-ppe.net",
|
||||||
|
"sql.cosmos.windows-ppe.net",
|
||||||
|
"sqlx.cosmos.windows-ppe.net",
|
||||||
|
]);
|
||||||
|
expect(configContext.MONGO_DNS_ZONES).toEqual(["documents.azure.com", "documents-staging.windows-ppe.net"]);
|
||||||
|
expect(configContext.MONGO_COMPUTE_DNS_ZONES).toEqual(["mongo.cosmos.azure.com", "mongo.cosmos.windows-ppe.net"]);
|
||||||
|
expect(configContext.CASSANDRA_DNS_ZONES).toEqual([
|
||||||
|
"cassandra.cosmosdb.azure.com",
|
||||||
|
"cassandra.cosmos.azure.com",
|
||||||
|
"cassandra.cosmosdb.windows-ppe.net",
|
||||||
|
"cassandra.cosmos.windows-ppe.net",
|
||||||
|
]);
|
||||||
|
expect(configContext.TABLE_DNS_ZONES).toEqual([
|
||||||
|
"table.cosmosdb.azure.com",
|
||||||
|
"table.cosmos.azure.com",
|
||||||
|
"table.cosmosdb.windows-ppe.net",
|
||||||
|
"table.cosmos.windows-ppe.net",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep the document endpoint given by the connection string", () => {
|
it("should keep the document endpoint given by the connection string", () => {
|
||||||
@@ -26,28 +82,99 @@ describe("ConnectionStringParser", () => {
|
|||||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
|
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com/`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse a valid mongo account connection string", () => {
|
it.each(configContext.SQL_DNS_ZONES)(
|
||||||
const metadata = parseConnectionString(
|
"should parse a sql account connection string using the %s zone",
|
||||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.documents.azure.com:10255`,
|
(dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.sql(dnsZone));
|
||||||
|
|
||||||
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.SQL);
|
||||||
|
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.${dnsZone}:443/`);
|
||||||
|
expect(metadata.apiEndpoint).toBeUndefined();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(configContext.MONGO_DNS_ZONES)(
|
||||||
|
"should parse a mongo account connection string using the %s zone",
|
||||||
|
(dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDB);
|
||||||
});
|
},
|
||||||
|
|
||||||
it("should parse a valid compute mongo account connection string", () => {
|
|
||||||
const metadata = parseConnectionString(
|
|
||||||
`mongodb://${mockAccountName}:${mockMasterKey}@${mockAccountName}.mongo.cosmos.azure.com:10255`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each(configContext.MONGO_COMPUTE_DNS_ZONES)(
|
||||||
|
"should parse a compute mongo account connection string using the %s zone",
|
||||||
|
(dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.mongo(dnsZone));
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.MongoDBCompute);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(
|
||||||
|
["AccountEndpoint", "HostName"].flatMap((hostKey) =>
|
||||||
|
configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [hostKey, dnsZone]),
|
||||||
|
),
|
||||||
|
)(
|
||||||
|
"should parse a cassandra account connection string using %s and the %s zone",
|
||||||
|
(hostKey: string, dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.cassandra(dnsZone, hostKey));
|
||||||
|
|
||||||
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(configContext.TABLE_DNS_ZONES)(
|
||||||
|
"should parse a table account connection string using the %s zone",
|
||||||
|
(dnsZone: string) => {
|
||||||
|
const metadata = parseConnectionString(buildConnectionString.table(dnsZone));
|
||||||
|
|
||||||
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
||||||
|
expect(metadata.apiEndpoint).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// A Mongo, Cassandra or Table connection string names the account in its own api's dns zone rather than
|
||||||
|
// giving the document endpoint, so the document endpoint that data plane operations go through is built
|
||||||
|
// from the account name, under a zone of the same kind as the one that matched.
|
||||||
|
const publicDocumentEndpoint = `https://${mockAccountName}.documents.azure.com:443/`;
|
||||||
|
const ppeDocumentEndpoint = `https://${mockAccountName}.documents-staging.windows-ppe.net:443/`;
|
||||||
|
const expectedDocumentEndpointByZone: Record<string, string> = {
|
||||||
|
"documents.azure.com": publicDocumentEndpoint,
|
||||||
|
"documents-staging.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
"mongo.cosmos.azure.com": publicDocumentEndpoint,
|
||||||
|
"mongo.cosmos.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
"cassandra.cosmosdb.azure.com": publicDocumentEndpoint,
|
||||||
|
"cassandra.cosmos.azure.com": publicDocumentEndpoint,
|
||||||
|
"cassandra.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
"cassandra.cosmos.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
"table.cosmosdb.azure.com": publicDocumentEndpoint,
|
||||||
|
"table.cosmos.azure.com": publicDocumentEndpoint,
|
||||||
|
"table.cosmosdb.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
"table.cosmos.windows-ppe.net": ppeDocumentEndpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
...configContext.MONGO_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
|
||||||
|
...configContext.MONGO_COMPUTE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.mongo(dnsZone)]),
|
||||||
|
...configContext.CASSANDRA_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.cassandra(dnsZone)]),
|
||||||
|
...configContext.TABLE_DNS_ZONES.map((dnsZone) => [dnsZone, buildConnectionString.table(dnsZone)]),
|
||||||
|
])(
|
||||||
|
"should construct the document endpoint for an account in the %s zone",
|
||||||
|
(dnsZone: string, connectionString: string) => {
|
||||||
|
const metadata = parseConnectionString(connectionString);
|
||||||
|
|
||||||
|
expect(metadata.documentEndpoint).toBe(expectedDocumentEndpointByZone[dnsZone]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("should parse a valid graph account connection string", () => {
|
it("should parse a valid graph account connection string", () => {
|
||||||
const metadata = parseConnectionString(
|
const metadata = parseConnectionString(buildConnectionString.graph("documents.azure.com"));
|
||||||
`AccountEndpoint=https://${mockAccountName}.documents.azure.com:443/;AccountKey=${mockMasterKey};ApiKind=Gremlin;`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
|
||||||
@@ -55,36 +182,41 @@ describe("ConnectionStringParser", () => {
|
|||||||
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
|
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.azure.com:443`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse a valid table account connection string", () => {
|
it("should construct a PPE gremlin endpoint for a PPE graph account", () => {
|
||||||
const metadata = parseConnectionString(
|
const metadata = parseConnectionString(buildConnectionString.graph("documents-staging.windows-ppe.net"));
|
||||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmosdb.azure.com:443/;`,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
expect(metadata.accountName).toBe(mockAccountName);
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
expect(metadata.apiKind).toBe(DataModels.ApiKind.Graph);
|
||||||
// Table data operations go through the document endpoint, which is constructed from the account name.
|
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents-staging.windows-ppe.net:443/`);
|
||||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
// The constructed endpoint has to match the kind of zone the document endpoint we matched came from.
|
||||||
expect(metadata.apiEndpoint).toBeUndefined();
|
expect(metadata.apiEndpoint).toBe(`${mockAccountName}.gremlin.cosmos.windows-ppe.net:443`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse a valid table account connection string using the cosmos.azure.com zone", () => {
|
it("should reject a connection string when no DNS zone matches the account", () => {
|
||||||
const metadata = parseConnectionString(
|
const originalZones = configContext.DOCUMENT_ENDPOINT_ZONES;
|
||||||
`DefaultEndpointsProtocol=https;AccountName=${mockAccountName};AccountKey=${mockMasterKey};TableEndpoint=https://${mockAccountName}.table.cosmos.azure.com:443/;`,
|
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: ["documents.azure.com"] });
|
||||||
);
|
|
||||||
|
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
try {
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Table);
|
const metadata = parseConnectionString(buildConnectionString.table("table.cosmos.windows-ppe.net"));
|
||||||
expect(metadata.documentEndpoint).toBe(`https://${mockAccountName}.documents.azure.com:443/`);
|
|
||||||
expect(metadata.apiEndpoint).toBeUndefined();
|
// The account key travels to the constructed document endpoint, so a config carrying no PPE zone
|
||||||
|
// has to fail on a PPE account rather than fall back to a zone the account does not own.
|
||||||
|
expect(metadata).toBe(undefined);
|
||||||
|
} finally {
|
||||||
|
updateConfigContext({ DOCUMENT_ENDPOINT_ZONES: originalZones });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should parse a valid cassandra account connection string", () => {
|
it.each([
|
||||||
const metadata = parseConnectionString(
|
buildConnectionString.sql("documents.azure.com.attacker.example"),
|
||||||
`AccountEndpoint=${mockAccountName}.cassandra.cosmosdb.azure.com;AccountKey=${mockMasterKey};`,
|
buildConnectionString.mongo("documents.azure.com.attacker.example"),
|
||||||
);
|
buildConnectionString.mongo("mongo.cosmos.azure.com.attacker.example"),
|
||||||
|
buildConnectionString.cassandra("cassandra.cosmosdb.azure.com.attacker.example"),
|
||||||
expect(metadata.accountName).toBe(mockAccountName);
|
buildConnectionString.table("table.cosmosdb.azure.com.attacker.example"),
|
||||||
expect(metadata.apiKind).toBe(DataModels.ApiKind.Cassandra);
|
])("should not accept a host that only begins with a known zone: %s", (connectionString: string) => {
|
||||||
|
// The zone list is what keeps the account key from being sent somewhere arbitrary, so a host that
|
||||||
|
// appends to an allowed zone must not pass as that zone.
|
||||||
|
expect(parseConnectionString(connectionString)).toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fail to parse an invalid connection string", () => {
|
it("should fail to parse an invalid connection string", () => {
|
||||||
@@ -98,4 +230,92 @@ describe("ConnectionStringParser", () => {
|
|||||||
|
|
||||||
expect(metadata).toBe(undefined);
|
expect(metadata).toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("dnsZoneAlternation", () => {
|
||||||
|
it("should escape the dots in a zone", () => {
|
||||||
|
expect(dnsZoneAlternation(["documents.azure.com"])).toBe("(documents\\.azure\\.com)(?=[:/\\s]|$)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should join multiple zones into a single alternation", () => {
|
||||||
|
expect(dnsZoneAlternation(["a.example", "b.test"])).toBe("(a\\.example|b\\.test)(?=[:/\\s]|$)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not let the dots match arbitrary characters", () => {
|
||||||
|
// An unescaped dot would make the zone list match hosts that only resemble a real zone.
|
||||||
|
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
|
||||||
|
|
||||||
|
expect(regex.test("documents.azure.com")).toBe(true);
|
||||||
|
expect(regex.test("documentsXazure.com")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should capture the zone that matched", () => {
|
||||||
|
const regex = RegExp(dnsZoneAlternation(["a.example", "b.test"]));
|
||||||
|
|
||||||
|
expect("account.b.test".match(regex)[1]).toBe("b.test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should require the zone to run to the end of the host", () => {
|
||||||
|
const regex = RegExp(dnsZoneAlternation(["documents.azure.com"]));
|
||||||
|
|
||||||
|
expect(regex.test("account.documents.azure.com")).toBe(true);
|
||||||
|
expect(regex.test("account.documents.azure.com:443/")).toBe(true);
|
||||||
|
expect(regex.test("account.documents.azure.com/")).toBe(true);
|
||||||
|
// Without this the zone list stops being an allowlist, since anything can be appended to a zone.
|
||||||
|
expect(regex.test("account.documents.azure.com.attacker.example")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildEndpointsRegex", () => {
|
||||||
|
it("should build a pattern for every api matched by dns zone", () => {
|
||||||
|
expect(Object.keys(buildEndpointsRegex())).toEqual(["sql", "mongo", "mongoCompute", "cassandra", "table"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build a cassandra pattern for each supported key", () => {
|
||||||
|
const { cassandra } = buildEndpointsRegex();
|
||||||
|
|
||||||
|
expect(cassandra).toHaveLength(2);
|
||||||
|
expect(cassandra[0]).toContain("AccountEndpoint=");
|
||||||
|
expect(cassandra[1]).toContain("HostName=");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should build each pattern from its own zone list", () => {
|
||||||
|
// The patterns are near identical, so a zone list wired to the wrong api would be easy to miss in
|
||||||
|
// review and would let an account of one api be parsed as another.
|
||||||
|
const { sql, mongo, mongoCompute, cassandra, table } = buildEndpointsRegex();
|
||||||
|
|
||||||
|
expect(sql).toContain(dnsZoneAlternation(configContext.SQL_DNS_ZONES));
|
||||||
|
expect(mongo).toContain(dnsZoneAlternation(configContext.MONGO_DNS_ZONES));
|
||||||
|
expect(mongoCompute).toContain(dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES));
|
||||||
|
cassandra.forEach((pattern) => expect(pattern).toContain(dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)));
|
||||||
|
expect(table).toContain(dnsZoneAlternation(configContext.TABLE_DNS_ZONES));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selectEndpointZone", () => {
|
||||||
|
const nonPpeZone = "documents.azure.com";
|
||||||
|
const ppeZone = "documents-staging.windows-ppe.net";
|
||||||
|
|
||||||
|
it("should pick the ppe zone for a ppe account", () => {
|
||||||
|
expect(selectEndpointZone([nonPpeZone, ppeZone], true)).toBe(ppeZone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pick the non ppe zone for a non ppe account", () => {
|
||||||
|
expect(selectEndpointZone([nonPpeZone, ppeZone], false)).toBe(nonPpeZone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not depend on the order of the zones", () => {
|
||||||
|
expect(selectEndpointZone([ppeZone, nonPpeZone], true)).toBe(ppeZone);
|
||||||
|
expect(selectEndpointZone([ppeZone, nonPpeZone], false)).toBe(nonPpeZone);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined when no zone matches the kind of account", () => {
|
||||||
|
// Sovereign configs carry no ppe zone, and a ppe only config carries no non ppe zone.
|
||||||
|
expect(selectEndpointZone([nonPpeZone], true)).toBeUndefined();
|
||||||
|
expect(selectEndpointZone([ppeZone], false)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for an empty zone list", () => {
|
||||||
|
expect(selectEndpointZone([], false)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,44 +1,80 @@
|
|||||||
import * as Constants from "../../../Common/Constants";
|
import { configContext } from "../../../ConfigContext";
|
||||||
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
|
import { AccessInputMetadata, ApiKind } from "../../../Contracts/DataModels";
|
||||||
|
|
||||||
// Cosmos DB DNS zones used to construct endpoints client-side. These mirror what the Portal Backend's
|
const PpeDnsSuffix = "windows-ppe.net";
|
||||||
// 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";
|
const DnsPort = "443";
|
||||||
|
|
||||||
|
// Match on a label boundary so a zone like "notwindows-ppe.net" is not taken for a PPE zone.
|
||||||
|
const isPpeZone = (zone: string): boolean => zone === PpeDnsSuffix || zone.endsWith(`.${PpeDnsSuffix}`);
|
||||||
|
|
||||||
|
// Picks the DNS zone matching the kind of account the connection string came from, since a PPE
|
||||||
|
// account's endpoints sit under PPE zones and every other account's do not.
|
||||||
|
export const selectEndpointZone = (zones: ReadonlyArray<string>, isPpeAccount: boolean): string | undefined =>
|
||||||
|
zones.find((zone) => isPpeZone(zone) === isPpeAccount);
|
||||||
|
|
||||||
|
// Builds an alternation matching any of the given DNS zones, e.g. "(documents\.azure\.com|sql\.cosmos\.azure\.com)".
|
||||||
|
// The group captures so callers can tell which zone matched, and with it whether the account is a PPE account.
|
||||||
|
// The zone has to run to the end of the host, otherwise a host that merely starts with an allowed zone
|
||||||
|
// would pass as that zone and the account key would travel to whatever was appended to it. Zones come
|
||||||
|
// from config, so every regex metacharacter is escaped rather than just the dots.
|
||||||
|
export const dnsZoneAlternation = (zones: ReadonlyArray<string>): string =>
|
||||||
|
`(${zones.map((zone) => zone.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?=[:/\\s]|$)`;
|
||||||
|
|
||||||
|
// The zone lists live in ConfigContext, which is populated asynchronously by initializeConfiguration,
|
||||||
|
// so these are built per call rather than once at module load.
|
||||||
|
export const buildEndpointsRegex = () => ({
|
||||||
|
sql: `AccountEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.SQL_DNS_ZONES)}`,
|
||||||
|
mongo: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_DNS_ZONES)}`,
|
||||||
|
mongoCompute: `mongodb://.*:(.*)@([^.]+)\\.${dnsZoneAlternation(configContext.MONGO_COMPUTE_DNS_ZONES)}`,
|
||||||
|
cassandra: ["AccountEndpoint", "HostName"].map(
|
||||||
|
(key) => `${key}=([^.]+)\\.${dnsZoneAlternation(configContext.CASSANDRA_DNS_ZONES)}`,
|
||||||
|
),
|
||||||
|
table: `TableEndpoint=https://([^.]+)\\.${dnsZoneAlternation(configContext.TABLE_DNS_ZONES)}`,
|
||||||
|
});
|
||||||
|
|
||||||
export function parseConnectionString(connectionString: string): AccessInputMetadata {
|
export function parseConnectionString(connectionString: string): AccessInputMetadata {
|
||||||
if (connectionString) {
|
if (connectionString) {
|
||||||
try {
|
try {
|
||||||
const accessInput = {} as AccessInputMetadata;
|
const accessInput = {} as AccessInputMetadata;
|
||||||
const connectionStringParts = connectionString.split(";");
|
const connectionStringParts = connectionString.split(";");
|
||||||
|
const endpointsRegex = buildEndpointsRegex();
|
||||||
|
// Endpoints we build from the account name have to match the kind of zone the connection string
|
||||||
|
// actually matched, since PPE accounts and other accounts do not share zones.
|
||||||
|
let isPpeAccount = false;
|
||||||
|
|
||||||
connectionStringParts.forEach((connectionStringPart: string) => {
|
connectionStringParts.forEach((connectionStringPart: string) => {
|
||||||
if (RegExp(Constants.EndpointsRegex.sql).test(connectionStringPart)) {
|
if (RegExp(endpointsRegex.sql).test(connectionStringPart)) {
|
||||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.sql)[1];
|
const matches: string[] = connectionStringPart.match(endpointsRegex.sql);
|
||||||
|
accessInput.accountName = matches[1];
|
||||||
accessInput.apiKind = ApiKind.SQL;
|
accessInput.apiKind = ApiKind.SQL;
|
||||||
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
|
// SQL and Gremlin connection strings carry the account's document endpoint, so take it as
|
||||||
// given instead of rebuilding it from the account name.
|
// given instead of rebuilding it from the account name.
|
||||||
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
|
accessInput.documentEndpoint = connectionStringPart.substring(connectionStringPart.indexOf("=") + 1);
|
||||||
} else if (RegExp(Constants.EndpointsRegex.mongo).test(connectionStringPart)) {
|
isPpeAccount = isPpeZone(matches[2]);
|
||||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongo);
|
} else if (RegExp(endpointsRegex.mongo).test(connectionStringPart)) {
|
||||||
|
const matches: string[] = connectionStringPart.match(endpointsRegex.mongo);
|
||||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||||
accessInput.apiKind = ApiKind.MongoDB;
|
accessInput.apiKind = ApiKind.MongoDB;
|
||||||
} else if (RegExp(Constants.EndpointsRegex.mongoCompute).test(connectionStringPart)) {
|
isPpeAccount = isPpeZone(matches[3]);
|
||||||
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
|
} else if (RegExp(endpointsRegex.mongoCompute).test(connectionStringPart)) {
|
||||||
|
const matches: string[] = connectionStringPart.match(endpointsRegex.mongoCompute);
|
||||||
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
accessInput.accountName = matches && matches.length > 1 && matches[2];
|
||||||
accessInput.apiKind = ApiKind.MongoDBCompute;
|
accessInput.apiKind = ApiKind.MongoDBCompute;
|
||||||
} else if (Constants.EndpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
|
isPpeAccount = isPpeZone(matches[3]);
|
||||||
Constants.EndpointsRegex.cassandra.forEach((regex) => {
|
} else if (endpointsRegex.cassandra.some((regex) => RegExp(regex).test(connectionStringPart))) {
|
||||||
if (RegExp(regex).test(connectionStringPart)) {
|
endpointsRegex.cassandra.forEach((regex) => {
|
||||||
accessInput.accountName = connectionStringPart.match(regex)[1];
|
const matches: string[] = connectionStringPart.match(regex);
|
||||||
|
if (matches) {
|
||||||
|
accessInput.accountName = matches[1];
|
||||||
accessInput.apiKind = ApiKind.Cassandra;
|
accessInput.apiKind = ApiKind.Cassandra;
|
||||||
|
isPpeAccount = isPpeZone(matches[2]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
|
} else if (RegExp(endpointsRegex.table).test(connectionStringPart)) {
|
||||||
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
|
const matches: string[] = connectionStringPart.match(endpointsRegex.table);
|
||||||
|
accessInput.accountName = matches[1];
|
||||||
accessInput.apiKind = ApiKind.Table;
|
accessInput.apiKind = ApiKind.Table;
|
||||||
|
isPpeAccount = isPpeZone(matches[2]);
|
||||||
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
|
} else if (connectionStringPart.indexOf("ApiKind=Gremlin") >= 0) {
|
||||||
accessInput.apiKind = ApiKind.Graph;
|
accessInput.apiKind = ApiKind.Graph;
|
||||||
}
|
}
|
||||||
@@ -48,14 +84,28 @@ export function parseConnectionString(connectionString: string): AccessInputMeta
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Table connection strings only carry the table endpoint, so the document endpoint that data plane
|
// A Table, Mongo or Cassandra connection string names the account in its own api's dns zone rather
|
||||||
// operations go through has to be derived from the account name. Gremlin accounts additionally
|
// than giving the document endpoint, so the document endpoint that data plane operations go through
|
||||||
// need the Gremlin endpoint, which is never part of the connection string.
|
// has to be built from the account name. SQL and Gremlin strings carry it and take it as given.
|
||||||
|
// Gremlin additionally needs the Gremlin endpoint, which is never part of the connection string.
|
||||||
if (accessInput.accountName) {
|
if (accessInput.accountName) {
|
||||||
if (accessInput.apiKind === ApiKind.Table) {
|
if (
|
||||||
accessInput.documentEndpoint = `https://${accessInput.accountName}.${DocumentEndpointZone}:${DnsPort}/`;
|
accessInput.apiKind === ApiKind.Table ||
|
||||||
|
accessInput.apiKind === ApiKind.MongoDB ||
|
||||||
|
accessInput.apiKind === ApiKind.MongoDBCompute ||
|
||||||
|
accessInput.apiKind === ApiKind.Cassandra
|
||||||
|
) {
|
||||||
|
const documentEndpointZone = selectEndpointZone(configContext.DOCUMENT_ENDPOINT_ZONES, isPpeAccount);
|
||||||
|
if (!documentEndpointZone) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
accessInput.documentEndpoint = `https://${accessInput.accountName}.${documentEndpointZone}:${DnsPort}/`;
|
||||||
} else if (accessInput.apiKind === ApiKind.Graph) {
|
} else if (accessInput.apiKind === ApiKind.Graph) {
|
||||||
accessInput.apiEndpoint = `${accessInput.accountName}.${GremlinEndpointZone}:${DnsPort}`;
|
const gremlinEndpointZone = selectEndpointZone(configContext.GREMLIN_ENDPOINT_ZONES, isPpeAccount);
|
||||||
|
if (!gremlinEndpointZone) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
accessInput.apiEndpoint = `${accessInput.accountName}.${gremlinEndpointZone}:${DnsPort}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,4 +88,6 @@ export const allowedHostedExplorerEndpoints: ReadonlyArray<string> = [
|
|||||||
...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []),
|
...(process.env.NODE_ENV === "development" ? ["https://localhost:12900"] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = ["https://dataexplorer-preview.azurewebsites.net/"];
|
export const allowedMsalRedirectEndpoints: ReadonlyArray<string> = [
|
||||||
|
"https://dataexplorer-preview.portal.cosmos.azure.com/",
|
||||||
|
];
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import enableHooks from "jest-react-hooks-shallow";
|
|||||||
import { TextDecoder, TextEncoder } from "util";
|
import { TextDecoder, TextEncoder } from "util";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
import enResources from "./Localization/en/Resources.json";
|
import enResources from "./Localization/en/Resources.json";
|
||||||
|
|
||||||
|
jest.mock("web-vitals");
|
||||||
|
|
||||||
configure({ adapter: new Adapter() });
|
configure({ adapter: new Adapter() });
|
||||||
initializeIcons();
|
initializeIcons();
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export enum TestAccount {
|
|||||||
SQLReadOnly = "SQLReadOnly",
|
SQLReadOnly = "SQLReadOnly",
|
||||||
SQLContainerCopyOnly = "SQLContainerCopyOnly",
|
SQLContainerCopyOnly = "SQLContainerCopyOnly",
|
||||||
SQLConnectionString = "SQLConnectionString",
|
SQLConnectionString = "SQLConnectionString",
|
||||||
|
SQLConnectionStringPublicNetworkAccessDisabled = "SQLConnectionStringPublicNetworkAccessDisabled",
|
||||||
TableConnectionString = "TableConnectionString",
|
TableConnectionString = "TableConnectionString",
|
||||||
GremlinConnectionString = "GremlinConnectionString",
|
GremlinConnectionString = "GremlinConnectionString",
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,8 @@ export function getDefaultAccountName(accountType: TestAccount): string {
|
|||||||
return `${accountNamePrefix}-de-test-sql-containercopy`;
|
return `${accountNamePrefix}-de-test-sql-containercopy`;
|
||||||
case TestAccount.SQLConnectionString:
|
case TestAccount.SQLConnectionString:
|
||||||
return `${accountNamePrefix}-de-test-sql-connstring-1`;
|
return `${accountNamePrefix}-de-test-sql-connstring-1`;
|
||||||
|
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
|
||||||
|
return `${accountNamePrefix}-de-test-sql-connstring-nopublic-1`;
|
||||||
case TestAccount.TableConnectionString:
|
case TestAccount.TableConnectionString:
|
||||||
return `${accountNamePrefix}-de-test-table-connstring-1`;
|
return `${accountNamePrefix}-de-test-table-connstring-1`;
|
||||||
case TestAccount.GremlinConnectionString:
|
case TestAccount.GremlinConnectionString:
|
||||||
@@ -258,6 +261,7 @@ export async function getTestExplorerUrl(accountType: TestAccount, options?: Tes
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TestAccount.SQLConnectionString:
|
case TestAccount.SQLConnectionString:
|
||||||
|
case TestAccount.SQLConnectionStringPublicNetworkAccessDisabled:
|
||||||
case TestAccount.TableConnectionString:
|
case TestAccount.TableConnectionString:
|
||||||
case TestAccount.GremlinConnectionString:
|
case TestAccount.GremlinConnectionString:
|
||||||
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
|
// Connection string (account key) login navigates directly to hostedExplorer.html and doesn't
|
||||||
|
|||||||
@@ -115,4 +115,32 @@ test.describe("SQL account using connection string login", () => {
|
|||||||
await expect(page.locator("#connectExplorer")).toHaveCount(0);
|
await expect(page.locator("#connectExplorer")).toHaveCount(0);
|
||||||
await expect(page.locator(".errorDetails")).toHaveCount(0);
|
await expect(page.locator(".errorDetails")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opens Data Explorer but loads no databases when the account rejects the client IP", async ({ page }) => {
|
||||||
|
// An account that refuses this client's IP.
|
||||||
|
const armClient = new CosmosDBManagementClient(getAzureCLICredentials(), subscriptionId);
|
||||||
|
const blockedAccountName = getAccountName(TestAccount.SQLConnectionStringPublicNetworkAccessDisabled);
|
||||||
|
const blockedAccount = await armClient.databaseAccounts.get(resourceGroupName, blockedAccountName);
|
||||||
|
const blockedKeys = await armClient.databaseAccounts.listKeys(resourceGroupName, blockedAccountName);
|
||||||
|
|
||||||
|
await loginWithConnectionString(
|
||||||
|
page,
|
||||||
|
`AccountEndpoint=${blockedAccount.documentEndpoint!};AccountKey=${blockedKeys.primaryMasterKey};`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const explorer = await DataExplorer.waitForExplorer(page);
|
||||||
|
|
||||||
|
// Login is a client-side parse of the connection string, so nothing checks whether the account will
|
||||||
|
// accept requests from this IP before letting the user in.
|
||||||
|
await expect(page.locator("#connectExplorer")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".errorDetails")).toHaveCount(0);
|
||||||
|
|
||||||
|
// The rejection surfaces once the tree tries to read the data plane, and only in the console.
|
||||||
|
const consoleMessages = await explorer.getNotificationConsoleMessages();
|
||||||
|
await expect(consoleMessages).toContainText("Error while refreshing databases", { timeout: ONE_MINUTE_MS });
|
||||||
|
|
||||||
|
// The tree is left with the static Home node and no database or container beneath it.
|
||||||
|
await expect(explorer.treeNode("Home").element).toBeAttached();
|
||||||
|
await expect(explorer.frame.locator("[data-test^='TreeNode:']")).toHaveCount(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@
|
|||||||
<clear />
|
<clear />
|
||||||
<add name="X-Xss-Protection" value="1; mode=block" />
|
<add name="X-Xss-Protection" value="1; mode=block" />
|
||||||
<add name="X-Content-Type-Options" value="nosniff" />
|
<add name="X-Content-Type-Options" value="nosniff" />
|
||||||
<add name="Content-Security-Policy" value="frame-ancestors 'self' portal.azure.com *.portal.azure.com portal.azure.us portal.azure.cn portal.microsoftazure.de df.onecloud.azure-test.net *.fabric.microsoft.com *.powerbi.com *.analysis-df.windows.net dataexplorer-preview.azurewebsites.net portal.sovcloud-azure.fr portal.sovcloud-azure.de portal.sovcloud-azure.sg" />
|
<add name="Content-Security-Policy" value="frame-ancestors 'self' portal.azure.com *.portal.azure.com portal.azure.us portal.azure.cn portal.microsoftazure.de df.onecloud.azure-test.net *.fabric.microsoft.com *.powerbi.com *.analysis-df.windows.net dataexplorer-preview.portal.cosmos.azure.com portal.sovcloud-azure.fr portal.sovcloud-azure.de portal.sovcloud-azure.sg" />
|
||||||
</customHeaders>
|
</customHeaders>
|
||||||
<redirectHeaders>
|
<redirectHeaders>
|
||||||
<clear />
|
<clear />
|
||||||
|
|||||||
Reference in New Issue
Block a user