Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd3fc5cea7 | ||
|
|
719bab7358 | ||
|
|
95f72e1248 | ||
|
|
f9a9f677ca | ||
|
|
dd0c5df25c | ||
|
|
4fc3244049 | ||
|
|
5faf830d46 | ||
|
|
d2cf82b85c | ||
|
|
fb0020c395 | ||
|
|
e55ea0b629 | ||
|
|
9234dbae70 | ||
|
|
27136eaa71 | ||
|
|
6554af1164 | ||
|
|
437d0e686d | ||
|
|
25b801826d | ||
|
|
123818dc70 | ||
|
|
dafcd046d7 | ||
|
|
accf9faf60 | ||
|
|
6bb237e78c | ||
|
|
bbb5808875 | ||
|
|
905a8f1951 | ||
|
|
f39961bbf5 | ||
|
|
2bf09011fb | ||
|
|
013aa2e570 | ||
|
|
953e35bcd6 | ||
|
|
e138b06fd3 | ||
|
|
2ef6b0549d | ||
|
|
1c49303432 | ||
|
|
f31b1e975d | ||
|
|
48d0087141 | ||
|
|
58cc9e56fc | ||
|
|
189a453e8b | ||
|
|
6fca90747c | ||
|
|
92f5a65c00 | ||
|
|
d8744e81bc | ||
|
|
136baf8260 | ||
|
|
ef47c163d0 | ||
|
|
39e5e3406b | ||
|
|
91d8d61afb | ||
|
|
b1d9153efe | ||
|
|
fda1f56ad1 | ||
|
|
fa18bfff01 | ||
|
|
72f7c8a92d | ||
|
|
838bc37243 | ||
|
|
866972c6c6 | ||
|
|
b19d76f249 | ||
|
|
35ccaeba56 | ||
|
|
bf134072e5 | ||
|
|
0bd152e0b5 | ||
|
|
928ca5409c | ||
|
|
0f846eab2c | ||
|
|
14ed74dd3d | ||
|
|
701d19bd18 | ||
|
|
240a544d82 | ||
|
|
b4f3741979 | ||
|
|
396bf0a43f | ||
|
|
3225ccda10 | ||
|
|
ad2c1ba2ad | ||
|
|
ea591df0bb |
No files matched your search
@@ -13,14 +13,14 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
- name: Output ember a11y test report summary (desktop)
|
||||
run: 'cat ember-a11y-reports/desktop.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||
- name: Upload ember a11y reports
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: ember-a11y-reports
|
||||
path: ./ember-a11y-reports
|
||||
|
||||
@@ -11,11 +11,11 @@ jobs:
|
||||
name: Install dependencies
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -39,12 +39,12 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:oss
|
||||
- name: Upload artifact admin-ui
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui
|
||||
path: ./ui/admin/dist/
|
||||
- name: Upload artifact OSS admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-oss
|
||||
path: ./ui/admin/dist/
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:enterprise
|
||||
- name: Upload artifact enterprise admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-ent
|
||||
path: ./ui/admin/dist/
|
||||
@@ -75,12 +75,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:hcp
|
||||
- name: Upload artifact HCP admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-hcp
|
||||
path: ./ui/admin/dist/
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -6,7 +6,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# Ref and repository aren't normally needed, this is a workaround for a bug in auto commit
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
@@ -14,6 +14,6 @@ jobs:
|
||||
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
||||
- name: Add missing Copyright Headers
|
||||
run: copywrite headers
|
||||
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
|
||||
with:
|
||||
commit_message: Add missing copyright headers
|
||||
@@ -6,6 +6,6 @@ jobs:
|
||||
triage:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -6,11 +6,11 @@ jobs:
|
||||
name: Install dependencies
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -61,11 +61,11 @@ jobs:
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
- run: |
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Test E2E Admin UI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/admin/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/admin/**'
|
||||
- '.github/workflows/test-e2e-admin-ui.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
boundary-enterprise-branch:
|
||||
description: 'Branch of boundary-enterprise to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
boundary-branch:
|
||||
description: 'Branch of boundary to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- boundary_edition: community
|
||||
github_repo: hashicorp/boundary
|
||||
folder_name: boundary
|
||||
boundary_edition_short: oss
|
||||
enos_scenario: e2e_ui_docker
|
||||
test_command: admin:ce:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-branch || 'main' }}
|
||||
- boundary_edition: enterprise
|
||||
github_repo: hashicorp/boundary-enterprise
|
||||
folder_name: boundary-enterprise
|
||||
boundary_edition_short: ent
|
||||
enos_scenario: e2e_ui_docker_ent
|
||||
test_command: admin:ent:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-enterprise-branch || 'main' }}
|
||||
fail-fast: false
|
||||
env:
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
ui/desktop/electron-app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Vault CLI
|
||||
run: |
|
||||
wget https://releases.hashicorp.com/vault/1.12.2/vault_1.12.2_linux_amd64.zip -O /tmp/vault.zip
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: GPG setup
|
||||
run: |
|
||||
# Create a GPG key
|
||||
export KEY_PW=boundary
|
||||
gpg --generate-key --batch <<eoGpgConf
|
||||
%echo Started!
|
||||
Key-Type: RSA
|
||||
Key-Length: default
|
||||
Subkey-Type: RSA
|
||||
Name-Real: boundary
|
||||
Name-Comment: default
|
||||
Name-Email: default
|
||||
Expire-Date: 0
|
||||
Passphrase: $KEY_PW
|
||||
%commit
|
||||
%echo Done.
|
||||
eoGpgConf
|
||||
|
||||
# Enable gpg-preset-passphrase so that key passwords can be saved
|
||||
echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
|
||||
gpg-connect-agent reloadagent /bye &>/dev/null
|
||||
|
||||
# Get information about the created keys
|
||||
export lines=$(gpg --list-secret-keys --with-colons --with-keygrip)
|
||||
export KEY_ID=""
|
||||
while read -r line
|
||||
do
|
||||
# Save the first key id to be used later
|
||||
if [[ $line =~ "fpr"* ]]; then
|
||||
if [[ $KEY_ID == "" ]]; then
|
||||
KEY_ID="$(echo "$line" | sed -r 's/fpr|://g')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cache the passphrases for the keys so passwords do not need to be entered
|
||||
if [[ $line =~ "grp"* ]]; then
|
||||
export KEYGRIP_ID="$(echo "$line" | sed -r 's/grp|://g')"
|
||||
/usr/lib/gnupg/gpg-preset-passphrase --preset -P $KEY_PW $KEYGRIP_ID
|
||||
fi
|
||||
done <<< $lines
|
||||
|
||||
# Trust the key
|
||||
touch /tmp/test.txt
|
||||
gpg -a --encrypt -r $KEY_ID --trust-model always --batch --yes /tmp/test.txt
|
||||
echo "trusted-key $KEY_ID" >> ~/.gnupg/gpg.conf
|
||||
|
||||
# Initialize the password store
|
||||
pass init $KEY_ID &>/dev/null
|
||||
|
||||
- name: Checkout ${{ matrix.folder_name }} repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: ${{ matrix.github_repo }}
|
||||
path: support/src/${{ matrix.folder_name }}
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
ref: ${{ matrix.boundary_branch }}
|
||||
|
||||
- name: GH fix for localhost resolution
|
||||
run: |
|
||||
cat /etc/hosts && echo "-----------"
|
||||
sudo sed -i 's/::1 *localhost ip6-localhost ip6-loopback/::1 ip6 -localhost ip6-loopback/g' /etc/hosts
|
||||
cat /etc/hosts
|
||||
ssh -V
|
||||
|
||||
echo "127.0.0.1 localhost boundary" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost worker" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost vault" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Set git config
|
||||
run: |
|
||||
git config --global url."https://oauth2:${{ secrets.ELEVATED_GITHUB_TOKEN }}@github.com".insteadOf "https://github.com"
|
||||
|
||||
- name: Install node dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
cd e2e-tests
|
||||
pnpm playwright install --with-deps
|
||||
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
echo ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} > ./support/src/${{matrix.folder_name}}/internal/ui/VERSION
|
||||
export ENOS_VAR_ui_build_override="oss" && \
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Move boundary binary
|
||||
run: |
|
||||
mv ./support/src/${{matrix.folder_name}}/bin/boundary /usr/local/bin/
|
||||
boundary version
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source <(bash ./support/src/${{matrix.folder_name}}/enos/scripts/test_e2e_env.sh)
|
||||
cd e2e-tests
|
||||
pnpm run ${{ matrix.test_command }} --reporter=html
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report-${{ matrix.boundary_edition }}
|
||||
path: e2e-tests/playwright-report
|
||||
|
||||
- name: Clean up test infra
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: admin ui e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -3,30 +3,35 @@ name: Test E2E Desktop Client
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/desktop/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/desktop/**'
|
||||
- '.github/workflows/test-e2e-desktop.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{contains(github.event.pull_request.labels.*.name, 'desktop')}}
|
||||
name: Run Desktop E2E Tests
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
env:
|
||||
ENOS_VAR_boundary_edition: "ent"
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
ENOS_VAR_boundary_license_path: "./license.hclic"
|
||||
SETUP_CLI: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
@@ -38,7 +43,7 @@ jobs:
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@b9fa53484a1e8fdcc7b02a118bcf01d65b9414c9
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -48,7 +53,7 @@ jobs:
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: Checkout boundary-enterprise
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: hashicorp/boundary-enterprise
|
||||
# ref: ${{ github.event.inputs.boundary-enterprise-branch }}
|
||||
@@ -72,16 +77,14 @@ jobs:
|
||||
docker pull hashicorp/boundary-enterprise:latest
|
||||
docker save hashicorp/boundary-enterprise:latest | gzip > /tmp/boundary-enterprise.zip
|
||||
|
||||
# TODO: Set up license in a better way? Use directly rather than writing to file and pull from secrets manager
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
echo "${{ secrets.BOUNDARY_ENT_LICENSE }}" > ./support/src/boundary-enterprise/enos/license.hclic
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_docker_image_name=hashicorp/boundary-enterprise:latest && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
export ENOS_VAR_boundary_docker_image_file=/tmp/boundary-enterprise.zip && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
@@ -102,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e-tests/playwright-report
|
||||
@@ -113,3 +116,13 @@ jobs:
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: desktop client e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
trigger-gha-release:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Release description
|
||||
run: |
|
||||
echo "Triggering release with a TAG ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
Binary file not shown.
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
|
||||
@@ -59,10 +59,11 @@ export default class RoleAbility extends ModelAbility {
|
||||
const type = this.model.constructor.modelName;
|
||||
const typeIsValid =
|
||||
type === 'user' || type === 'group' || type === 'managed-group';
|
||||
if (!typeIsValid)
|
||||
if (!typeIsValid) {
|
||||
throw new InvalidRolePrincipalTypeError(
|
||||
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`,
|
||||
);
|
||||
}
|
||||
return this.can.can(`read ${type}`, this.model);
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,15 @@ export default class GeneratedCredentialLibraryModel extends BaseModel {
|
||||
path;
|
||||
|
||||
@attr('string', {
|
||||
for: ['vault-generic', 'vault-ldap'],
|
||||
for: 'vault-generic',
|
||||
description: 'It indicates the type of credential the library returns.',
|
||||
})
|
||||
credential_type;
|
||||
|
||||
@attr('object-as-array', {
|
||||
for: ['vault-generic', 'vault-ldap'],
|
||||
@attr('object', {
|
||||
for: 'vault-generic',
|
||||
description: 'It indicates the credential mapping overrides.',
|
||||
emptyObjectIfMissing: true,
|
||||
})
|
||||
credential_mapping_overrides;
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
})
|
||||
username;
|
||||
|
||||
// =attributes (username_password, username_password_domain)
|
||||
// =attributes (username_password, username_password_domain, password)
|
||||
@attr('string', {
|
||||
for: ['username_password', 'username_password_domain'],
|
||||
for: ['username_password', 'username_password_domain', 'password'],
|
||||
isNestedAttribute: true,
|
||||
isSecret: true,
|
||||
description: 'The password for credential.',
|
||||
|
||||
@@ -49,7 +49,13 @@ export default class SqliteHandler {
|
||||
const schema = store.modelFor(type);
|
||||
const serializer = store.serializerFor(type);
|
||||
|
||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
||||
let {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
query: queryObj,
|
||||
...remainingQuery
|
||||
} = query;
|
||||
let payload,
|
||||
listToken,
|
||||
writeToDbPromise,
|
||||
@@ -101,6 +107,7 @@ export default class SqliteHandler {
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
pushToStore,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
@@ -119,7 +126,7 @@ export default class SqliteHandler {
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select: ['data'],
|
||||
select: select ?? [{ field: 'data' }],
|
||||
});
|
||||
|
||||
const rows = await this.sqlite.fetchResource({
|
||||
@@ -129,7 +136,7 @@ export default class SqliteHandler {
|
||||
|
||||
const { sql: countSql, parameters: countParams } =
|
||||
generateSQLExpressions(type, queryObj, {
|
||||
select: ['count(*) as total'],
|
||||
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||
});
|
||||
const count = await this.sqlite.fetchResource({
|
||||
sql: countSql,
|
||||
@@ -207,6 +214,7 @@ export default class SqliteHandler {
|
||||
payload,
|
||||
storeToken,
|
||||
tokenKey,
|
||||
pushToStore,
|
||||
serializer,
|
||||
store,
|
||||
schema,
|
||||
@@ -218,11 +226,6 @@ export default class SqliteHandler {
|
||||
]);
|
||||
}
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await this.sqlite.deleteResource(type, payload.removed_ids);
|
||||
}
|
||||
|
||||
const normalizedPayload = serializer.normalizeResponse(
|
||||
store,
|
||||
schema,
|
||||
@@ -233,6 +236,19 @@ export default class SqliteHandler {
|
||||
|
||||
const { data: payloadData } = normalizedPayload;
|
||||
|
||||
// Remove any records from the DB if the API indicates they've been deleted
|
||||
// Additionally remove deleted records from ember data store for symmetry
|
||||
if (payload.removed_ids?.length > 0) {
|
||||
await this.sqlite.deleteResource(type, payload.removed_ids);
|
||||
|
||||
if (pushToStore) {
|
||||
payload.removed_ids.forEach((id) => {
|
||||
const record = store.peekRecord(type, id);
|
||||
store.unloadRecord(record);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Store the new data we just got back from the API refresh
|
||||
const items = payloadData.map((datum) => {
|
||||
const params = Object.values(modelMapping[type]).map((key) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
/**
|
||||
* Enum options for credential library.
|
||||
@@ -19,6 +20,7 @@ export const options = {
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
],
|
||||
mapping_overrides: {
|
||||
username_password: ['username_attribute', 'password_attribute'],
|
||||
@@ -32,6 +34,7 @@ export const options = {
|
||||
'password_attribute',
|
||||
'domain_attribute',
|
||||
],
|
||||
password: ['password_attribute'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -12,13 +12,16 @@ import GeneratedCredentialModel from '../generated/models/credential';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password';
|
||||
export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key';
|
||||
export const TYPE_CREDENTIAL_JSON = 'json';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN = 'username_password_domain';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN =
|
||||
'username_password_domain';
|
||||
export const TYPE_CREDENTIAL_PASSWORD = 'password';
|
||||
|
||||
export const TYPES_CREDENTIAL = Object.freeze([
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
]);
|
||||
|
||||
export default class CredentialModel extends GeneratedCredentialModel {
|
||||
|
||||
@@ -98,8 +98,9 @@ export default class AccountSerializer extends ApplicationSerializer {
|
||||
serialized.attributes = {};
|
||||
}
|
||||
// New record case
|
||||
if (password && snapshot?.record?.isNew)
|
||||
if (password && snapshot?.record?.isNew) {
|
||||
serialized.attributes.password = password;
|
||||
}
|
||||
// Set password custom method
|
||||
if (snapshot?.adapterOptions?.method === 'set-password') {
|
||||
serialized = this.serializeForSetPassword(snapshot, password);
|
||||
|
||||
@@ -7,7 +7,6 @@ import ApplicationSerializer from './application';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
options,
|
||||
} from '../models/credential-library';
|
||||
|
||||
@@ -30,8 +29,6 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
return this.serializeVaultGeneric(...arguments);
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE:
|
||||
return this.serializeVaultSshCertificate(...arguments);
|
||||
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
|
||||
return this.serializeVaultLdap(...arguments);
|
||||
default:
|
||||
return super.serialize(...arguments);
|
||||
}
|
||||
@@ -64,15 +61,6 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
serializeVaultLdap(snapshot) {
|
||||
const { isNew } = snapshot;
|
||||
const serialized = super.serialize(...arguments);
|
||||
if (serialized.attributes) {
|
||||
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle credential mapping overrides for serialization.
|
||||
* @param {Object} serialized - The serialized object.
|
||||
@@ -80,7 +68,9 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
*/
|
||||
handleCredentialMappingOverrides(serialized, isNew) {
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
|
||||
if (Object.keys(credential_mapping_overrides).length === 0) {
|
||||
serialized.credential_mapping_overrides = null;
|
||||
}
|
||||
// API expects to send null to fields if it is undefined or deleted
|
||||
if (credential_mapping_overrides && !isNew) {
|
||||
serialized.credential_mapping_overrides = options.mapping_overrides[
|
||||
|
||||
@@ -28,8 +28,9 @@ export default class CredentialStoreSerializer extends ApplicationSerializer {
|
||||
if (
|
||||
!serialized.attributes?.client_certificate_key &&
|
||||
serialized.attributes?.client_certificate
|
||||
)
|
||||
) {
|
||||
delete serialized.attributes.client_certificate_key;
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
@@ -76,9 +76,10 @@ export default class StorageBucketSerializer extends ApplicationSerializer {
|
||||
// This deletes any fields that don't belong to the record's credential type
|
||||
if (options.isNestedAttribute && json.attributes) {
|
||||
// The key must be included in the fieldsByType list above
|
||||
if (!fieldsByCredentialType[credentialType].includes(key))
|
||||
if (!fieldsByCredentialType[credentialType].includes(key)) {
|
||||
// API requires these fields to be null
|
||||
json.attributes[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
//json.secrets is only present, if there are any updates to the secret fields
|
||||
|
||||
@@ -33,16 +33,18 @@ export default class TargetSerializer extends ApplicationSerializer {
|
||||
const injectedApplicationCredentialSourceIDs =
|
||||
snapshot?.adapterOptions?.injectedApplicationCredentialSourceIDs;
|
||||
|
||||
if (brokeredCredentialSourceIDs)
|
||||
if (brokeredCredentialSourceIDs) {
|
||||
serialized = this.serializeWithBrokeredCredentialSources(
|
||||
snapshot,
|
||||
brokeredCredentialSourceIDs,
|
||||
);
|
||||
if (injectedApplicationCredentialSourceIDs)
|
||||
}
|
||||
if (injectedApplicationCredentialSourceIDs) {
|
||||
serialized = this.serializeWithInjectedApplicationCredentialSources(
|
||||
snapshot,
|
||||
injectedApplicationCredentialSourceIDs,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNew && serialized?.with_aliases) {
|
||||
// API expects scope id along with every alias value
|
||||
|
||||
@@ -18,8 +18,9 @@ export default class UserSerializer extends ApplicationSerializer {
|
||||
serialize(snapshot) {
|
||||
let serialized = super.serialize(...arguments);
|
||||
const accountIDs = snapshot?.adapterOptions?.accountIDs;
|
||||
if (accountIDs)
|
||||
if (accountIDs) {
|
||||
serialized = this.serializewithAccounts(snapshot, accountIDs);
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service, { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { assert } from '@ember/debug';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
/**
|
||||
* Database service for querying Boundary resources from storage.
|
||||
* Provides a high-level interface for executing queries against the local database.
|
||||
*/
|
||||
export default class DbService extends Service {
|
||||
@service sqlite;
|
||||
|
||||
/**
|
||||
* Query resources from the database by type.
|
||||
*
|
||||
* @param {string} type - The resource type (e.g., 'scope', 'user', 'target').
|
||||
* Must be a supported model type defined in modelMapping.
|
||||
* @param {Object} query - Query configuration object
|
||||
* @returns {Promise<Object>} Promise resolving to query results from sqlite service
|
||||
* @throws {Error} Assertion error if resource type is not supported
|
||||
*/
|
||||
query(type, query) {
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
assert('Resource type is not supported.', supportedModels.includes(type));
|
||||
|
||||
let { page, pageSize, select, query: queryObj } = query;
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
});
|
||||
|
||||
return this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export const modelMapping = {
|
||||
target_name: 'create_time_values.target.name',
|
||||
target_scope_id: 'create_time_values.target.scope.id',
|
||||
target_scope_name: 'create_time_values.target.scope.name',
|
||||
target_scope_parent_scope_id:
|
||||
'create_time_values.target.scope.parent_scope_id',
|
||||
created_time: 'created_time',
|
||||
},
|
||||
session: {
|
||||
@@ -114,21 +116,6 @@ export const modelMapping = {
|
||||
},
|
||||
};
|
||||
|
||||
// A list of tables that we support searching using FTS5 in SQLite.
|
||||
export const searchTables = new Set([
|
||||
'target',
|
||||
'alias',
|
||||
'group',
|
||||
'role',
|
||||
'user',
|
||||
'credential-store',
|
||||
'scope',
|
||||
'auth-method',
|
||||
'host-catalog',
|
||||
'session-recording',
|
||||
'session',
|
||||
]);
|
||||
|
||||
export default class SqliteDbService extends Service {
|
||||
// =attributes
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { searchTables } from 'api/services/sqlite';
|
||||
import { typeOf } from '@ember/utils';
|
||||
import { underscore } from '@ember/string';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
/**
|
||||
* Takes a POJO representing a filter query and builds a SQL query.
|
||||
@@ -31,12 +31,14 @@ export function generateSQLExpressions(
|
||||
const conditions = [];
|
||||
const parameters = [];
|
||||
const tableName = underscore(resource);
|
||||
const joins = [];
|
||||
|
||||
addFilterConditions({ filters, parameters, conditions });
|
||||
addFilterConditions({ filters, parameters, conditions, joins, tableName });
|
||||
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
||||
|
||||
const orderByClause = constructOrderByClause(resource, sort);
|
||||
|
||||
const selectClause = constructSelectClause(select, tableName);
|
||||
const joinClause = constructJoinClause(joins);
|
||||
const orderByClause = constructOrderByClause(resource, tableName, sort);
|
||||
const whereClause = conditions.length ? `WHERE ${and(conditions)}` : '';
|
||||
|
||||
const paginationClause = page && pageSize ? `LIMIT ? OFFSET ?` : '';
|
||||
@@ -44,13 +46,12 @@ export function generateSQLExpressions(
|
||||
parameters.push(pageSize, (page - 1) * pageSize);
|
||||
}
|
||||
|
||||
const selectClause = `SELECT ${select ? select.join(', ') : '*'} FROM "${tableName}"`;
|
||||
|
||||
return {
|
||||
// Replace any empty newlines or leading whitespace on each line to be consistent with formatting
|
||||
// This is mainly to help us read and test the generated SQL as it has no effect on the actual SQL execution.
|
||||
sql: `
|
||||
${selectClause}
|
||||
${joinClause}
|
||||
${whereClause}
|
||||
${orderByClause}
|
||||
${paginationClause}`
|
||||
@@ -60,7 +61,13 @@ export function generateSQLExpressions(
|
||||
};
|
||||
}
|
||||
|
||||
function addFilterConditions({ filters, parameters, conditions }) {
|
||||
function addFilterConditions({
|
||||
filters,
|
||||
parameters,
|
||||
conditions,
|
||||
joins,
|
||||
tableName,
|
||||
}) {
|
||||
if (!filters) {
|
||||
return;
|
||||
}
|
||||
@@ -71,7 +78,7 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
? filterArrayOrObject
|
||||
: filterArrayOrObject.values;
|
||||
|
||||
if (!filterValueArray || !filterValueArray.length) {
|
||||
if (!filterValueArray || !filterValueArray.length || key === 'joins') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -88,8 +95,13 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
allOperatorsEqual
|
||||
) {
|
||||
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
|
||||
const nullOperator =
|
||||
firstOperator === 'equals' ? 'IS NULL' : 'IS NOT NULL';
|
||||
const values = filterValueArray
|
||||
.filter((f) => f)
|
||||
.filter(
|
||||
(filterObjValue) =>
|
||||
filterObjValue && Object.values(filterObjValue)[0] !== null,
|
||||
)
|
||||
.map((filterObjValue) => {
|
||||
let value = Object.values(filterObjValue)[0];
|
||||
if (typeOf(value) === 'date') {
|
||||
@@ -98,7 +110,14 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
parameters.push(value);
|
||||
return value;
|
||||
});
|
||||
const filterCondition = `${key}${OPERATORS[operation](values)}`;
|
||||
|
||||
// Add a check for null values as an IN clause will mistakenly use `=` for null values.
|
||||
// We'll add an extra OR clause to handle nulls separately.
|
||||
const isNullValue = filterValueArray.some(
|
||||
(filterObjValue) => Object.values(filterObjValue)[0] === null,
|
||||
);
|
||||
|
||||
const filterCondition = `"${tableName}".${key}${OPERATORS[operation](values)}${isNullValue ? ` OR "${tableName}".${key} ${nullOperator}` : ''}`;
|
||||
conditions.push(parenthetical(filterCondition));
|
||||
continue;
|
||||
}
|
||||
@@ -108,6 +127,16 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
.map((filterObjValue) => {
|
||||
let [operation, value] = Object.entries(filterObjValue)[0];
|
||||
|
||||
// Handle null values: convert equals/notEquals to IS NULL or IS NOT NULL
|
||||
if (value === null) {
|
||||
if (operation === 'equals') {
|
||||
return `"${tableName}".${key} IS NULL`;
|
||||
}
|
||||
if (operation === 'notEquals') {
|
||||
return `"${tableName}".${key} IS NOT NULL`;
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite needs to be working with ISO strings
|
||||
if (typeOf(value) === 'date') {
|
||||
value = value.toISOString();
|
||||
@@ -120,7 +149,7 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
parameters.push(value);
|
||||
}
|
||||
|
||||
return `${key}${OPERATORS[operation]}`;
|
||||
return `"${tableName}".${key}${OPERATORS[operation]}`;
|
||||
});
|
||||
|
||||
const { logicalOperator } = filterArrayOrObject;
|
||||
@@ -133,6 +162,43 @@ function addFilterConditions({ filters, parameters, conditions }) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.joins?.length > 0) {
|
||||
const tableNameIndex = {};
|
||||
|
||||
filters.joins.forEach((join) => {
|
||||
const {
|
||||
resource,
|
||||
query,
|
||||
joinFrom = 'id',
|
||||
joinOn,
|
||||
joinType = 'INNER',
|
||||
} = join;
|
||||
const joinTableName = underscore(resource);
|
||||
tableNameIndex[resource] ??= 1;
|
||||
// Alias in the possible scenario we join the same table more than once
|
||||
const alias = `${joinTableName}${tableNameIndex[resource]}`;
|
||||
|
||||
joins.push({
|
||||
type: joinType,
|
||||
table: joinTableName,
|
||||
alias,
|
||||
condition: `"${tableName}".${joinFrom} = ${alias}.${joinOn}`,
|
||||
});
|
||||
|
||||
// Add the conditions from the join query
|
||||
if (query?.filters) {
|
||||
addFilterConditions({
|
||||
filters: query.filters,
|
||||
parameters,
|
||||
conditions,
|
||||
tableName: alias,
|
||||
});
|
||||
}
|
||||
|
||||
tableNameIndex[resource]++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addSearchConditions({
|
||||
@@ -142,39 +208,148 @@ function addSearchConditions({
|
||||
parameters,
|
||||
conditions,
|
||||
}) {
|
||||
if (!search) {
|
||||
if (!search || !modelMapping[resource]) {
|
||||
return;
|
||||
}
|
||||
const searchSelect = search?.select ?? 'rowid';
|
||||
const searchSql = `SELECT ${searchSelect} FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?`;
|
||||
const getParameter = (fields, text) =>
|
||||
fields?.length > 0
|
||||
? or(fields.map((field) => `${field}:"${text}"*`))
|
||||
: `"${text}"*`;
|
||||
|
||||
if (searchTables.has(resource)) {
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
// Use the special prefix indicator "*" for full-text search
|
||||
if (typeOf(search) === 'object') {
|
||||
if (!search.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parameter = getParameter(search.fields, search.text);
|
||||
parameters.push(parameter);
|
||||
} else {
|
||||
parameters.push(`"${search}"*`);
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(
|
||||
`rowid IN (SELECT rowid FROM ${tableName}_fts WHERE ${tableName}_fts MATCH ?)`,
|
||||
}
|
||||
|
||||
// If there are extra related searches on other tables, add them too
|
||||
if (search?.relatedSearches?.length > 0) {
|
||||
const relatedSearchQueries = [];
|
||||
|
||||
search.relatedSearches.forEach((relatedSearch) => {
|
||||
const { resource: relatedResource, fields, join } = relatedSearch;
|
||||
const relatedTableName = underscore(relatedResource);
|
||||
|
||||
const parameter = getParameter(fields, search.text);
|
||||
parameters.push(parameter);
|
||||
|
||||
// Build the related FTS query with optional join
|
||||
let relatedQuery = [];
|
||||
relatedQuery.push(
|
||||
`SELECT "${tableName}".${searchSelect} FROM ${relatedTableName}_fts`,
|
||||
);
|
||||
|
||||
if (join) {
|
||||
const { joinFrom = 'id', joinOn } = join;
|
||||
relatedQuery.push(
|
||||
`JOIN "${tableName}" ON "${tableName}".${joinFrom} = ${relatedResource}_fts.${joinOn}`,
|
||||
);
|
||||
}
|
||||
relatedQuery.push(`WHERE ${relatedTableName}_fts MATCH ?`);
|
||||
|
||||
relatedSearchQueries.push(relatedQuery.join(' '));
|
||||
});
|
||||
|
||||
// Add the original search first since we've already added the original parameter first
|
||||
const conditionStatement = [searchSql, ...relatedSearchQueries].join(
|
||||
'\nUNION ',
|
||||
);
|
||||
conditions.push(
|
||||
`"${tableName}".${searchSelect} IN (${conditionStatement})`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = Object.keys(modelMapping[resource]);
|
||||
const searchConditions = parenthetical(
|
||||
or(
|
||||
fields.map((field) => {
|
||||
parameters.push(`%${search}%`);
|
||||
return `${field}${OPERATORS['contains']}`;
|
||||
}),
|
||||
),
|
||||
);
|
||||
conditions.push(searchConditions);
|
||||
// Use a subquery to match against the FTS table with rowids as SQLite is
|
||||
// much more efficient with FTS queries when using rowids or MATCH (or both).
|
||||
// We could have also used a join here but a subquery is simpler.
|
||||
conditions.push(`"${tableName}".${searchSelect} IN (${searchSql})`);
|
||||
}
|
||||
|
||||
function constructOrderByClause(resource, sort) {
|
||||
const defaultOrderByClause = 'ORDER BY created_time DESC';
|
||||
function constructSelectClause(select = [{ field: '*' }], tableName) {
|
||||
const distinctColumns = select.filter(({ isDistinct }) => isDistinct);
|
||||
const nonDistinctColumns = select.filter(({ isDistinct }) => !isDistinct);
|
||||
|
||||
const buildColumnExpression = ({ field, isCount, alias, isDistinct }) => {
|
||||
let column = field === '*' ? field : `"${tableName}".${field}`;
|
||||
|
||||
// If there's just distinct with nothing else, this will be handled separately
|
||||
// and we will just return the column back as is
|
||||
if (isCount && isDistinct) {
|
||||
column = `count(DISTINCT ${column})`;
|
||||
} else if (isCount) {
|
||||
column = `count(${column})`;
|
||||
}
|
||||
|
||||
if (alias) {
|
||||
column = `${column} as ${alias}`;
|
||||
}
|
||||
|
||||
return column;
|
||||
};
|
||||
|
||||
let selectColumns;
|
||||
|
||||
if (distinctColumns.length > 0) {
|
||||
// Check if any columns also have COUNT (or other aggregate function in the future)
|
||||
const hasAggregateDistinct = distinctColumns.some((col) => col.isCount);
|
||||
|
||||
if (hasAggregateDistinct) {
|
||||
selectColumns = distinctColumns.map(buildColumnExpression).join(', ');
|
||||
|
||||
// Add back in the non distinct columns
|
||||
if (nonDistinctColumns.length > 0) {
|
||||
const regularPart = nonDistinctColumns
|
||||
.map(buildColumnExpression)
|
||||
.join(', ');
|
||||
selectColumns = `${selectColumns}, ${regularPart}`;
|
||||
}
|
||||
} else {
|
||||
assert(
|
||||
'Can not combine non-distincts with multi column distincts',
|
||||
nonDistinctColumns.length === 0,
|
||||
);
|
||||
|
||||
// Only do multi column DISTINCT with no aggregate functions
|
||||
selectColumns = `DISTINCT ${distinctColumns.map(buildColumnExpression).join(', ')}`;
|
||||
}
|
||||
} else {
|
||||
selectColumns = select.map(buildColumnExpression).join(', ');
|
||||
}
|
||||
|
||||
return `SELECT ${selectColumns} FROM "${tableName}"`;
|
||||
}
|
||||
|
||||
function constructJoinClause(joins) {
|
||||
if (!joins || joins.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return joins
|
||||
.map(
|
||||
({ type, table, alias, condition }) =>
|
||||
`${type} JOIN "${table}" ${alias} ON ${condition}`,
|
||||
)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function constructOrderByClause(resource, tableName, sort) {
|
||||
const defaultOrderByClause = `ORDER BY "${tableName}".created_time DESC`;
|
||||
|
||||
const { attributes, customSort, direction, isCoalesced } = sort;
|
||||
const sortDirection = direction === 'desc' ? 'DESC' : 'ASC';
|
||||
const attributesWithTableName = attributes?.map(
|
||||
(attribute) => `"${tableName}".${attribute}`,
|
||||
);
|
||||
|
||||
// We have to check if the attributes are valid for the resource
|
||||
// as we can't use parameterized queries for ORDER BY
|
||||
@@ -190,21 +365,21 @@ function constructOrderByClause(resource, sort) {
|
||||
},
|
||||
'',
|
||||
);
|
||||
return `ORDER BY CASE ${attributes.join(', ')} ${whenClauses}END ${sortDirection}`;
|
||||
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
|
||||
} else if (attributes?.length > 0) {
|
||||
const commaSeparatedVals = attributes.join(', ');
|
||||
const commaSeparatedVals = attributesWithTableName.join(', ');
|
||||
|
||||
// In places where `collate nocase` is used, it is to ensure case is ignored on the initial sort.
|
||||
// Then, a sort on the same condition is performed to ensure upper-case strings are given preference in a tie.
|
||||
if (isCoalesced) {
|
||||
return `ORDER BY COALESCE(${attributes.join(', ')}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
||||
return `ORDER BY COALESCE(${commaSeparatedVals}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
||||
}
|
||||
|
||||
const attributesWithNoCollate = attributes
|
||||
.map((attr) => `${attr} COLLATE NOCASE ${sortDirection}`)
|
||||
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
|
||||
.join(', ');
|
||||
const attributesWithDirection = attributes
|
||||
.map((attr) => `${attr} ${sortDirection}`)
|
||||
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
|
||||
.join(', ');
|
||||
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
||||
} else if (modelMapping[resource]?.created_time) {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
// See "Maximum Number Of Host Parameters In A Single SQL Statement" in
|
||||
// https://www.sqlite.org/limits.html
|
||||
const MAX_HOST_PARAMETERS = 32766;
|
||||
const SCHEMA_VERSION = 1;
|
||||
const SCHEMA_VERSION = 2;
|
||||
|
||||
// Some browsers do not allow calling getDirectory in private browsing modes even
|
||||
// if we're in a secure context. This will cause the SQLite setup to fail so we should
|
||||
|
||||
@@ -25,8 +25,7 @@ const createTargetTables = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC);
|
||||
|
||||
-- Create a contentless FTS table as we will only use the rowids.
|
||||
-- Note that this only creates the FTS index and cannot reference the target content
|
||||
-- Create a content FTS table that references the original table
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
id,
|
||||
type,
|
||||
@@ -35,7 +34,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||
address,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='target',
|
||||
);
|
||||
|
||||
-- Create triggers to keep the FTS table in sync with the target table
|
||||
@@ -68,6 +67,7 @@ CREATE TABLE IF NOT EXISTS alias (
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_created_time ON alias(created_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_alias_destination_id ON alias(destination_id);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
id,
|
||||
@@ -78,7 +78,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS alias_fts USING fts5(
|
||||
value,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='alias',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON alias BEGIN
|
||||
@@ -111,7 +111,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS group_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='group',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS group_ai AFTER INSERT ON "group" BEGIN
|
||||
@@ -144,7 +144,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS role_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='role',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS role_ai AFTER INSERT ON role BEGIN
|
||||
@@ -177,7 +177,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS user_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='user',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS user_ai AFTER INSERT ON user BEGIN
|
||||
@@ -212,7 +212,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS credential_store_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='credential_store',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS credential_store_ai AFTER INSERT ON credential_store BEGIN
|
||||
@@ -247,7 +247,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS scope_fts USING fts5(
|
||||
description,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='scope',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS scope_ai AFTER INSERT ON scope BEGIN
|
||||
@@ -284,7 +284,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS auth_method_fts USING fts5(
|
||||
is_primary,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='auth_method',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS auth_method_ai AFTER INSERT ON auth_method BEGIN
|
||||
@@ -321,7 +321,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS host_catalog_fts USING fts5(
|
||||
plugin_name,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='host_catalog',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS host_catalog_ai AFTER INSERT ON host_catalog BEGIN
|
||||
@@ -352,6 +352,7 @@ CREATE TABLE IF NOT EXISTS session_recording (
|
||||
target_name TEXT,
|
||||
target_scope_id TEXT,
|
||||
target_scope_name TEXT,
|
||||
target_scope_parent_scope_id TEXT,
|
||||
created_time TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
@@ -371,21 +372,22 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||
target_name,
|
||||
target_scope_id,
|
||||
target_scope_name,
|
||||
target_scope_parent_scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='session_recording',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ai AFTER INSERT ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time
|
||||
rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time
|
||||
) VALUES (
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.created_time
|
||||
new.rowid, new.id, new.type, new.state, new.start_time, new.end_time, new.duration, new.scope_id, new.user_id, new.user_name, new.target_id, new.target_name, new.target_scope_id, new.target_scope_name, new.target_scope_parent_scope_id, new.created_time
|
||||
);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_recording_ad AFTER DELETE ON session_recording BEGIN
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.created_time);
|
||||
INSERT INTO session_recording_fts(session_recording_fts, rowid, id, type, state, start_time, end_time, duration, scope_id, user_id, user_name, target_id, target_name, target_scope_id, target_scope_name, target_scope_parent_scope_id, created_time)
|
||||
VALUES('delete', old.rowid, old.id, old.type, old.state, old.start_time, old.end_time, old.duration, old.scope_id, old.user_id, old.user_name, old.target_id, old.target_name, old.target_scope_id, old.target_scope_name, old.target_scope_parent_scope_id, old.created_time);
|
||||
END;`;
|
||||
|
||||
const createSessionTables = `
|
||||
@@ -411,7 +413,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
||||
user_id,
|
||||
scope_id,
|
||||
created_time,
|
||||
content='',
|
||||
content='session',
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'api/services/db';
|
||||
@@ -542,10 +542,11 @@ function routes() {
|
||||
session.update({
|
||||
status: pickRandomStatusString(),
|
||||
});
|
||||
if (session.id.split('-').pop() % 2)
|
||||
if (session.id.split('-').pop() % 2) {
|
||||
session.update({
|
||||
userId: 'authenticateduser',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (recursive && scope_id === 'global') {
|
||||
|
||||
@@ -23,9 +23,6 @@ export default factory.extend({
|
||||
if (this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC) {
|
||||
return options.credential_types[i % options.credential_types.length];
|
||||
}
|
||||
if (this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP) {
|
||||
return options.credential_types[2]; // Only supports username, password & domain credential type
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
permissions.authorizedActionsFor('credential-library') || [
|
||||
|
||||
@@ -65,7 +65,7 @@ export default factory.extend({
|
||||
break;
|
||||
case 'static':
|
||||
default:
|
||||
server.createList('credential', 3, {
|
||||
server.createList('credential', 5, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
|
||||
const types = [...TYPES_CREDENTIAL];
|
||||
@@ -20,14 +22,16 @@ export default factory.extend({
|
||||
type: (i) => types[i % types.length],
|
||||
id() {
|
||||
switch (this.type) {
|
||||
case 'ssh_private_key':
|
||||
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||
return generatedId('credspk_');
|
||||
case 'username_password':
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||
return generatedId('credup_');
|
||||
case 'json':
|
||||
case TYPE_CREDENTIAL_JSON:
|
||||
return generatedId('credjson_');
|
||||
case 'username_password_domain':
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||
return generatedId('credupd_');
|
||||
case TYPE_CREDENTIAL_PASSWORD:
|
||||
return generatedId('credp_');
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { trait } from 'miragejs';
|
||||
import permissions from '../helpers/permissions';
|
||||
import generateId from '../helpers/id';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TYPE_AUTH_METHOD_PASSWORD } from 'api/models/auth-method';
|
||||
|
||||
export default factory.extend({
|
||||
type: 'global',
|
||||
@@ -108,4 +109,21 @@ export default factory.extend({
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
withGlobalAuth: trait({
|
||||
afterCreate(record, server) {
|
||||
if (record.type === 'global') {
|
||||
const authMethod = server.create('auth-method', {
|
||||
type: TYPE_AUTH_METHOD_PASSWORD,
|
||||
scope: record,
|
||||
});
|
||||
server.create('account', {
|
||||
type: TYPE_AUTH_METHOD_PASSWORD,
|
||||
full_name: 'admin',
|
||||
scope: record,
|
||||
authMethod,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
} from 'api/models/credential';
|
||||
import { TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP } from 'api/models/credential-library';
|
||||
|
||||
const randomBoolean = (chance = 0.5) => Math.random() < chance;
|
||||
const randomFilter = () =>
|
||||
@@ -111,7 +113,9 @@ export default factory.extend({
|
||||
server.schema.credentialLibraries,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
cred.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
cred.credential_type !== TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN &&
|
||||
cred.credential_type !== TYPE_CREDENTIAL_PASSWORD &&
|
||||
cred.type !== TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
);
|
||||
const filteredCredentials = selectItems(
|
||||
server.schema.credentials,
|
||||
@@ -119,6 +123,7 @@ export default factory.extend({
|
||||
cred.scopeId === scope.id &&
|
||||
![
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
].includes(cred.type),
|
||||
);
|
||||
@@ -127,11 +132,12 @@ export default factory.extend({
|
||||
const filteredCredentialLibrariesForRDP = selectItems(
|
||||
server.schema.credentialLibraries,
|
||||
(cred) =>
|
||||
cred.scopeId === scope.id &&
|
||||
[
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
].includes(cred.credential_type),
|
||||
(cred.scopeId === scope.id &&
|
||||
[
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
].includes(cred.credential_type)) ||
|
||||
cred.type == TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
);
|
||||
const filteredCredentialsForRDP = selectItems(
|
||||
server.schema.credentials,
|
||||
|
||||
@@ -13,7 +13,7 @@ const oidcRequiredAttempts = 3;
|
||||
|
||||
const commandHandlers = {
|
||||
password: {
|
||||
login: (payload, scopeAttrs) => {
|
||||
login: (payload, auth_method_id, account_id, scopeAttrs) => {
|
||||
if (payload.attributes.login_name === 'error') {
|
||||
return new Response(400);
|
||||
} else {
|
||||
@@ -25,9 +25,9 @@ const commandHandlers = {
|
||||
scope: scopeAttrs,
|
||||
id: 'token123',
|
||||
token: 'thetokenstring',
|
||||
account_id: 'authenticatedaccount',
|
||||
account_id: account_id || 'authenticatedaccount',
|
||||
user_id: 'authenticateduser',
|
||||
auth_method_id: 'authmethod123',
|
||||
auth_method_id: auth_method_id || 'authmethod123',
|
||||
created_time: '',
|
||||
updated_time: '',
|
||||
last_used_time: '',
|
||||
@@ -61,7 +61,7 @@ const commandHandlers = {
|
||||
},
|
||||
},
|
||||
),
|
||||
token: (_, scopeAttrs) => {
|
||||
token: (_, auth_method_id, account_id, scopeAttrs) => {
|
||||
oidcAttemptCounter++;
|
||||
if (oidcAttemptCounter < oidcRequiredAttempts) {
|
||||
return new Response(202);
|
||||
@@ -74,9 +74,9 @@ const commandHandlers = {
|
||||
scope: scopeAttrs,
|
||||
id: 'token123',
|
||||
token: 'thetokenstring',
|
||||
account_id: '1',
|
||||
account_id: account_id || '1',
|
||||
user_id: 'authenticateduser',
|
||||
auth_method_id: 'authmethod123',
|
||||
auth_method_id: auth_method_id || 'authmethod123',
|
||||
created_time: '',
|
||||
updated_time: '',
|
||||
last_used_time: '',
|
||||
@@ -90,7 +90,7 @@ const commandHandlers = {
|
||||
};
|
||||
|
||||
// Handles all custom methods on /auth-methods/:id route
|
||||
export function authHandler({ scopes, authMethods }, request) {
|
||||
export function authHandler({ scopes, authMethods, accounts }, request) {
|
||||
const [, id, method] = request.params.id_method.match(
|
||||
/(?<id>.[^:]*):(?<method>(.*))/,
|
||||
);
|
||||
@@ -100,8 +100,14 @@ export function authHandler({ scopes, authMethods }, request) {
|
||||
const payload = JSON.parse(request.requestBody);
|
||||
const { command } = payload;
|
||||
const scope = scopes.find(authMethod.scopeId);
|
||||
const account = accounts.where({ authMethodId: authMethod.id })?.models[0];
|
||||
const scopeAttrs = this.serialize(scopes.find(scope.id));
|
||||
return commandHandlers[authMethod.type][command](payload, scopeAttrs);
|
||||
return commandHandlers[authMethod.type][command](
|
||||
payload,
|
||||
authMethod.id,
|
||||
account?.id,
|
||||
scopeAttrs,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: this handler doesn't really belong here, but we already route
|
||||
|
||||
@@ -248,6 +248,20 @@ export default function initializeMockIPC(server, config) {
|
||||
resumeClientAgent() {}
|
||||
hasRunningSessions() {}
|
||||
stopAll() {}
|
||||
getRdpClients() {
|
||||
return ['windows-app', 'none', 'mstsc'];
|
||||
}
|
||||
getPreferredRdpClient() {
|
||||
return 'windows-app';
|
||||
}
|
||||
getRecommendedRdpClient() {
|
||||
return {
|
||||
name: 'windows-app',
|
||||
link: 'https://apps.apple.com/us/app/windows-app/id1295203466',
|
||||
};
|
||||
}
|
||||
setPreferredRdpClient() {}
|
||||
launchRdpClient() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,9 +25,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
http_method: 'GET',
|
||||
version: 1,
|
||||
credential_type: 'ssh_private_key',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
],
|
||||
credential_mapping_overrides: { username_attribute: 'user' },
|
||||
});
|
||||
const snapshot = record._createSnapshot();
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
@@ -64,9 +62,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
http_method: 'GET',
|
||||
version: 1,
|
||||
credential_type: 'ssh_private_key',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'private_key_attribute', value: 'test' },
|
||||
],
|
||||
credential_mapping_overrides: { private_key_attribute: 'test' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -291,11 +287,6 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
description: 'Description',
|
||||
path: '/vault/path',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
{ key: 'domain_attribute', value: 'domain' },
|
||||
],
|
||||
});
|
||||
const snapshot = record._createSnapshot();
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
@@ -306,11 +297,6 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
name: 'Name',
|
||||
description: 'Description',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: {
|
||||
username_attribute: 'user',
|
||||
domain_attribute: 'domain',
|
||||
},
|
||||
attributes: {
|
||||
path: '/vault/path',
|
||||
},
|
||||
@@ -326,15 +312,10 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
type: 'credential-library',
|
||||
attributes: {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
name: 'Name',
|
||||
name: 'Test',
|
||||
description: 'Description',
|
||||
path: '/vault/path',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
{ key: 'domain_attribute', value: 'domain' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -345,15 +326,9 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
assert.deepEqual(serializedRecord, {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
credential_store_id: null,
|
||||
name: 'Name',
|
||||
name: 'Test',
|
||||
description: 'Description',
|
||||
version: 1,
|
||||
credential_type: 'username_password_domain',
|
||||
credential_mapping_overrides: {
|
||||
username_attribute: 'user',
|
||||
domain_attribute: 'domain',
|
||||
password_attribute: null,
|
||||
},
|
||||
attributes: {
|
||||
path: '/vault/path',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
|
||||
module('Unit | Service | db', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('query throws assertion error for unsupported resource type', function (assert) {
|
||||
let service = this.owner.lookup('service:db');
|
||||
|
||||
assert.throws(() => {
|
||||
service.query('unsupported-type', { query: {} });
|
||||
}, /Resource type is not supported/);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,10 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'dummy/tests/helpers';
|
||||
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
import { modelMapping, searchTables } from 'api/services/sqlite';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { underscore } from '@ember/string';
|
||||
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
const supportedFtsTables = [...searchTables];
|
||||
|
||||
module('Unit | Service | sqlite', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -38,7 +37,7 @@ module('Unit | Service | sqlite', function (hooks) {
|
||||
|
||||
test.each(
|
||||
'Mapping matches fts table columns',
|
||||
supportedFtsTables,
|
||||
supportedModels,
|
||||
async function (assert, resource) {
|
||||
const service = this.owner.lookup('service:sqlite');
|
||||
|
||||
|
||||
@@ -20,21 +20,6 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
delete String.prototype.removeExtraWhiteSpace;
|
||||
});
|
||||
|
||||
// TODO: Add a normal LIKE search when we add a resource that uses it
|
||||
test('it generates search correctly with FTS5', function (assert) {
|
||||
const query = { search: 'favorite' };
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, ['"favorite"*']);
|
||||
});
|
||||
|
||||
test('it grabs resources not in the model mapping', function (assert) {
|
||||
const query = {
|
||||
filters: { id: [{ equals: 'tokenKey' }] },
|
||||
@@ -45,14 +30,14 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "token"
|
||||
WHERE (id = ?)`.removeExtraWhiteSpace(),
|
||||
WHERE ("token".id = ?)`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, ['tokenKey']);
|
||||
});
|
||||
|
||||
test('it executes count queries correctly', function (assert) {
|
||||
const select = {
|
||||
select: ['count(*) as total'],
|
||||
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||
};
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', {}, select);
|
||||
@@ -60,11 +45,128 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
sql,
|
||||
`
|
||||
SELECT count(*) as total FROM "target"
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it generates select clause with various options',
|
||||
{
|
||||
'single field': {
|
||||
select: [{ field: 'name' }],
|
||||
expectedSelect: '"target".name',
|
||||
},
|
||||
'multiple fields': {
|
||||
select: [{ field: 'name' }, { field: 'type' }, { field: 'id' }],
|
||||
expectedSelect: '"target".name, "target".type, "target".id',
|
||||
},
|
||||
'field with alias': {
|
||||
select: [{ field: 'name', alias: 'resource_name' }],
|
||||
expectedSelect: '"target".name as resource_name',
|
||||
},
|
||||
'multiple fields with aliases': {
|
||||
select: [
|
||||
{ field: 'name', alias: 'resource_name' },
|
||||
{ field: 'type', alias: 'resource_type' },
|
||||
],
|
||||
expectedSelect: '"target".name as resource_name, "target".type as resource_type',
|
||||
},
|
||||
'count with alias': {
|
||||
select: [{ field: 'id', isCount: true, alias: 'total_count' }],
|
||||
expectedSelect: 'count("target".id) as total_count',
|
||||
},
|
||||
'count without alias': {
|
||||
select: [{ field: '*', isCount: true }],
|
||||
expectedSelect: 'count(*)',
|
||||
},
|
||||
'count distinct': {
|
||||
select: [{ field: 'status', isCount: true, isDistinct: true }],
|
||||
expectedSelect: 'count(DISTINCT "target".status)',
|
||||
},
|
||||
'count distinct with alias': {
|
||||
select: [
|
||||
{
|
||||
field: 'type',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'unique_types',
|
||||
},
|
||||
],
|
||||
expectedSelect: 'count(DISTINCT "target".type) as unique_types',
|
||||
},
|
||||
'mixed regular and count fields': {
|
||||
select: [
|
||||
{ field: 'name' },
|
||||
{ field: 'id', isCount: true, alias: 'count' },
|
||||
],
|
||||
expectedSelect: '"target".name, count("target".id) as count',
|
||||
},
|
||||
'field with distinct': {
|
||||
select: [{ field: 'type', isDistinct: true }],
|
||||
expectedSelect: 'DISTINCT "target".type',
|
||||
},
|
||||
'multiple distinct fields': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
],
|
||||
expectedSelect: 'DISTINCT "target".type, "target".status',
|
||||
},
|
||||
'multiple distinct with count fields': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true, isCount: true },
|
||||
{ field: 'status', isDistinct: true, isCount: true },
|
||||
],
|
||||
expectedSelect: 'count(DISTINCT "target".type), count(DISTINCT "target".status)',
|
||||
},
|
||||
'distinct with alias': {
|
||||
select: [{ field: 'type', isDistinct: true, alias: 'unique_type' }],
|
||||
expectedSelect: 'DISTINCT "target".type as unique_type',
|
||||
},
|
||||
'multiple distinct fields with aliases': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true, alias: 'unique_type' },
|
||||
{ field: 'status', isDistinct: true, alias: 'unique_status' },
|
||||
],
|
||||
expectedSelect: 'DISTINCT "target".type as unique_type, "target".status as unique_status',
|
||||
},
|
||||
'count distinct multiple fields': {
|
||||
select: [
|
||||
{
|
||||
field: 'type',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'type_count',
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
isCount: true,
|
||||
isDistinct: true,
|
||||
alias: 'status_count',
|
||||
},
|
||||
],
|
||||
expectedSelect:
|
||||
'count(DISTINCT "target".type) as type_count, count(DISTINCT "target".status) as status_count',
|
||||
},
|
||||
},
|
||||
function (assert, { select, expectedSelect }) {
|
||||
const { sql, parameters } = generateSQLExpressions(
|
||||
'target',
|
||||
{},
|
||||
{ select },
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT ${expectedSelect} FROM "target"
|
||||
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it generates filters correctly',
|
||||
{
|
||||
@@ -74,7 +176,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
type: [{ equals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type = ?)',
|
||||
expectedWhereClause: 'WHERE ("target".type = ?)',
|
||||
expectedParams: ['ssh'],
|
||||
},
|
||||
notEquals: {
|
||||
@@ -83,7 +185,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
type: [{ notEquals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type != ?)',
|
||||
expectedWhereClause: 'WHERE ("target".type != ?)',
|
||||
expectedParams: ['ssh'],
|
||||
},
|
||||
contains: {
|
||||
@@ -92,7 +194,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
type: [{ contains: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (type LIKE ?)',
|
||||
expectedWhereClause: 'WHERE ("target".type LIKE ?)',
|
||||
expectedParams: ['%ssh%'],
|
||||
},
|
||||
greaterThan: {
|
||||
@@ -102,7 +204,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
numberField: [{ gt: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time > ?) AND (numberField > ?)',
|
||||
expectedWhereClause:
|
||||
'WHERE ("target".created_time > ?) AND ("target".numberField > ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
lessThan: {
|
||||
@@ -112,7 +215,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
numberField: [{ lt: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time < ?) AND (numberField < ?)',
|
||||
expectedWhereClause:
|
||||
'WHERE ("target".created_time < ?) AND ("target".numberField < ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
greaterThanOrEqual: {
|
||||
@@ -122,7 +226,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
numberField: [{ gte: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time >= ?) AND (numberField >= ?)',
|
||||
expectedWhereClause:
|
||||
'WHERE ("target".created_time >= ?) AND ("target".numberField >= ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
lessThanOrEqual: {
|
||||
@@ -132,7 +237,8 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
numberField: [{ lte: 10 }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE (created_time <= ?) AND (numberField <= ?)',
|
||||
expectedWhereClause:
|
||||
'WHERE ("target".created_time <= ?) AND ("target".numberField <= ?)',
|
||||
expectedParams: [isoDateString, 10],
|
||||
},
|
||||
logicalOperators: {
|
||||
@@ -150,9 +256,42 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
},
|
||||
},
|
||||
expectedWhereClause:
|
||||
'WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND (type = ?)',
|
||||
'WHERE ("target".id NOT IN (?, ?)) AND ("target".status IN (?, ?)) AND ("target".type = ?)',
|
||||
expectedParams: ['id1', 'id2', 'active', 'pending', 'ssh'],
|
||||
},
|
||||
equalsNull: {
|
||||
query: {
|
||||
filters: {
|
||||
description: [{ equals: null }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE ("target".description IS NULL)',
|
||||
expectedParams: [],
|
||||
},
|
||||
notEqualsNull: {
|
||||
query: {
|
||||
filters: {
|
||||
description: [{ notEquals: null }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause: 'WHERE ("target".description IS NOT NULL)',
|
||||
expectedParams: [],
|
||||
},
|
||||
mixedNullAndValues: {
|
||||
query: {
|
||||
filters: {
|
||||
status: [
|
||||
{ equals: 'active' },
|
||||
{ equals: 'pending' },
|
||||
{ equals: null },
|
||||
],
|
||||
type: [{ notEquals: null }, { notEquals: 'ssh' }],
|
||||
},
|
||||
},
|
||||
expectedWhereClause:
|
||||
'WHERE ("target".status IN (?, ?) OR "target".status IS NULL) AND ("target".type NOT IN (?) OR "target".type IS NOT NULL)',
|
||||
expectedParams: ['active', 'pending', 'ssh'],
|
||||
},
|
||||
},
|
||||
|
||||
function (assert, { query, expectedWhereClause, expectedParams }) {
|
||||
@@ -162,7 +301,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
${expectedWhereClause}
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
@@ -184,7 +323,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY name COLLATE NOCASE ${expectedDirection}, name ${expectedDirection}`.removeExtraWhiteSpace(),
|
||||
ORDER BY "target".name COLLATE NOCASE ${expectedDirection}, "target".name ${expectedDirection}`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, []);
|
||||
},
|
||||
@@ -198,20 +337,20 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
attributes: ['name', 'id'],
|
||||
isCoalesced: true,
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY COALESCE(name, id) COLLATE NOCASE DESC, COALESCE(name, id) DESC`,
|
||||
expectedOrderByClause: `ORDER BY COALESCE("target".name, "target".id) COLLATE NOCASE DESC, COALESCE("target".name, "target".id) DESC`,
|
||||
},
|
||||
'sort on multiple attributes': {
|
||||
sort: {
|
||||
attributes: ['name', 'id'],
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY name COLLATE NOCASE DESC, id COLLATE NOCASE DESC, name DESC, id DESC`,
|
||||
expectedOrderByClause: `ORDER BY "target".name COLLATE NOCASE DESC, "target".id COLLATE NOCASE DESC, "target".name DESC, "target".id DESC`,
|
||||
},
|
||||
'sort on mapped attributes': {
|
||||
sort: {
|
||||
attributes: ['type'],
|
||||
customSort: { attributeMap: { ssh: 'SSH', tcp: 'Generic TCP' } },
|
||||
},
|
||||
expectedOrderByClause: `ORDER BY CASE type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
|
||||
expectedOrderByClause: `ORDER BY CASE "target".type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
|
||||
},
|
||||
},
|
||||
function (assert, { sort, expectedOrderByClause }) {
|
||||
@@ -247,41 +386,189 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY created_time DESC
|
||||
ORDER BY "target".created_time DESC
|
||||
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
);
|
||||
|
||||
test('it generates FTS5 search with filters', function (assert) {
|
||||
const query = {
|
||||
search: 'favorite',
|
||||
filters: {
|
||||
id: {
|
||||
logicalOperator: 'and',
|
||||
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
|
||||
test.each(
|
||||
'it generates FTS5 search correctly',
|
||||
{
|
||||
'string search with filters': {
|
||||
query: {
|
||||
search: 'favorite',
|
||||
},
|
||||
status: [{ equals: 'active' }, { equals: 'pending' }],
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['"favorite"*'],
|
||||
},
|
||||
};
|
||||
'object search with field-specific searches': {
|
||||
query: {
|
||||
search: {
|
||||
text: 'favorite',
|
||||
fields: ['name', 'description'],
|
||||
},
|
||||
},
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['name:"favorite"* OR description:"favorite"*'],
|
||||
},
|
||||
'search with related searches': {
|
||||
query: {
|
||||
search: {
|
||||
text: 'dev',
|
||||
select: 'id',
|
||||
relatedSearches: [
|
||||
{
|
||||
resource: 'alias',
|
||||
fields: ['name', 'description'],
|
||||
join: {
|
||||
joinOn: 'destination_id',
|
||||
joinFrom: 'id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".id IN (SELECT id FROM target_fts WHERE target_fts MATCH ?
|
||||
UNION SELECT "target".id FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['"dev"*', 'name:"dev"* OR description:"dev"*'],
|
||||
},
|
||||
'search with multiple related searches': {
|
||||
query: {
|
||||
search: {
|
||||
text: 'dev',
|
||||
relatedSearches: [
|
||||
{
|
||||
resource: 'alias',
|
||||
fields: ['name', 'description'],
|
||||
join: {
|
||||
joinOn: 'destination_id',
|
||||
},
|
||||
},
|
||||
{
|
||||
resource: 'session',
|
||||
fields: ['name'],
|
||||
join: {
|
||||
joinOn: 'target_id',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
expectedSql: `
|
||||
SELECT * FROM "target"
|
||||
WHERE "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?
|
||||
UNION SELECT "target".rowid FROM alias_fts JOIN "target" ON "target".id = alias_fts.destination_id WHERE alias_fts MATCH ?
|
||||
UNION SELECT "target".rowid FROM session_fts JOIN "target" ON "target".id = session_fts.target_id WHERE session_fts MATCH ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: [
|
||||
'"dev"*',
|
||||
'name:"dev"* OR description:"dev"*',
|
||||
'name:"dev"*',
|
||||
],
|
||||
},
|
||||
},
|
||||
function (assert, { query, expectedSql, expectedParams }) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(sql, expectedSql.removeExtraWhiteSpace());
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
);
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
WHERE (id NOT IN (?, ?)) AND (status IN (?, ?)) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(parameters, [
|
||||
'id1',
|
||||
'id2',
|
||||
'active',
|
||||
'pending',
|
||||
'"favorite"*',
|
||||
]);
|
||||
});
|
||||
test.each(
|
||||
'it generates joins with filters',
|
||||
{
|
||||
'basic joins with filters': {
|
||||
query: {
|
||||
filters: {
|
||||
type: [{ equals: 'ssh' }],
|
||||
joins: [
|
||||
{
|
||||
resource: 'session',
|
||||
query: {
|
||||
filters: {
|
||||
status: [{ equals: 'active' }],
|
||||
},
|
||||
},
|
||||
joinOn: 'target_id',
|
||||
joinType: 'INNER',
|
||||
},
|
||||
{
|
||||
resource: 'session-recording',
|
||||
query: {
|
||||
filters: {
|
||||
state: [{ equals: 'available' }],
|
||||
},
|
||||
},
|
||||
joinFrom: 'idx',
|
||||
joinOn: 'target_id',
|
||||
joinType: 'LEFT',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: [{ field: 'data' }],
|
||||
expectedSql: `
|
||||
SELECT "target".data FROM "target"
|
||||
INNER JOIN "session" session1 ON "target".id = session1.target_id LEFT JOIN "session_recording" session_recording1 ON "target".idx = session_recording1.target_id
|
||||
WHERE ("target".type = ?) AND ("session1".status = ?) AND ("session_recording1".state = ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['ssh', 'active', 'available'],
|
||||
},
|
||||
'multiple joins same table': {
|
||||
query: {
|
||||
filters: {
|
||||
joins: [
|
||||
{
|
||||
resource: 'session',
|
||||
query: {
|
||||
filters: {
|
||||
status: [{ equals: 'active' }],
|
||||
},
|
||||
},
|
||||
joinOn: 'target_id',
|
||||
joinType: 'INNER',
|
||||
},
|
||||
{
|
||||
resource: 'session',
|
||||
query: {
|
||||
filters: {
|
||||
status: [{ equals: 'pending' }],
|
||||
},
|
||||
},
|
||||
joinOn: 'host_id',
|
||||
joinType: 'LEFT',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: [{ field: 'id' }],
|
||||
expectedSql: `
|
||||
SELECT "target".id FROM "target"
|
||||
INNER JOIN "session" session1 ON "target".id = session1.target_id LEFT JOIN "session" session2 ON "target".id = session2.host_id
|
||||
WHERE ("session1".status = ?) AND ("session2".status = ?)
|
||||
ORDER BY "target".created_time DESC`,
|
||||
expectedParams: ['active', 'pending'],
|
||||
},
|
||||
},
|
||||
function (assert, { query, select, expectedSql, expectedParams }) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query, {
|
||||
select,
|
||||
});
|
||||
assert.strictEqual(sql, expectedSql.removeExtraWhiteSpace());
|
||||
assert.deepEqual(parameters, expectedParams);
|
||||
},
|
||||
);
|
||||
|
||||
test('it generates SQL with all clauses combined', function (assert) {
|
||||
const query = {
|
||||
@@ -300,15 +587,15 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
const { sql, parameters } = generateSQLExpressions('target', query, {
|
||||
page: 2,
|
||||
pageSize: 15,
|
||||
select: ['data'],
|
||||
select: [{ field: 'data' }],
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
sql,
|
||||
`
|
||||
SELECT data FROM "target"
|
||||
WHERE (type = ?) AND (status IN (?, ?)) AND (created_time >= ?) AND rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY name COLLATE NOCASE DESC, name DESC
|
||||
SELECT "target".data FROM "target"
|
||||
WHERE ("target".type = ?) AND ("target".status IN (?, ?)) AND ("target".created_time >= ?) AND "target".rowid IN (SELECT rowid FROM target_fts WHERE target_fts MATCH ?)
|
||||
ORDER BY "target".name COLLATE NOCASE DESC, "target".name DESC
|
||||
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
||||
);
|
||||
|
||||
@@ -323,6 +610,35 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(
|
||||
'it throws assertion error for invalid select combinations',
|
||||
{
|
||||
'mixing distinct with non-distinct columns': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
{ field: 'name' },
|
||||
],
|
||||
expectedError:
|
||||
/Can not combine non-distincts with multi column distincts/,
|
||||
},
|
||||
'mixing distinct with aggregate non-distinct columns': {
|
||||
select: [
|
||||
{ field: 'type', isDistinct: true },
|
||||
{ field: 'status', isDistinct: true },
|
||||
{ field: 'name', isCount: true },
|
||||
],
|
||||
expectedError:
|
||||
/Can not combine non-distincts with multi column distincts/,
|
||||
},
|
||||
},
|
||||
function (assert, { select, expectedError }) {
|
||||
assert.throws(() => {
|
||||
generateSQLExpressions('target', {}, { select });
|
||||
}, expectedError);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(
|
||||
'it handles empty and invalid fields',
|
||||
{
|
||||
@@ -350,7 +666,7 @@ module('Unit | Utility | sqlite-query', function (hooks) {
|
||||
targetSql,
|
||||
`
|
||||
SELECT * FROM "target"
|
||||
ORDER BY created_time DESC`.removeExtraWhiteSpace(),
|
||||
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||
);
|
||||
assert.deepEqual(targetParameters, []);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
|
||||
@@ -101,7 +101,7 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
* @param {string} username
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeData(data, username) {
|
||||
normalizeData(data) {
|
||||
// Pull fields up from `data.attributes` for easier access in JavaScript.
|
||||
// The `attributes` field exists on the Go side for its convenience but is
|
||||
// unnecessary here.
|
||||
@@ -109,7 +109,6 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
// Add booleans indicated the scope type
|
||||
data.isGlobal = data?.scope?.type === 'global';
|
||||
data.isOrg = data?.scope?.type === 'org';
|
||||
if (username) data.username = username;
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
|
||||
fetch(authEndpointURL, { method: 'post', body }),
|
||||
);
|
||||
const json = await response.json();
|
||||
return response.status < 400
|
||||
? resolve(this.normalizeData(json, login_name))
|
||||
: reject();
|
||||
return response.status < 400 ? resolve(this.normalizeData(json)) : reject();
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<DD.Header @hasDivider={{true}}>
|
||||
<Hds::Form::TextInput::Base
|
||||
@type='search'
|
||||
@value={{this.searchTerm}}
|
||||
@value={{or this.searchTerm @searchTerm}}
|
||||
placeholder={{t 'actions.narrow-results'}}
|
||||
aria-label={{t 'actions.narrow-results'}}
|
||||
{{on 'input' this.filterItems}}
|
||||
@@ -28,11 +28,18 @@
|
||||
/>
|
||||
</DD.Header>
|
||||
{{/if}}
|
||||
{{#unless this.itemOptions}}
|
||||
|
||||
{{#unless (or this.itemOptions @isLoading)}}
|
||||
<DD.Description @text={{t 'titles.no-results'}} />
|
||||
{{/unless}}
|
||||
|
||||
{{#if (has-block)}}
|
||||
{{#if @isLoading}}
|
||||
<DD.Generic>
|
||||
<Hds::Layout::Flex @align='center' @justify='center'>
|
||||
<Hds::Icon @name='loading' @size='24' class='margin-y-xs' />
|
||||
</Hds::Layout::Flex>
|
||||
</DD.Generic>
|
||||
{{else if (has-block)}}
|
||||
{{yield DD this.selectItem this.itemOptions}}
|
||||
{{else}}
|
||||
{{#each this.itemOptions as |itemOption|}}
|
||||
@@ -47,13 +54,15 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{#unless @isLoading}}
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{/unless}}
|
||||
</Hds::Dropdown>
|
||||
@@ -21,7 +21,8 @@ export default class DropdownComponent extends Component {
|
||||
*/
|
||||
get itemOptions() {
|
||||
let items = this.args.itemOptions;
|
||||
if (this.searchTerm) {
|
||||
|
||||
if (this.searchTerm && !this.args.updateSearchTerm) {
|
||||
const searchTerm = this.searchTerm.toLowerCase();
|
||||
items = this.args.itemOptions.filter((item) => {
|
||||
const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
|
||||
@@ -29,7 +30,7 @@ export default class DropdownComponent extends Component {
|
||||
return isNameMatch || isIdMatch;
|
||||
});
|
||||
}
|
||||
return items.slice(0, 500);
|
||||
return items.slice(0, 250);
|
||||
}
|
||||
|
||||
// =methods
|
||||
@@ -39,10 +40,15 @@ export default class DropdownComponent extends Component {
|
||||
* @param {object} event
|
||||
*/
|
||||
@action
|
||||
@debounce(150)
|
||||
filterItems(event) {
|
||||
@debounce(250)
|
||||
async filterItems(event) {
|
||||
const { value } = event.target;
|
||||
this.searchTerm = value;
|
||||
|
||||
if (this.args.updateSearchTerm) {
|
||||
this.args.updateSearchTerm(value);
|
||||
} else {
|
||||
this.searchTerm = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,16 +17,27 @@ export default class FilterTagsIndexComponent extends Component {
|
||||
|
||||
get filters() {
|
||||
const { allFilters, selectedFilters } = this.args.filters;
|
||||
return Object.entries(allFilters).flatMap(([key, value]) => {
|
||||
assert(`Tags must be an array for key ${key}`, Array.isArray(value));
|
||||
const paramsSet = new Set(selectedFilters[key]);
|
||||
const filters = value.filter((item) => paramsSet.has(item.id));
|
||||
return Object.entries(allFilters).flatMap(([key, values]) => {
|
||||
assert(`Tags must be an array for key ${key}`, Array.isArray(values));
|
||||
const uniqueSelectedFilters = [...new Set(selectedFilters[key])];
|
||||
if (!uniqueSelectedFilters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: key,
|
||||
}));
|
||||
const valueMap = new Map(values.map((value) => [value.id, value.name]));
|
||||
|
||||
return uniqueSelectedFilters.filter(Boolean).map((item) => {
|
||||
const filter = {
|
||||
id: item,
|
||||
type: key,
|
||||
};
|
||||
|
||||
if (valueMap.has(item)) {
|
||||
filter.name = valueMap.get(item);
|
||||
}
|
||||
|
||||
return filter;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
}}
|
||||
|
||||
{{#if @model.type}}
|
||||
{{component
|
||||
(concat 'form/authenticate/' @model.type)
|
||||
onSubmit=@submit
|
||||
disabled=@disabled
|
||||
}}
|
||||
<this.authenticateFormComponent
|
||||
@onSubmit={{@submit}}
|
||||
@disabled={{@disabled}}
|
||||
/>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { assert } from '@ember/debug';
|
||||
import oidc from './oidc';
|
||||
import ldap from './ldap';
|
||||
import password from './password';
|
||||
|
||||
const modelTypeToComponent = {
|
||||
ldap,
|
||||
password,
|
||||
oidc,
|
||||
};
|
||||
|
||||
export default class FormAuthenticateIndex extends Component {
|
||||
get authenticateFormComponent() {
|
||||
const component = modelTypeToComponent[this.args.model.type];
|
||||
assert(
|
||||
`Mapped component must exist for auth method: ${this.args.model.type}`,
|
||||
component,
|
||||
);
|
||||
return component;
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,9 @@ class RouteResourceFilter extends EmberObject {
|
||||
const filterKey = `filter-${name}`;
|
||||
|
||||
// If the route has no query params specified yet, declare them.
|
||||
if (!Object.keys(routeClass.queryParams).length)
|
||||
if (!Object.keys(routeClass.queryParams).length) {
|
||||
routeClass.queryParams = {};
|
||||
}
|
||||
|
||||
// If the route has no resource filters list specified yet, create one.
|
||||
if (!routeClass.resourceFilterParams) routeClass.resourceFilterParams = [];
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
@use 'rose/app/styles/rose/variables/sizing' as sizing;
|
||||
@use 'sass:meta';
|
||||
|
||||
.filters-applied {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -72,3 +75,34 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
$margin-directions: (
|
||||
'': 'margin',
|
||||
'-top': 'margin-top',
|
||||
'-right': 'margin-right',
|
||||
'-bottom': 'margin-bottom',
|
||||
'-left': 'margin-left',
|
||||
'-x': (
|
||||
'margin-left',
|
||||
'margin-right',
|
||||
),
|
||||
'-y': (
|
||||
'margin-top',
|
||||
'margin-bottom',
|
||||
),
|
||||
);
|
||||
|
||||
// Generate margin utility classes with sizes and directions
|
||||
@each $size-name, $size-coefficient in sizing.$size-coefficients {
|
||||
@each $direction-suffix, $properties in $margin-directions {
|
||||
.margin#{$direction-suffix}-#{$size-name} {
|
||||
@if meta.type-of($properties) == 'list' {
|
||||
@each $property in $properties {
|
||||
#{$property}: #{sizing.rems($size-name)};
|
||||
}
|
||||
} @else {
|
||||
#{$properties}: #{sizing.rems($size-name)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { default } from 'core/components/form/authenticate/index';
|
||||
@@ -127,7 +127,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
assert.true(result);
|
||||
});
|
||||
|
||||
test('it only renders a max of 500 items but allows searching on all items', async function (assert) {
|
||||
test('it only renders a max of 250 items but allows searching on all items', async function (assert) {
|
||||
this.set('checkedItems', []);
|
||||
this.set(
|
||||
'itemOptions',
|
||||
@@ -145,7 +145,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
|
||||
await click(TOGGLE_DROPDOWN_SELECTOR);
|
||||
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 500 });
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 250 });
|
||||
|
||||
// Random number between 500 and 599 to search
|
||||
await fillIn(SEARCH_INPUT_SELECTOR, '591');
|
||||
|
||||
@@ -22,4 +22,14 @@ module('Integration | Component | filter-tags/index', function (hooks) {
|
||||
|
||||
assert.dom('.hds-tag__text').hasText('Project 1');
|
||||
});
|
||||
|
||||
test('it renders id if allFilter does not have corresponding value', async function (assert) {
|
||||
this.set('filter', {
|
||||
allFilters: { scopes: [{ id: '2', name: 'Project 2' }] },
|
||||
selectedFilters: { scopes: ['1'] },
|
||||
});
|
||||
await render(hbs`<FilterTags @filters={{this.filter}} />`);
|
||||
|
||||
assert.dom('.hds-tag__text').hasText('1');
|
||||
});
|
||||
});
|
||||
@@ -68,3 +68,4 @@ show-errors: Show Errors
|
||||
hide-errors: Hide Errors
|
||||
edit-worker-filter: Edit Worker Filter
|
||||
add-worker-filter: Add Worker Filter
|
||||
open: Open
|
||||
@@ -49,6 +49,7 @@ titles:
|
||||
project-actions: Project Actions
|
||||
user-menu: User Menu
|
||||
back-link: 'Back to {scope}'
|
||||
authenticated: Signed in as
|
||||
descriptions:
|
||||
empty-set: There are no items to display yet. You may be able to add items or try back later.
|
||||
cluster-url-initialization: To get started, please enter your cluster URL
|
||||
@@ -141,6 +142,21 @@ settings:
|
||||
alerts:
|
||||
cache-daemon: There may be a problem with the cache daemon
|
||||
client-agent: There may be a problem with the client agent
|
||||
preferred-clients:
|
||||
title: Preferred Clients
|
||||
description: Select the applications for Boundary to use when opening these target types.
|
||||
table:
|
||||
header:
|
||||
protocol: Protocol
|
||||
client: Client
|
||||
data:
|
||||
protocols:
|
||||
windows-rdp: Windows RDP
|
||||
clients:
|
||||
none: None
|
||||
mstsc: Remote Desktop Connection (mstsc)
|
||||
windows-app: Windows App
|
||||
none-detected: 'None detected. We recommend <a href={rdpClientLink} target="_blank" rel="noopener noreferrer">{rdpClientName}</a>.'
|
||||
worker-filter-generator:
|
||||
title: Filter generator
|
||||
description: Choose what you want to format into a filter.
|
||||
|
||||
@@ -913,11 +913,12 @@ credential-library:
|
||||
ssh_private_key: SSH Private Key
|
||||
username_password: Username & Password
|
||||
username_password_domain: Username, Password & Domain
|
||||
username_attribute: Username
|
||||
password_attribute: Password
|
||||
private_key_passphrase_attribute: Private Key Passphrase
|
||||
private_key_attribute: Private Key
|
||||
domain_attribute: Domain
|
||||
password: Password
|
||||
username_attribute: username_attribute
|
||||
password_attribute: password_attribute
|
||||
private_key_passphrase_attribute: private_key_passphrase_attribute
|
||||
private_key_attribute: private_key_attribute
|
||||
domain_attribute: domain_attribute
|
||||
actions:
|
||||
create: New Credential Library
|
||||
delete: Delete Credential Library
|
||||
@@ -989,12 +990,14 @@ credential:
|
||||
ssh_private_key: Username & Key Pair
|
||||
username_password_domain: Username, Password & Domain
|
||||
json: JSON
|
||||
password: Password
|
||||
unknown: Unknown
|
||||
help:
|
||||
username_password: Connect using the provided username and password.
|
||||
ssh_private_key: Connect using a username, public key, and private key.
|
||||
json: Connect using a JSON blob containing the credentials.
|
||||
username_password_domain: Includes a domain field for Active Directory
|
||||
username_password_domain: Includes a domain field for Active Directory.
|
||||
password: Connect using only a provided password.
|
||||
form:
|
||||
username:
|
||||
label: Username
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-jquery': 'error',
|
||||
'ember/no-new-mixins': 0,
|
||||
'ember/no-get': 'off',
|
||||
|
||||
@@ -45,13 +45,13 @@ form {
|
||||
.hds-table__th-sort button {
|
||||
color: var(--token-color-foreground-strong);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This is a temporary hack to fix an issue causing weird whitespace
|
||||
// and scrollbars from an accessible class in HDS. We have to use !important
|
||||
// as they already forced it with !important and we can't override it otherwise
|
||||
.sr-only {
|
||||
position: unset !important;
|
||||
}
|
||||
// TODO: This is a temporary hack to fix an issue causing weird whitespace
|
||||
// and scrollbars from an accessible class in HDS. We have to use !important
|
||||
// as they already forced it with !important and we can't override it otherwise
|
||||
.sr-only {
|
||||
position: unset !important;
|
||||
}
|
||||
|
||||
.hds-dropdown {
|
||||
|
||||
@@ -121,14 +121,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark-mode-ember-dropdown-override {
|
||||
.ember-basic-dropdown-content {
|
||||
background-color: var(--token-color-palette-neutral-0);
|
||||
|
||||
/* stylelint-disable-next-line selector-class-pattern */
|
||||
&.ember-basic-dropdown-content--in-place {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ember-application:not(.rose-theme-light) {
|
||||
@include dark-mode;
|
||||
@include dark-mode-modal-override;
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
}
|
||||
}
|
||||
|
||||
.ember-application.rose-theme-dark {
|
||||
@include dark-mode;
|
||||
@include dark-mode-modal-override;
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ html {
|
||||
body {
|
||||
// Used by Rose::Tabs and '/' in breadcrumbs
|
||||
// and link list panel
|
||||
font-family: typography.$family-primary;
|
||||
font-family: var(--token-typography-font-stack-text);
|
||||
|
||||
// this is used across the app
|
||||
line-height: typography.line-height(m);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-family: var(--token-typography-font-stack-code);
|
||||
}
|
||||
|
||||
.rose-autosize {
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
|
||||
.rose-form-checkbox-label {
|
||||
// label styles
|
||||
@include type.type(s, semibold);
|
||||
@include type.type(s);
|
||||
|
||||
color: currentcolor;
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
|
||||
&,
|
||||
.rose-form-checkbox-label-description {
|
||||
@@ -53,7 +54,7 @@
|
||||
pointer-events: none;
|
||||
content: ' ';
|
||||
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
|
||||
border-radius: sizing.rems(xxxs);
|
||||
border-radius: var(--token-form-checkbox-border-radius);
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
margin-bottom: sizing.rems(m);
|
||||
|
||||
.rose-form-fieldset-title {
|
||||
@include type.type(s, semibold);
|
||||
@include type.type(s);
|
||||
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
}
|
||||
|
||||
.rose-form-fieldset-body {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
margin-bottom: sizing.rems(xs);
|
||||
padding: sizing.rems(xs) sizing.rems(xxl) sizing.rems(xs) sizing.rems(xs);
|
||||
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
|
||||
border-radius: sizing.rems(xxxs);
|
||||
border-radius: var(--token-border-radius-x-small);
|
||||
box-shadow: 0 sizing.rems(xxs) sizing.rems(xxs)
|
||||
rgba(var(--stark-components), var(--opacity-2));
|
||||
background: var(--ui-gray-subtler-5)
|
||||
|
||||
@@ -20,8 +20,9 @@
|
||||
}
|
||||
|
||||
.rose-header-brand-text {
|
||||
@include type.type(m, normal);
|
||||
@include type.type(m);
|
||||
|
||||
font-weight: var(--token-typography-font-weight-regular);
|
||||
margin: 0;
|
||||
margin-left: sizing.rems(s);
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
// First cell (key) is approximately 25%
|
||||
&:nth-child(1) {
|
||||
width: 25%;
|
||||
font-weight: typography.font-weight(semibold);
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
}
|
||||
|
||||
// Second cell (value) is approximately 75%
|
||||
|
||||
@@ -22,10 +22,11 @@
|
||||
|
||||
// TODO revisit the title
|
||||
.rose-nav-title {
|
||||
@include type.type(xs, semibold);
|
||||
@include type.type(xs);
|
||||
|
||||
display: none;
|
||||
color: #{sass.color(ui-gray, 400)};
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
|
||||
$line-height: math.div(20, 12);
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
--background: transparent;
|
||||
--box-shadow-color: transparent;
|
||||
|
||||
@include type.type(s, semibold);
|
||||
@include type.type(s);
|
||||
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
display: inline-block;
|
||||
color: var(--color);
|
||||
background-color: var(--background);
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
* Mixin for specifying a font-size/line-height size on an element, with
|
||||
* an option for specifying weight.
|
||||
*/
|
||||
@mixin type($size, $weight: &) {
|
||||
@mixin type($size) {
|
||||
font-size: typography.font-size($size);
|
||||
line-height: typography.line-height($size);
|
||||
@if $weight {
|
||||
font-weight: typography.font-weight($weight);
|
||||
}
|
||||
}
|
||||
@@ -11,29 +11,6 @@
|
||||
* SASS variables defining typography.
|
||||
*/
|
||||
|
||||
$family-primary:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
blinkmacsystemfont,
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'Oxygen',
|
||||
'Ubuntu',
|
||||
'Cantarell',
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
$family-monospace:
|
||||
'SFMono-Regular', consolas, 'Liberation Mono', menlo, courier, monospace;
|
||||
|
||||
// Available font weights by name
|
||||
$font-weights: (
|
||||
normal: 400,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
);
|
||||
|
||||
// unitless value to start, but think pixels or points
|
||||
$base-font-size: 16;
|
||||
|
||||
@@ -84,10 +61,3 @@ $line-heights: (
|
||||
@function line-height($name) {
|
||||
@return map.get($line-heights, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a named font weight.
|
||||
*/
|
||||
@function font-weight($name) {
|
||||
@return map.get($font-weights, $name);
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.26.10",
|
||||
"@hashicorp/design-system-components": "^4.20.2",
|
||||
"@hashicorp/design-system-components": "^4.24.1",
|
||||
"@hashicorp/design-system-tokens": "^2.3.0",
|
||||
"@hashicorp/flight-icons": "^3.10.0",
|
||||
"@nullvoxpopuli/ember-composable-helpers": "^5.2.10",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
width: 9rem;
|
||||
|
||||
// border: 1px solid var(--ui-gray-subtler-4);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--token-border-radius-small);
|
||||
margin: 0 0.5rem 0.5rem 0 !important;
|
||||
box-shadow: 0 3px 6px rgba(var(--stark-components), var(--opacity-3));
|
||||
list-style-type: none;
|
||||
@@ -36,7 +36,7 @@
|
||||
.swatch-chip {
|
||||
height: 5rem;
|
||||
border: 0.5rem solid var(--subtle);
|
||||
border-radius: 4px;
|
||||
border-radius: var(--token-border-radius-small);
|
||||
}
|
||||
|
||||
.swatch-detail {
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
es2021: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'no-empty-pattern': ['error', { allowObjectPatternsAsParameters: true }],
|
||||
},
|
||||
overrides: [
|
||||
|
||||
@@ -147,11 +147,11 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
await this.page.getByRole('link', { name: 'New Credential' }).click();
|
||||
}
|
||||
|
||||
await this.page.getByLabel('Name (Optional)').fill(credentialName);
|
||||
await this.page.getByLabel('Name', { exact: true }).fill(credentialName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByRole('combobox', { name: 'Type' }).click();
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username & Key Pair')
|
||||
.getByRole('option', { name: 'Username & Key Pair' })
|
||||
.click();
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
@@ -207,11 +207,11 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
}
|
||||
|
||||
const credentialName = 'Credential ' + nanoid();
|
||||
await this.page.getByLabel('Name (Optional)').fill(credentialName);
|
||||
await this.page.getByLabel('Name', { exact: true }).fill(credentialName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByRole('combobox', { name: 'Type' }).click();
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username & Password')
|
||||
.getByRole('option', { name: 'Username & Password' })
|
||||
.click();
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
@@ -271,11 +271,11 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
}
|
||||
|
||||
const credentialName = 'Credential ' + nanoid();
|
||||
await this.page.getByLabel('Name (Optional)').fill(credentialName);
|
||||
await this.page.getByLabel('Name', { exact: true }).fill(credentialName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByRole('combobox', { name: 'Type' }).click();
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Username, Password & Domain')
|
||||
.getByRole('option', { name: 'Username, Password & Domain' })
|
||||
.click();
|
||||
await this.page
|
||||
.getByLabel('Username Required', { exact: true })
|
||||
@@ -308,11 +308,9 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
await this.page.getByRole('link', { name: 'Credential Libraries' }).click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page
|
||||
.getByLabel('Name (Optional)', { exact: true })
|
||||
.getByLabel('Name', { exact: true })
|
||||
.fill(credentialLibraryName);
|
||||
await this.page
|
||||
.getByLabel('Description (Optional)')
|
||||
.fill('This is an automated test');
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('Generic Secrets')
|
||||
@@ -345,11 +343,9 @@ export class CredentialStoresPage extends BaseResourcePage {
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
|
||||
await this.page
|
||||
.getByLabel('Name (Optional)', { exact: true })
|
||||
.getByLabel('Name', { exact: true })
|
||||
.fill(credentialLibraryName);
|
||||
await this.page
|
||||
.getByLabel('Description (Optional)')
|
||||
.fill('This is an automated test');
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('SSH Certificates')
|
||||
|
||||
@@ -19,8 +19,7 @@ export class LoginPage extends BaseResourcePage {
|
||||
await this.page.getByRole('button', { name: 'Sign In' }).click();
|
||||
await expect(
|
||||
this.page.getByRole('navigation', {
|
||||
// TODO: Uncomment line below once both UIs have been refactored to use HDS side nav.
|
||||
// name: 'Application local navigation',
|
||||
name: 'Application local navigation',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(this.page.getByText(loginName)).toBeEnabled();
|
||||
|
||||
@@ -60,16 +60,15 @@ export class OrgsPage extends BaseResourcePage {
|
||||
async chooseScopeFromDropdown(fromScope, toScope) {
|
||||
await this.page
|
||||
.getByRole('button', { name: fromScope, exact: true })
|
||||
.click()
|
||||
await this.page
|
||||
.getByRole('option', { name: toScope, exact: true })
|
||||
.click()
|
||||
.click();
|
||||
await this.page.getByRole('option', { name: toScope, exact: true }).click();
|
||||
await expect(
|
||||
this.page.getByRole('button', { name: toScope, exact: true })
|
||||
).toBeVisible()
|
||||
if (toScope != 'Global')
|
||||
this.page.getByRole('button', { name: toScope, exact: true }),
|
||||
).toBeVisible();
|
||||
if (toScope !== 'Global') {
|
||||
await expect(
|
||||
this.page.getByRole('link', { name: toScope, exact: true })
|
||||
).toBeVisible()
|
||||
this.page.getByRole('link', { name: toScope, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,73 +9,15 @@ import { nanoid } from 'nanoid';
|
||||
import { BaseResourcePage } from './base-resource.js';
|
||||
|
||||
export class TargetsPage extends BaseResourcePage {
|
||||
/**
|
||||
* Creates a new target. Assumes you have selected the desired project.
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createTarget(port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new target with address. Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createTargetWithAddress(address, port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new target with address and alias. Assumes you have selected the desired project.
|
||||
* @param {string} targetType Type of target (ssh, rdp, tcp)
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @param {string} alias alias used for the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createTargetWithAddressAndAlias(
|
||||
address,
|
||||
port,
|
||||
alias,
|
||||
targetType = 'tcp',
|
||||
) {
|
||||
async createTarget({ targetType, port, address, alias }) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
let targetTypeLabel;
|
||||
|
||||
@@ -92,9 +34,6 @@ export class TargetsPage extends BaseResourcePage {
|
||||
targetTypeLabel = 'Generic TCP';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unexpected target type passed ${targetType}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.page
|
||||
@@ -104,258 +43,26 @@ export class TargetsPage extends BaseResourcePage {
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page.getByLabel(targetTypeLabel).click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Aliases' })
|
||||
.getByLabel('value')
|
||||
.last()
|
||||
.fill(alias);
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new TCP target with address in boundary-enterprise
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createTcpTargetWithAddressEnt(address, port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('TCP')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SSH target. Assumes you have selected the desired project.
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createSshTargetEnt(port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('SSH')
|
||||
.click();
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SSH target with address in boundary-enterprise
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createSshTargetWithAddressEnt(address, port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('SSH')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SSH target with address and alias.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @param {string} alias alias used for the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createSshTargetWithAddressAndAlias(address, port, alias) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('SSH')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Aliases' })
|
||||
.getByLabel('value')
|
||||
.last()
|
||||
.fill(alias);
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createRdpTargetEnt(port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target with address in boundary-enterprise
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
async createRDPTargetWithAddressEnt(address, port) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
await expect(
|
||||
this.page
|
||||
.getByRole('navigation', { name: 'breadcrumbs' })
|
||||
.getByText(targetName),
|
||||
).toBeVisible();
|
||||
|
||||
return targetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new RDP target with address and alias.
|
||||
* Assumes you have selected the desired project.
|
||||
* @param {string} address Address of the target
|
||||
* @param {string} port Port of the target
|
||||
* @param {string} alias alias used for the target
|
||||
* @returns Name of the target
|
||||
*/
|
||||
|
||||
async createRdpTargetWithAddressAndAlias(address, port, alias) {
|
||||
const targetName = 'Target ' + nanoid();
|
||||
await this.page
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Targets' })
|
||||
.click();
|
||||
await this.page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await this.page.getByLabel('Name').fill(targetName);
|
||||
await this.page.getByLabel('Description').fill('This is an automated test');
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('RDP')
|
||||
.click();
|
||||
await this.page.getByLabel('Target Address').fill(address);
|
||||
await this.page.getByLabel('Default Port').fill(port);
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Aliases' })
|
||||
.getByLabel('value')
|
||||
.last()
|
||||
.fill(alias);
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
if (targetType) {
|
||||
if (targetTypeLabel) {
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel(targetTypeLabel)
|
||||
.click();
|
||||
} else {
|
||||
throw new Error(`Unsupported target type: ${targetType}`);
|
||||
}
|
||||
}
|
||||
if (address) await this.page.getByLabel('Target Address').fill(address);
|
||||
if (port) await this.page.getByLabel('Default Port').fill(port);
|
||||
if (alias) {
|
||||
await this.page
|
||||
.getByRole('group', { name: 'Aliases' })
|
||||
.getByLabel('value')
|
||||
.last()
|
||||
.fill(alias);
|
||||
await this.page.getByRole('button', { name: 'Add' }).click();
|
||||
}
|
||||
|
||||
await this.page.getByRole('button', { name: 'Save' }).click();
|
||||
await this.dismissSuccessAlert();
|
||||
|
||||
@@ -40,7 +40,7 @@ export class WorkersPage extends BaseResourcePage {
|
||||
await this.page
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: origKey }) })
|
||||
.getByRole('cell', { name: 'Overflow Options' })
|
||||
.getByRole('button', { name: 'Overflow Options' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Edit Tag' }).click();
|
||||
|
||||
@@ -59,7 +59,7 @@ export class WorkersPage extends BaseResourcePage {
|
||||
await this.page
|
||||
.getByRole('row')
|
||||
.filter({ has: this.page.getByRole('cell', { name: key }) })
|
||||
.getByRole('cell', { name: 'Overflow Options' })
|
||||
.getByRole('button', { name: 'Overflow Options' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: 'Remove Tag' }).click();
|
||||
|
||||
|
||||
@@ -46,10 +46,11 @@ test.describe('Aliases (Enterprise)', () => {
|
||||
await projectsPage.createProject();
|
||||
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
@@ -165,12 +166,12 @@ test.describe('Aliases (Enterprise)', () => {
|
||||
await projectsPage.createProject();
|
||||
alias = 'example.alias.' + nanoid();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddressAndAlias(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
alias,
|
||||
'ssh',
|
||||
);
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
@@ -236,10 +237,11 @@ test.describe('Aliases (Enterprise)', () => {
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
|
||||
@@ -44,10 +44,10 @@ test.describe('Aliases', () => {
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddress(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
// Create alias for target
|
||||
const aliasName = 'Alias ' + nanoid();
|
||||
@@ -148,11 +148,11 @@ test.describe('Aliases', () => {
|
||||
await projectsPage.createProject();
|
||||
alias = 'example.alias.' + nanoid();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddressAndAlias(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
alias,
|
||||
);
|
||||
});
|
||||
|
||||
// Connect to target using alias
|
||||
connect = await boundaryCli.connectToAlias(alias, sshUser, sshKeyPath);
|
||||
@@ -206,10 +206,10 @@ test.describe('Aliases', () => {
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddress(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
// Create new alias from scope page
|
||||
await boundaryCli.authenticateBoundary(
|
||||
|
||||
@@ -55,7 +55,10 @@ test(
|
||||
.getByRole('navigation', { name: 'Application local navigation' })
|
||||
.getByRole('link', { name: 'Auth Methods' })
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'New' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Auth Methods' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'New', exact: true }).click();
|
||||
await page.getByRole('link', { name: 'LDAP' }).click();
|
||||
|
||||
const ldapAuthMethodName = 'LDAP ' + nanoid();
|
||||
|
||||
@@ -133,9 +133,7 @@ test(
|
||||
).toBeVisible();
|
||||
|
||||
// Log back in as an admin
|
||||
await page.getByRole('button', { name: 'User Menu' }).click();
|
||||
await page.getByRole('button', { name: 'Sign Out' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Sign In' })).toBeVisible();
|
||||
await loginPage.logout(email);
|
||||
await loginPage.login(adminLoginName, adminPassword);
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'breadcrumbs' }).getByText('Orgs'),
|
||||
|
||||
@@ -41,10 +41,11 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
|
||||
@@ -31,10 +31,10 @@ test.beforeEach(async ({ page, targetAddress, targetPort }) => {
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
targetName = await targetsPage.createTargetWithAddress(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
});
|
||||
@@ -191,12 +191,10 @@ test(
|
||||
.getByRole('link', { name: 'Credentials', exact: true })
|
||||
.click();
|
||||
await page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await page.getByLabel('Name (Optional)').fill(credentialName);
|
||||
await page.getByLabel('Name', { exact: true }).fill(credentialName);
|
||||
await page.getByLabel('Description').fill('This is an automated test');
|
||||
await page
|
||||
.getByRole('group', { name: 'Type' })
|
||||
.getByLabel('JSON')
|
||||
.click();
|
||||
await page.getByRole('combobox', { name: 'Type' }).click();
|
||||
await page.getByRole('option', { name: 'JSON' }).click();
|
||||
await page.getByText('{}').click();
|
||||
const testName = 'name-json';
|
||||
const testPassword = 'password-json';
|
||||
|
||||
@@ -68,10 +68,11 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createVaultCredentialStoreWithWorkerFilter(
|
||||
vaultAddrPrivate,
|
||||
|
||||
@@ -85,10 +85,10 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddress(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createVaultCredentialStore(
|
||||
vaultAddr,
|
||||
@@ -99,11 +99,9 @@ test(
|
||||
await page.getByRole('link', { name: 'Credential Libraries' }).click();
|
||||
await page.getByRole('link', { name: 'New', exact: true }).click();
|
||||
await page
|
||||
.getByLabel('Name (Optional)', { exact: true })
|
||||
.getByLabel('Name', { exact: true })
|
||||
.fill(credentialLibraryName);
|
||||
await page
|
||||
.getByLabel('Description (Optional)')
|
||||
.fill('This is an automated test');
|
||||
await page.getByLabel('Description').fill('This is an automated test');
|
||||
await page
|
||||
.getByLabel('Vault Path')
|
||||
.fill(`${secretsPath}/data/${secretName}`);
|
||||
|
||||
@@ -156,7 +156,10 @@ test.describe('AWS', () => {
|
||||
|
||||
// Create a target and add DHC host set as a host source
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetEnt(targetPort);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
});
|
||||
await targetsPage.addHostSourceToTarget(hostSetName);
|
||||
|
||||
// Add another host source
|
||||
|
||||
@@ -156,7 +156,9 @@ test.describe('AWS', () => {
|
||||
|
||||
// Create a target and add DHC host set as a host source
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTarget(targetPort);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
});
|
||||
await targetsPage.addHostSourceToTarget(hostSetName);
|
||||
|
||||
// Add another host source
|
||||
|
||||
@@ -93,10 +93,11 @@ test(
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
await targetsPage.addEgressWorkerFilterToTarget(
|
||||
`"${workerTagEgress}" in "/tags/type"`,
|
||||
);
|
||||
|
||||
@@ -97,10 +97,11 @@ test(
|
||||
await page.getByRole('link', { name: orgName }).click();
|
||||
await page.getByRole('link', { name: projectName }).click();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
await targetsPage.addIngressWorkerFilterToTarget(
|
||||
`"${workerTagEgress}" in "/tags/type"`,
|
||||
);
|
||||
|
||||
@@ -99,10 +99,11 @@ test(
|
||||
|
||||
// Create target
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
// Create credentials
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
|
||||
@@ -89,10 +89,11 @@ test(
|
||||
|
||||
// Create target
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
// Create credentials
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
|
||||
@@ -41,10 +41,11 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTcpTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'tcp',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
await boundaryCli.authenticateBoundary(
|
||||
controllerAddr,
|
||||
@@ -108,10 +109,11 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
const credentialStoresPage = new CredentialStoresPage(page);
|
||||
await credentialStoresPage.createStaticCredentialStore();
|
||||
const credentialName =
|
||||
@@ -194,7 +196,10 @@ test(
|
||||
|
||||
// Create target
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createSshTargetEnt(targetPort);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
targetType: 'ssh',
|
||||
port: targetPort,
|
||||
});
|
||||
await targetsPage.addHostSourceToTarget(hostSetName);
|
||||
|
||||
// Add/Remove another host source
|
||||
@@ -285,10 +290,11 @@ test(
|
||||
);
|
||||
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const rdpTarget = await targetsPage.createRDPTargetWithAddressEnt(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const rdpTarget = await targetsPage.createTarget({
|
||||
targetType: 'rdp',
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
await targetsPage.addBrokeredCredentialsToTarget(
|
||||
rdpTarget,
|
||||
credentialName,
|
||||
|
||||
@@ -57,7 +57,7 @@ test(
|
||||
|
||||
// Create target
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTarget(targetPort);
|
||||
const targetName = await targetsPage.createTarget({ port: targetPort });
|
||||
await targetsPage.addHostSourceToTarget(hostSetName);
|
||||
|
||||
// Add/Remove another host source
|
||||
@@ -127,10 +127,10 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
const projectName = await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
const targetName = await targetsPage.createTargetWithAddress(
|
||||
targetAddress,
|
||||
targetPort,
|
||||
);
|
||||
const targetName = await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
await boundaryCli.authenticateBoundary(
|
||||
controllerAddr,
|
||||
@@ -191,7 +191,10 @@ test(
|
||||
const projectsPage = new ProjectsPage(page);
|
||||
await projectsPage.createProject();
|
||||
const targetsPage = new TargetsPage(page);
|
||||
await targetsPage.createTargetWithAddress(targetAddress, targetPort);
|
||||
await targetsPage.createTarget({
|
||||
port: targetPort,
|
||||
address: targetAddress,
|
||||
});
|
||||
|
||||
// Update target
|
||||
await page.getByRole('button', { name: 'Edit Form' }).click();
|
||||
@@ -234,7 +237,9 @@ test(
|
||||
.locator('.CodeMirror')
|
||||
.getByRole('textbox')
|
||||
.click({ force: true });
|
||||
await page.keyboard.press('Meta+A');
|
||||
const selectAllShortcut =
|
||||
process.platform === 'darwin' ? 'Meta+A' : 'Control+A';
|
||||
await page.keyboard.press(selectAllShortcut);
|
||||
await page.keyboard.press('Backspace');
|
||||
await page
|
||||
.locator('.CodeMirror')
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import { expect, test } from '../fixtures/baseTest.js';
|
||||
import * as boundaryHttp from '../../helpers/boundary-http.js';
|
||||
import { textToMatch } from '../fixtures/tesseractTest.js';
|
||||
import {
|
||||
isRdpClientInstalled,
|
||||
isRdpRunning,
|
||||
killRdpProcesses,
|
||||
isOSForRdpSupported,
|
||||
} from '../../helpers/rdp.js';
|
||||
|
||||
const hostName = 'Host name for test';
|
||||
let org;
|
||||
@@ -117,7 +123,6 @@ test.beforeEach(
|
||||
});
|
||||
|
||||
// Create an RDP target and add host source and credential sources
|
||||
// TODO: A test for RDP target connection will be added later when the Proxy is in place.
|
||||
rdpTarget = await boundaryHttp.createTarget(request, {
|
||||
scopeId: project.id,
|
||||
type: 'rdp',
|
||||
@@ -285,4 +290,37 @@ test.describe('Targets tests', () => {
|
||||
authedPage.getByRole('link', { name: targetWithHost.name }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Launches RDP client when connecting to an RDP target', async ({
|
||||
authedPage,
|
||||
}) => {
|
||||
const isRdpClientInstalledCheck = await isRdpClientInstalled();
|
||||
const isOSForRdpSupportedCheck = await isOSForRdpSupported();
|
||||
|
||||
test.skip(
|
||||
!isRdpClientInstalledCheck || !isOSForRdpSupportedCheck,
|
||||
'RDP client is not installed/supported on this system',
|
||||
);
|
||||
|
||||
const beforeLaunchRunning = await isRdpRunning();
|
||||
expect(beforeLaunchRunning).toBe(false);
|
||||
|
||||
await authedPage.getByRole('link', { name: rdpTarget.name }).click();
|
||||
await authedPage.getByRole('button', { name: 'Open' }).click();
|
||||
|
||||
await expect(
|
||||
authedPage.getByRole('heading', { name: 'Sessions' }),
|
||||
).toBeVisible();
|
||||
|
||||
const afterLaunchRunning = await isRdpRunning();
|
||||
expect(afterLaunchRunning).toBe(true);
|
||||
|
||||
await authedPage.getByRole('button', { name: 'End Session' }).click();
|
||||
await expect(authedPage.getByText('Canceled successfully.')).toBeVisible();
|
||||
|
||||
killRdpProcesses();
|
||||
|
||||
const afterKillRunning = await isRdpRunning();
|
||||
expect(afterKillRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
export async function isOSForRdpSupported() {
|
||||
return process.platform === 'win32' || process.platform === 'darwin';
|
||||
}
|
||||
|
||||
export async function isRdpRunning() {
|
||||
try {
|
||||
let result;
|
||||
if (process.platform === 'win32') {
|
||||
result = execSync('tasklist /FI "IMAGENAME eq mstsc.exe" /FO CSV /NH', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return result && result.includes('mstsc.exe');
|
||||
} else if (process.platform === 'darwin') {
|
||||
result = execSync('pgrep -x "Windows App"', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return result && result.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isRdpClientInstalled() {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync('where mstsc', { stdio: 'ignore' });
|
||||
return true;
|
||||
} else if (process.platform === 'darwin') {
|
||||
const result = execSync(
|
||||
'mdfind "kMDItemCFBundleIdentifier == \'com.microsoft.rdc.macos\'"',
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
return result && result.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function killRdpProcesses() {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync('taskkill /F /IM mstsc.exe', { stdio: 'ignore' });
|
||||
} else if (process.platform === 'darwin') {
|
||||
execSync('pkill -x "Windows App"', { stdio: 'ignore' });
|
||||
}
|
||||
} catch {
|
||||
// no op
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,8 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.10",
|
||||
"@babel/eslint-parser": "^7.21.3",
|
||||
"@openapitools/openapi-generator-cli": "^2.23.4",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@openapitools/openapi-generator-cli": "^2.25.2",
|
||||
"@playwright/test": "^1.56.1",
|
||||
"concurrently": "^9.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"eslint": "^8.54.0",
|
||||
|
||||
Loaded 100 of 367 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user