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 | ||
|
|
72639bd6a3 | ||
|
|
31f0044c0f | ||
|
|
d0924f7e6d | ||
|
|
d2616acf36 | ||
|
|
dfd116bd56 | ||
|
|
830acef847 | ||
|
|
ee7c916494 | ||
|
|
46d477ea5b | ||
|
|
177b4af695 | ||
|
|
d2b8805605 | ||
|
|
39dedc68ec | ||
|
|
bd67756cda | ||
|
|
dd37fa4270 | ||
|
|
3c5c048152 | ||
|
|
dd6e4ef2d0 | ||
|
|
bce79f3488 | ||
|
|
74793a2399 | ||
|
|
6b120b053a | ||
|
|
1d0d62337e | ||
|
|
df8bfc42bb | ||
|
|
ff087738fc | ||
|
|
27567c88c9 | ||
|
|
9077a747d6 | ||
|
|
b359d9f525 | ||
|
|
ad7bd2e52d | ||
|
|
36aed9588b | ||
|
|
f8c81a29c4 | ||
|
|
c91771e152 | ||
|
|
3b7873302b | ||
|
|
d79bd54f0b | ||
|
|
51dc9a0e49 | ||
|
|
b49e55d400 | ||
|
|
33cb7e0a50 | ||
|
|
9edc90306e | ||
|
|
0a75e05b28 | ||
|
|
4a179ac3e3 | ||
|
|
42e32f9568 | ||
|
|
0223755b2a | ||
|
|
30a5f2a701 | ||
|
|
d2d351c9f7 | ||
|
|
e1696e9a4d | ||
|
|
25f015b179 | ||
|
|
9e263379a7 | ||
|
|
4c7ea161cd | ||
|
|
dc026c44a1 | ||
|
|
cb8b21c10c | ||
|
|
47c1beadcd | ||
|
|
8f7eb2abea | ||
|
|
c2b0f321de | ||
|
|
ba7b8b8824 | ||
|
|
9e612cf83d | ||
|
|
2154251c30 | ||
|
|
2d38266609 | ||
|
|
d414df4ca4 | ||
|
|
eab93cf6c5 | ||
|
|
3304def44a | ||
|
|
a4bf02b21b | ||
|
|
e0982da744 | ||
|
|
8abed1a809 | ||
|
|
a9a6518b23 | ||
|
|
1bafbe1ad2 | ||
|
|
a26074a3b8 | ||
|
|
90fd2f1447 | ||
|
|
e59690e703 | ||
|
|
bf06e9f654 | ||
|
|
84359d8e79 | ||
|
|
7801429530 | ||
|
|
dc9376a460 | ||
|
|
1f4076d8d4 | ||
|
|
f202d0f533 | ||
|
|
4344dcbb36 | ||
|
|
87d6bbae2a | ||
|
|
7a42bf456e | ||
|
|
a51f1eee8e | ||
|
|
5b72a916e4 | ||
|
|
42013a919f | ||
|
|
3918f82e14 | ||
|
|
6e79a2a133 | ||
|
|
088deaed3a | ||
|
|
7504e7710c | ||
|
|
bb7352ca54 | ||
|
|
b42ecbff36 | ||
|
|
5fd9f0216a | ||
|
|
942ea99459 | ||
|
|
990e2d6b9d | ||
|
|
f776ba0617 | ||
|
|
fb57965457 | ||
|
|
b99fce7d55 | ||
|
|
0a2d743ea2 | ||
|
|
fbd613084d | ||
|
|
4cd56f8ae5 | ||
|
|
c0d41c0a62 | ||
|
|
389c635030 | ||
|
|
d93a0c170c | ||
|
|
aa18f599ce | ||
|
|
aa7eb2e844 |
No files matched your search
+3
-2
@@ -13,9 +13,10 @@ project {
|
|||||||
"**/node_modules/**",
|
"**/node_modules/**",
|
||||||
"**/dist/**",
|
"**/dist/**",
|
||||||
"**/coverage/**",
|
"**/coverage/**",
|
||||||
"ui/desktop/electron-app/out/**",
|
|
||||||
"ui/desktop/electron-app/ember-dist/**",
|
|
||||||
"**/pnpm-lock.yaml",
|
"**/pnpm-lock.yaml",
|
||||||
"**/pnpm-workspace.yaml",
|
"**/pnpm-workspace.yaml",
|
||||||
|
"ui/desktop/electron-app/out/**",
|
||||||
|
"ui/desktop/electron-app/ember-dist/**",
|
||||||
|
"e2e-tests/api-client/**"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,17 @@
|
|||||||
## Checklist
|
## Checklist
|
||||||
<!-- strikethrough the checklist item that is not relevant to your change -->
|
<!-- strikethrough the checklist item that is not relevant to your change -->
|
||||||
|
|
||||||
|
<!-- If you are merging a long lived branch, major feature, or UI altering changes,
|
||||||
|
please add the a11y-tests label. Other cases, like a dependency change or
|
||||||
|
translation update likely does not need an a11y audit -->
|
||||||
|
|
||||||
- [ ] I have added before and after screenshots for UI changes
|
- [ ] I have added before and after screenshots for UI changes
|
||||||
- [ ] I have added JSON response output for API changes
|
- [ ] I have added JSON response output for API changes
|
||||||
- [ ] I have added steps to reproduce and test for bug fixes in the description
|
- [ ] I have added steps to reproduce and test for bug fixes in the description
|
||||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||||
- [ ] My changes generate no new warnings
|
- [ ] My changes generate no new warnings
|
||||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||||
|
- [ ] I have added `a11y-tests` label to run a11y audit tests if needed
|
||||||
|
|
||||||
## PCI review checklist
|
## PCI review checklist
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
name: A11y Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [labeled, opened, synchronize, reopened]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
a11y-test:
|
||||||
|
name: Run A11y Tests
|
||||||
|
if: contains(github.event.pull_request.labels.*.name, 'a11y-tests')
|
||||||
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||||
|
with:
|
||||||
|
install-dependencies: true
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: '**/pnpm-lock.yaml'
|
||||||
|
- run: pnpm install
|
||||||
|
- name: Run a11y tests
|
||||||
|
env:
|
||||||
|
# this will generate the test reports but causes tests to pass even when there a11y failures
|
||||||
|
ENABLE_A11Y_MIDDLEWARE_REPORTER: true
|
||||||
|
run: pnpm test-a11y
|
||||||
|
- name: Consolidate ember a11y reports
|
||||||
|
run: |
|
||||||
|
mkdir ember-a11y-reports
|
||||||
|
jq -s 'map(.[])' ./ui/admin/ember-a11y-report/*.json > ember-a11y-reports/admin.json
|
||||||
|
jq -s 'map(.[])' ./ui/desktop/ember-a11y-report/*.json > ember-a11y-reports/desktop.json
|
||||||
|
- name: Output ember a11y test report summary (admin)
|
||||||
|
run: 'cat ember-a11y-reports/admin.json | jq "map({moduleName,testName,routes,urls,violations: .violations | map({id, nodes: .nodes | map({failureSummary, targets: .[].[]?.relatedNodes?.[]?.target? })}) })"'
|
||||||
|
- 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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
|
with:
|
||||||
|
name: ember-a11y-reports
|
||||||
|
path: ./ember-a11y-reports
|
||||||
|
- name: Report a11y test results status
|
||||||
|
if: always()
|
||||||
|
# fail CI when the combined array length of admin and desktop a11y reports is not 0
|
||||||
|
run: |
|
||||||
|
EMBER_A11Y_FAILURE_COUNT=$(jq -s 'map(.[]) | length' ember-a11y-reports/admin.json ember-a11y-reports/desktop.json)
|
||||||
|
echo "$EMBER_A11Y_FAILURE_COUNT ember-a11y test failures"
|
||||||
|
if [ "$EMBER_A11Y_FAILURE_COUNT" != '0' ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -11,11 +11,11 @@ jobs:
|
|||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -26,12 +26,12 @@ jobs:
|
|||||||
needs: [dependencies]
|
needs: [dependencies]
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -39,12 +39,12 @@ jobs:
|
|||||||
- run: pnpm install --offline
|
- run: pnpm install --offline
|
||||||
- run: pnpm run build:ui:admin:oss
|
- run: pnpm run build:ui:admin:oss
|
||||||
- name: Upload artifact admin-ui
|
- name: Upload artifact admin-ui
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: admin-ui
|
name: admin-ui
|
||||||
path: ./ui/admin/dist/
|
path: ./ui/admin/dist/
|
||||||
- name: Upload artifact OSS admin UI
|
- name: Upload artifact OSS admin UI
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: admin-ui-oss
|
name: admin-ui-oss
|
||||||
path: ./ui/admin/dist/
|
path: ./ui/admin/dist/
|
||||||
@@ -53,12 +53,12 @@ jobs:
|
|||||||
needs: [dependencies]
|
needs: [dependencies]
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- run: pnpm install --offline
|
- run: pnpm install --offline
|
||||||
- run: pnpm run build:ui:admin:enterprise
|
- run: pnpm run build:ui:admin:enterprise
|
||||||
- name: Upload artifact enterprise admin UI
|
- name: Upload artifact enterprise admin UI
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: admin-ui-ent
|
name: admin-ui-ent
|
||||||
path: ./ui/admin/dist/
|
path: ./ui/admin/dist/
|
||||||
@@ -75,12 +75,12 @@ jobs:
|
|||||||
needs: [dependencies]
|
needs: [dependencies]
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Build
|
- name: Build
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
- run: pnpm install --offline
|
- run: pnpm install --offline
|
||||||
- run: pnpm run build:ui:admin:hcp
|
- run: pnpm run build:ui:admin:hcp
|
||||||
- name: Upload artifact HCP admin UI
|
- name: Upload artifact HCP admin UI
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: admin-ui-hcp
|
name: admin-ui-hcp
|
||||||
path: ./ui/admin/dist/
|
path: ./ui/admin/dist/
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
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 }}
|
||||||
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
||||||
- name: Add missing Copyright Headers
|
- name: Add missing Copyright Headers
|
||||||
run: copywrite 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:
|
with:
|
||||||
commit_message: Add missing copyright headers
|
commit_message: Add missing copyright headers
|
||||||
@@ -6,6 +6,6 @@ jobs:
|
|||||||
triage:
|
triage:
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||||
with:
|
with:
|
||||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
@@ -6,11 +6,11 @@ jobs:
|
|||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
needs: [dependencies]
|
needs: [dependencies]
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -40,11 +40,11 @@ jobs:
|
|||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -58,14 +58,14 @@ jobs:
|
|||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3
|
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||||
with:
|
with:
|
||||||
install-dependencies: true
|
install-dependencies: true
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ jobs:
|
|||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||||
- run: |
|
- 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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [labeled, opened, synchronize, reopened]
|
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:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
if: ${{contains(github.event.pull_request.labels.*.name, 'desktop')}}
|
name: test
|
||||||
name: Run Desktop E2E Tests
|
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||||
runs-on: ubuntu-22.04
|
|
||||||
env:
|
env:
|
||||||
ENOS_VAR_boundary_edition: "ent"
|
ENOS_VAR_boundary_edition: "ent"
|
||||||
ENOS_VAR_e2e_debug_no_run: true
|
ENOS_VAR_e2e_debug_no_run: true
|
||||||
ENOS_VAR_boundary_license_path: "./license.hclic"
|
|
||||||
SETUP_CLI: true
|
SETUP_CLI: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up pnpm
|
- name: Set up pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||||
with:
|
with:
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -38,7 +43,7 @@ jobs:
|
|||||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||||
|
|
||||||
- name: Set up Enos
|
- name: Set up Enos
|
||||||
uses: hashicorp/action-setup-enos@b9fa53484a1e8fdcc7b02a118bcf01d65b9414c9
|
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
@@ -48,7 +53,7 @@ jobs:
|
|||||||
unzip /tmp/vault.zip -d /usr/local/bin
|
unzip /tmp/vault.zip -d /usr/local/bin
|
||||||
|
|
||||||
- name: Checkout boundary-enterprise
|
- name: Checkout boundary-enterprise
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
with:
|
with:
|
||||||
repository: hashicorp/boundary-enterprise
|
repository: hashicorp/boundary-enterprise
|
||||||
# ref: ${{ github.event.inputs.boundary-enterprise-branch }}
|
# ref: ${{ github.event.inputs.boundary-enterprise-branch }}
|
||||||
@@ -72,16 +77,14 @@ jobs:
|
|||||||
docker pull hashicorp/boundary-enterprise:latest
|
docker pull hashicorp/boundary-enterprise:latest
|
||||||
docker save hashicorp/boundary-enterprise:latest | gzip > /tmp/boundary-enterprise.zip
|
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
|
- name: Set up test infra
|
||||||
id: infra
|
id: infra
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.BOUNDARY_ENT_LICENSE }}" > ./support/src/boundary-enterprise/enos/license.hclic
|
|
||||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||||
mkdir -p ./enos/terraform-plugin-cache
|
mkdir -p ./enos/terraform-plugin-cache
|
||||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
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 && \
|
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
|
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
|
- name: Upload Playwright report
|
||||||
if: ${{ failure() }}
|
if: ${{ failure() }}
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
path: e2e-tests/playwright-report
|
path: e2e-tests/playwright-report
|
||||||
@@ -113,3 +116,13 @@ jobs:
|
|||||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
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
|
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:
|
trigger-gha-release:
|
||||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Release description
|
- name: Release description
|
||||||
run: |
|
run: |
|
||||||
echo "Triggering release with a TAG ${GITHUB_REF#refs/tags/}"
|
echo "Triggering release with a TAG ${GITHUB_REF#refs/tags/}"
|
||||||
|
|||||||
Binary file not shown.
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
curly: ['error', 'multi-line', 'consistent'],
|
||||||
'ember/no-get': 'off',
|
'ember/no-get': 'off',
|
||||||
'ember/no-get-with-default': 'off',
|
'ember/no-get-with-default': 'off',
|
||||||
'ember/no-computed-properties-in-native-classes': 'off',
|
'ember/no-computed-properties-in-native-classes': 'off',
|
||||||
@@ -45,6 +46,7 @@ module.exports = {
|
|||||||
'./blueprints/*/index.js',
|
'./blueprints/*/index.js',
|
||||||
'./config/**/*.js',
|
'./config/**/*.js',
|
||||||
'./tests/dummy/config/**/*.js',
|
'./tests/dummy/config/**/*.js',
|
||||||
|
'./broccoli-plugins/**/*.js',
|
||||||
],
|
],
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: 'script',
|
sourceType: 'script',
|
||||||
|
|||||||
@@ -56,32 +56,33 @@ export default function setupStubs(hooks) {
|
|||||||
stubTypes = types;
|
stubTypes = types;
|
||||||
|
|
||||||
types.forEach((type, i) => {
|
types.forEach((type, i) => {
|
||||||
let models;
|
|
||||||
let resourceName;
|
|
||||||
if (typeOf(type) === 'object') {
|
|
||||||
models = type.func();
|
|
||||||
resourceName = type.resource;
|
|
||||||
} else {
|
|
||||||
models = this.server.schema[type].all().models;
|
|
||||||
resourceName = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ipcStub
|
this.ipcStub
|
||||||
.withArgs('searchCacheDaemon')
|
.withArgs('searchCacheDaemon')
|
||||||
.onCall(i)
|
.onCall(i)
|
||||||
.returns({
|
.callsFake(() => {
|
||||||
[underscore(resourceNames[singularize(resourceName)])]: models.map(
|
let models;
|
||||||
(model) => {
|
let resourceName;
|
||||||
// Use internal serializer to serialize the model correctly
|
if (typeOf(type) === 'object') {
|
||||||
// according to our mirage serializers
|
models = type.func();
|
||||||
const modelData =
|
resourceName = type.resource;
|
||||||
this.server.serializerOrRegistry.serialize(model);
|
} else {
|
||||||
|
models = this.server.schema[type].all().models;
|
||||||
|
resourceName = type;
|
||||||
|
}
|
||||||
|
|
||||||
// Serialize the data properly to standard JSON as that is what
|
return {
|
||||||
// we're expecting from the cache daemon response
|
[underscore(resourceNames[singularize(resourceName)])]:
|
||||||
return JSON.parse(JSON.stringify(modelData));
|
models.map((model) => {
|
||||||
},
|
// Use internal serializer to serialize the model correctly
|
||||||
),
|
// according to our mirage serializers
|
||||||
|
const modelData =
|
||||||
|
this.server.serializerOrRegistry.serialize(model);
|
||||||
|
|
||||||
|
// Serialize the data properly to standard JSON as that is what
|
||||||
|
// we're expecting from the cache daemon response
|
||||||
|
return JSON.parse(JSON.stringify(modelData));
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-8
@@ -3,15 +3,17 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This test helper can help set up and cleanup the database in your tests.
|
* This test helper can help set up and cleanup the database in your tests.
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
* import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||||
*
|
*
|
||||||
* module('Acceptance | my test', function(hooks) {
|
* module('Acceptance | my test', function(hooks) {
|
||||||
* setupApplicationTest(hooks);
|
* setupApplicationTest(hooks);
|
||||||
* setupIndexedDb(hooks);
|
* setupSqlite(hooks);
|
||||||
*
|
*
|
||||||
* // add your actual tests here
|
* // add your actual tests here
|
||||||
* });
|
* });
|
||||||
@@ -19,17 +21,17 @@
|
|||||||
*
|
*
|
||||||
* @param hooks
|
* @param hooks
|
||||||
*/
|
*/
|
||||||
export function setupIndexedDb(hooks) {
|
export function setupSqlite(hooks) {
|
||||||
hooks.beforeEach(async function () {
|
hooks.beforeEach(async function () {
|
||||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||||
await indexedDb.setup(`test-indexed-db-${Date.now()}`);
|
await sqliteDb.setup(`test-sqlite-${uuidv4()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
hooks.afterEach(async function () {
|
hooks.afterEach(async function () {
|
||||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||||
|
|
||||||
await indexedDb.db.delete();
|
await sqliteDb.deleteDatabase();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default setupIndexedDb;
|
export default setupSqlite;
|
||||||
@@ -59,10 +59,11 @@ export default class RoleAbility extends ModelAbility {
|
|||||||
const type = this.model.constructor.modelName;
|
const type = this.model.constructor.modelName;
|
||||||
const typeIsValid =
|
const typeIsValid =
|
||||||
type === 'user' || type === 'group' || type === 'managed-group';
|
type === 'user' || type === 'group' || type === 'managed-group';
|
||||||
if (!typeIsValid)
|
if (!typeIsValid) {
|
||||||
throw new InvalidRolePrincipalTypeError(
|
throw new InvalidRolePrincipalTypeError(
|
||||||
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`,
|
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return this.can.can(`read ${type}`, this.model);
|
return this.can.can(`read ${type}`, this.model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility {
|
|||||||
*/
|
*/
|
||||||
get canAddInjectedApplicationCredentialSources() {
|
get canAddInjectedApplicationCredentialSources() {
|
||||||
return (
|
return (
|
||||||
this.model.isSSH && this.hasAuthorizedAction('add-credential-sources')
|
(this.model.isSSH || this.model.isRDP) &&
|
||||||
|
this.hasAuthorizedAction('add-credential-sources')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -81,9 +81,10 @@ export default class GeneratedCredentialLibraryModel extends BaseModel {
|
|||||||
})
|
})
|
||||||
credential_type;
|
credential_type;
|
||||||
|
|
||||||
@attr('object-as-array', {
|
@attr('object', {
|
||||||
for: 'vault-generic',
|
for: 'vault-generic',
|
||||||
description: 'It indicates the credential mapping overrides.',
|
description: 'It indicates the credential mapping overrides.',
|
||||||
|
emptyObjectIfMissing: true,
|
||||||
})
|
})
|
||||||
credential_mapping_overrides;
|
credential_mapping_overrides;
|
||||||
|
|
||||||
|
|||||||
@@ -51,17 +51,17 @@ export default class GeneratedCredentialModel extends BaseModel {
|
|||||||
})
|
})
|
||||||
version;
|
version;
|
||||||
|
|
||||||
// =attributes (username_password, ssh_private_key)
|
// =attributes (username_password, ssh_private_key, username_password_domain)
|
||||||
@attr('string', {
|
@attr('string', {
|
||||||
for: ['username_password', 'ssh_private_key'],
|
for: ['username_password', 'ssh_private_key', 'username_password_domain'],
|
||||||
isNestedAttribute: true,
|
isNestedAttribute: true,
|
||||||
description: 'The username for credential.',
|
description: 'The username for credential.',
|
||||||
})
|
})
|
||||||
username;
|
username;
|
||||||
|
|
||||||
// =attributes (username_password)
|
// =attributes (username_password, username_password_domain, password)
|
||||||
@attr('string', {
|
@attr('string', {
|
||||||
for: 'username_password',
|
for: ['username_password', 'username_password_domain', 'password'],
|
||||||
isNestedAttribute: true,
|
isNestedAttribute: true,
|
||||||
isSecret: true,
|
isSecret: true,
|
||||||
description: 'The password for credential.',
|
description: 'The password for credential.',
|
||||||
@@ -92,4 +92,12 @@ export default class GeneratedCredentialModel extends BaseModel {
|
|||||||
description: 'The secret associated with credential.',
|
description: 'The secret associated with credential.',
|
||||||
})
|
})
|
||||||
json_object;
|
json_object;
|
||||||
|
|
||||||
|
// =attributes (username_password_domain)
|
||||||
|
@attr('string', {
|
||||||
|
for: 'username_password_domain',
|
||||||
|
isNestedAttribute: true,
|
||||||
|
description: 'The domain for credential.',
|
||||||
|
})
|
||||||
|
domain;
|
||||||
}
|
}
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { modelIndexes } from 'api/services/indexed-db';
|
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { getOwner, setOwner } from '@ember/application';
|
|
||||||
import { queryIndexedDb } from '../utils/indexed-db-query';
|
|
||||||
import { paginateResults } from '../utils/paginate-results';
|
|
||||||
import { sortResults } from '../utils/sort-results';
|
|
||||||
import { hashCode } from '../utils/hash-code';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handler to sit in front of the API request layer
|
|
||||||
* so we can handle any caching with indexedDb first
|
|
||||||
*/
|
|
||||||
export default class IndexedDbHandler {
|
|
||||||
@service indexedDb;
|
|
||||||
|
|
||||||
batchLimit = 10_000;
|
|
||||||
|
|
||||||
constructor(context) {
|
|
||||||
setOwner(this, getOwner(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
async request(context, next) {
|
|
||||||
const { batchLimit } = this;
|
|
||||||
|
|
||||||
switch (context.request.op) {
|
|
||||||
case 'query': {
|
|
||||||
const { store, data } = context.request;
|
|
||||||
// TODO: Remove storeToken option as this is a temporary fix for auth-methods.
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
query,
|
|
||||||
options: {
|
|
||||||
pushToStore = true,
|
|
||||||
peekIndexedDB = false,
|
|
||||||
storeToken = true,
|
|
||||||
} = {},
|
|
||||||
} = data;
|
|
||||||
const supportedModels = Object.keys(modelIndexes);
|
|
||||||
const { db: indexedDb } = this.indexedDb ?? {};
|
|
||||||
|
|
||||||
// Go through normal flow if we don't yet support the model
|
|
||||||
// or if we don't have an indexedDb instance
|
|
||||||
if (!supportedModels.includes(type) || !indexedDb) {
|
|
||||||
return next(context.request);
|
|
||||||
}
|
|
||||||
|
|
||||||
let { page, pageSize, query: queryObj, ...remainingQuery } = query;
|
|
||||||
|
|
||||||
const adapter = store.adapterFor(type);
|
|
||||||
const schema = store.modelFor(type);
|
|
||||||
const serializer = store.serializerFor(type);
|
|
||||||
|
|
||||||
if (!peekIndexedDB) {
|
|
||||||
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
|
||||||
let payload;
|
|
||||||
let listToken;
|
|
||||||
let writeToIndexedDbPromise;
|
|
||||||
|
|
||||||
if (storeToken) {
|
|
||||||
const tokenObj = await indexedDb.token.get(tokenKey);
|
|
||||||
listToken = tokenObj?.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
try {
|
|
||||||
payload = await adapter.query(store, schema, {
|
|
||||||
...remainingQuery,
|
|
||||||
list_token: listToken,
|
|
||||||
batchLimit: batchLimit,
|
|
||||||
});
|
|
||||||
|
|
||||||
// await the previous writeToIndexedDbPromise before writing to indexedDb again
|
|
||||||
if (writeToIndexedDbPromise) {
|
|
||||||
await writeToIndexedDbPromise;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
payload = await this.retryQueryFailure({
|
|
||||||
err,
|
|
||||||
indexedDb,
|
|
||||||
type,
|
|
||||||
adapter,
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
remainingQuery,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
listToken = payload.list_token;
|
|
||||||
|
|
||||||
// Don't await immediately and let the next query run while we write to indexedDb
|
|
||||||
writeToIndexedDbPromise = this.writeToIndexedDb({
|
|
||||||
payload,
|
|
||||||
storeToken,
|
|
||||||
indexedDb,
|
|
||||||
tokenKey,
|
|
||||||
type,
|
|
||||||
serializer,
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
} while (payload.response_type === 'delta');
|
|
||||||
|
|
||||||
await writeToIndexedDbPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Results returned in JSON API reponse, but in a format optimized for indexedDb
|
|
||||||
const indexedDbResults = await queryIndexedDb(
|
|
||||||
indexedDb,
|
|
||||||
type,
|
|
||||||
queryObj,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Normalize the results and clean up indexedDb format, ready for ember data consumption
|
|
||||||
const normalizedResults = indexedDbResults.map((item) => {
|
|
||||||
return this.indexedDb.normalizeData({
|
|
||||||
data: item,
|
|
||||||
cleanData: false,
|
|
||||||
schema,
|
|
||||||
serializer,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort the results
|
|
||||||
const sortedResults = sortResults(normalizedResults, {
|
|
||||||
querySort: queryObj?.sort,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paginate the results
|
|
||||||
const paginatedResults = paginateResults(sortedResults, page, pageSize);
|
|
||||||
|
|
||||||
// If we are not pushing to the store, use the raw data with id property
|
|
||||||
const records = pushToStore
|
|
||||||
? store.push({ data: paginatedResults })
|
|
||||||
: paginatedResults.map((record) => ({
|
|
||||||
...record.attributes,
|
|
||||||
id: record.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set a meta property on the array to store the total items of the results.
|
|
||||||
// This isn't conventional but is better than returning an ArrayProxy
|
|
||||||
// or EmberArray since the ember store query method asserts it has to be an array
|
|
||||||
// so we can't just return an object.
|
|
||||||
records.meta = { totalItems: indexedDbResults.length };
|
|
||||||
return records;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return next(context.request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async retryQueryFailure({
|
|
||||||
err,
|
|
||||||
indexedDb,
|
|
||||||
type,
|
|
||||||
adapter,
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
remainingQuery,
|
|
||||||
}) {
|
|
||||||
// If we get an invalid list token, we'll delete the token and
|
|
||||||
// clear the DB and try again without a token.
|
|
||||||
if (
|
|
||||||
err?.errors[0].status === 400 &&
|
|
||||||
err?.errors[0].code === 'invalid list token'
|
|
||||||
) {
|
|
||||||
await indexedDb[type].clear();
|
|
||||||
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
|
||||||
// Even if other tokens may still be valid, we might have cleared data that they
|
|
||||||
// depended on so we should clear all tokens of the same type when clearing the DB.
|
|
||||||
const tokenKeys = await indexedDb.token
|
|
||||||
.where(':id')
|
|
||||||
.startsWith(type)
|
|
||||||
.primaryKeys();
|
|
||||||
|
|
||||||
await indexedDb.token.bulkDelete(tokenKeys);
|
|
||||||
|
|
||||||
return await adapter.query(store, schema, {
|
|
||||||
...remainingQuery,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async writeToIndexedDb({
|
|
||||||
payload,
|
|
||||||
storeToken,
|
|
||||||
indexedDb,
|
|
||||||
tokenKey,
|
|
||||||
type,
|
|
||||||
serializer,
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
}) {
|
|
||||||
// Store the token we just got back from the payload if it exists
|
|
||||||
if (payload.list_token && storeToken) {
|
|
||||||
await indexedDb.token.put({
|
|
||||||
id: tokenKey,
|
|
||||||
token: payload.list_token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any records from the DB if the API indicates they've been deleted
|
|
||||||
if (payload.removed_ids?.length > 0) {
|
|
||||||
await indexedDb[type].bulkDelete(payload.removed_ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
|
||||||
// since we are not storing the token we do not get back a list of removed_ids
|
|
||||||
// from the API call and we do not want deleted items from showing in list view.
|
|
||||||
if (!storeToken) {
|
|
||||||
await indexedDb[type].clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPayload = serializer.normalizeResponse(
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
payload,
|
|
||||||
null,
|
|
||||||
'query',
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: payloadData } = normalizedPayload;
|
|
||||||
|
|
||||||
// Store the new data we just got back from the API refresh
|
|
||||||
const items = payloadData.map((datum) =>
|
|
||||||
this.indexedDb.normalizeData({
|
|
||||||
data: datum,
|
|
||||||
cleanData: true,
|
|
||||||
schema,
|
|
||||||
serializer,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await indexedDb[type].bulkPut(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getOwner, setOwner } from '@ember/owner';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { modelMapping } from 'api/services/sqlite';
|
||||||
|
import { get } from '@ember/object';
|
||||||
|
import { typeOf } from '@ember/utils';
|
||||||
|
import { hashCode } from '../utils/hash-code';
|
||||||
|
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||||
|
|
||||||
|
const isISODateString = (str) =>
|
||||||
|
typeOf(str) === 'string' &&
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?$/.test(str);
|
||||||
|
|
||||||
|
export default class SqliteHandler {
|
||||||
|
@service sqlite;
|
||||||
|
|
||||||
|
batchLimit = 5_000;
|
||||||
|
|
||||||
|
constructor(context) {
|
||||||
|
setOwner(this, getOwner(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(context, next) {
|
||||||
|
switch (context.request.op) {
|
||||||
|
case 'query': {
|
||||||
|
const { store, data } = context.request;
|
||||||
|
const {
|
||||||
|
type,
|
||||||
|
query,
|
||||||
|
options: {
|
||||||
|
pushToStore = true,
|
||||||
|
peekDb = false,
|
||||||
|
storeToken = true,
|
||||||
|
} = {},
|
||||||
|
} = data;
|
||||||
|
const supportedModels = Object.keys(modelMapping);
|
||||||
|
|
||||||
|
// Go through normal flow if we don't yet support the model
|
||||||
|
// or if we don't have a sqlite db instance
|
||||||
|
if (!supportedModels.includes(type) || !this.sqlite.worker) {
|
||||||
|
return next(context.request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = store.adapterFor(type);
|
||||||
|
const schema = store.modelFor(type);
|
||||||
|
const serializer = store.serializerFor(type);
|
||||||
|
|
||||||
|
let {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
select,
|
||||||
|
query: queryObj,
|
||||||
|
...remainingQuery
|
||||||
|
} = query;
|
||||||
|
let payload,
|
||||||
|
listToken,
|
||||||
|
writeToDbPromise,
|
||||||
|
totalInsert = 0;
|
||||||
|
|
||||||
|
if (!peekDb) {
|
||||||
|
const tokenKey = `${type}-${hashCode(remainingQuery)}`;
|
||||||
|
if (storeToken) {
|
||||||
|
const [tokenObj] = await this.sqlite.fetchResource({
|
||||||
|
...generateSQLExpressions('token', {
|
||||||
|
filters: { id: [{ equals: tokenKey }] },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
listToken = tokenObj?.token;
|
||||||
|
} else {
|
||||||
|
// This is a temporary fix of clearing the DB (specifically for auth-methods)
|
||||||
|
// since we are not storing the token we do not get back a list of removed_ids
|
||||||
|
// from the API call and we do not want deleted items from showing in list view.
|
||||||
|
await this.sqlite.deleteResource(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
payload = await adapter.query(store, schema, {
|
||||||
|
...remainingQuery,
|
||||||
|
list_token: listToken,
|
||||||
|
batchLimit: this.batchLimit,
|
||||||
|
});
|
||||||
|
totalInsert += payload.items?.length ?? 0;
|
||||||
|
|
||||||
|
// await the previous writeToDbPromise before writing to db again
|
||||||
|
if (writeToDbPromise) {
|
||||||
|
await writeToDbPromise;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
payload = await this.retryQueryFailure({
|
||||||
|
err,
|
||||||
|
type,
|
||||||
|
adapter,
|
||||||
|
store,
|
||||||
|
schema,
|
||||||
|
remainingQuery,
|
||||||
|
});
|
||||||
|
totalInsert += payload.items?.length ?? 0;
|
||||||
|
}
|
||||||
|
listToken = payload.list_token;
|
||||||
|
|
||||||
|
writeToDbPromise = this.writeToDb(
|
||||||
|
payload,
|
||||||
|
storeToken,
|
||||||
|
tokenKey,
|
||||||
|
pushToStore,
|
||||||
|
serializer,
|
||||||
|
store,
|
||||||
|
schema,
|
||||||
|
type,
|
||||||
|
);
|
||||||
|
} while (payload.response_type === 'delta');
|
||||||
|
|
||||||
|
await writeToDbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalInsert > 0) {
|
||||||
|
// If there were any inserts, let sqlite handle running analyze on the DB
|
||||||
|
await this.sqlite.analyzeDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
select: select ?? [{ field: 'data' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await this.sqlite.fetchResource({
|
||||||
|
sql,
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sql: countSql, parameters: countParams } =
|
||||||
|
generateSQLExpressions(type, queryObj, {
|
||||||
|
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||||
|
});
|
||||||
|
const count = await this.sqlite.fetchResource({
|
||||||
|
sql: countSql,
|
||||||
|
parameters: countParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = rows.map((item) =>
|
||||||
|
JSON.parse(item.data, (key, value) =>
|
||||||
|
// Reviver function to convert any ISO strings back to date objects
|
||||||
|
// as we had to initially convert them to strings when stored in SQLite
|
||||||
|
isISODateString(value) ? new Date(value) : value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we are not pushing to the store, use the raw data with id property
|
||||||
|
const records = pushToStore
|
||||||
|
? store.push({ data: results })
|
||||||
|
: results.map((record) => ({
|
||||||
|
...record.attributes,
|
||||||
|
id: record.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set a meta property on the array to store the total items of the results.
|
||||||
|
// This isn't conventional but is better than returning an ArrayProxy
|
||||||
|
// or EmberArray since the ember store query method asserts it has to be an array
|
||||||
|
// so we can't just return an object.
|
||||||
|
records.meta = { totalItems: count[0].total };
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return next(context.request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async retryQueryFailure({
|
||||||
|
err,
|
||||||
|
type,
|
||||||
|
adapter,
|
||||||
|
store,
|
||||||
|
schema,
|
||||||
|
remainingQuery,
|
||||||
|
}) {
|
||||||
|
// If we get an invalid list token, we'll delete the token and
|
||||||
|
// clear the DB and try again without a token.
|
||||||
|
if (
|
||||||
|
err?.errors?.[0].status === 400 &&
|
||||||
|
err?.errors?.[0].code === 'invalid list token'
|
||||||
|
) {
|
||||||
|
await this.sqlite.deleteResource(type);
|
||||||
|
// Delete all tokens of the same resource type so we don't keep clearing the DB.
|
||||||
|
// Even if other tokens may still be valid, we might have cleared data that they
|
||||||
|
// depended on so we should clear all tokens of the same type when clearing the DB.
|
||||||
|
const { sql, parameters } = generateSQLExpressions('token', {
|
||||||
|
filters: { id: [{ contains: type }] },
|
||||||
|
});
|
||||||
|
const tokenRows = await this.sqlite.fetchResource({
|
||||||
|
sql,
|
||||||
|
parameters,
|
||||||
|
});
|
||||||
|
const tokenIds = tokenRows.map((row) => row.id);
|
||||||
|
|
||||||
|
if (tokenIds.length > 0) {
|
||||||
|
await this.sqlite.deleteResource('token', tokenIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await adapter.query(store, schema, {
|
||||||
|
...remainingQuery,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeToDb(
|
||||||
|
payload,
|
||||||
|
storeToken,
|
||||||
|
tokenKey,
|
||||||
|
pushToStore,
|
||||||
|
serializer,
|
||||||
|
store,
|
||||||
|
schema,
|
||||||
|
type,
|
||||||
|
) {
|
||||||
|
if (payload.list_token && storeToken) {
|
||||||
|
await this.sqlite.insertResource('token', [
|
||||||
|
[tokenKey, payload.list_token],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPayload = serializer.normalizeResponse(
|
||||||
|
store,
|
||||||
|
schema,
|
||||||
|
payload,
|
||||||
|
null,
|
||||||
|
'query',
|
||||||
|
);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
let value;
|
||||||
|
|
||||||
|
if (key === 'id') {
|
||||||
|
value = datum.id;
|
||||||
|
} else {
|
||||||
|
value = get(datum, `attributes.${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeOf(value) === 'date') {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...params, JSON.stringify(datum)];
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sqlite.insertResource(type, items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import GeneratedCredentialLibraryModel from '../generated/models/credential-library';
|
import GeneratedCredentialLibraryModel from '../generated/models/credential-library';
|
||||||
|
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.
|
* Enum options for credential library.
|
||||||
*/
|
*/
|
||||||
export const options = {
|
export const options = {
|
||||||
http_method: ['GET', 'POST'],
|
http_method: ['GET', 'POST'],
|
||||||
key_type: ['ed25519', 'ecdsa', 'rsa'],
|
key_type: ['ed25519', 'ecdsa', 'rsa'],
|
||||||
credential_types: ['ssh_private_key', 'username_password'],
|
credential_types: [
|
||||||
|
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||||
|
TYPE_CREDENTIAL_PASSWORD,
|
||||||
|
],
|
||||||
mapping_overrides: {
|
mapping_overrides: {
|
||||||
username_password: ['username_attribute', 'password_attribute'],
|
username_password: ['username_attribute', 'password_attribute'],
|
||||||
ssh_private_key: [
|
ssh_private_key: [
|
||||||
@@ -19,12 +29,19 @@ export const options = {
|
|||||||
'private_key_passphrase_attribute',
|
'private_key_passphrase_attribute',
|
||||||
'username_attribute',
|
'username_attribute',
|
||||||
],
|
],
|
||||||
|
username_password_domain: [
|
||||||
|
'username_attribute',
|
||||||
|
'password_attribute',
|
||||||
|
'domain_attribute',
|
||||||
|
],
|
||||||
|
password: ['password_attribute'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic';
|
export const TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC = 'vault-generic';
|
||||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||||
'vault-ssh-certificate';
|
'vault-ssh-certificate';
|
||||||
|
export const TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP = 'vault-ldap';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported Credential Library types.
|
* Supported Credential Library types.
|
||||||
@@ -32,6 +49,7 @@ export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
|||||||
export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([
|
export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel {
|
export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel {
|
||||||
@@ -52,7 +70,8 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
|||||||
get isVault() {
|
get isVault() {
|
||||||
return (
|
return (
|
||||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC ||
|
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC ||
|
||||||
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE
|
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE ||
|
||||||
|
this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,4 +90,12 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
|||||||
get isVaultSSHCertificate() {
|
get isVaultSSHCertificate() {
|
||||||
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE;
|
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if credential is a vault ldap type.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
get isVaultLDAP() {
|
||||||
|
return this.type === TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,16 @@ import GeneratedCredentialModel from '../generated/models/credential';
|
|||||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password';
|
export const TYPE_CREDENTIAL_USERNAME_PASSWORD = 'username_password';
|
||||||
export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key';
|
export const TYPE_CREDENTIAL_SSH_PRIVATE_KEY = 'ssh_private_key';
|
||||||
export const TYPE_CREDENTIAL_JSON = 'json';
|
export const TYPE_CREDENTIAL_JSON = 'json';
|
||||||
|
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN =
|
||||||
|
'username_password_domain';
|
||||||
|
export const TYPE_CREDENTIAL_PASSWORD = 'password';
|
||||||
|
|
||||||
export const TYPES_CREDENTIAL = Object.freeze([
|
export const TYPES_CREDENTIAL = Object.freeze([
|
||||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||||
TYPE_CREDENTIAL_JSON,
|
TYPE_CREDENTIAL_JSON,
|
||||||
|
TYPE_CREDENTIAL_PASSWORD,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default class CredentialModel extends GeneratedCredentialModel {
|
export default class CredentialModel extends GeneratedCredentialModel {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { service } from '@ember/service';
|
|||||||
|
|
||||||
export const TYPE_TARGET_TCP = 'tcp';
|
export const TYPE_TARGET_TCP = 'tcp';
|
||||||
export const TYPE_TARGET_SSH = 'ssh';
|
export const TYPE_TARGET_SSH = 'ssh';
|
||||||
export const TYPES_TARGET = Object.freeze([TYPE_TARGET_TCP, TYPE_TARGET_SSH]);
|
export const TYPE_TARGET_RDP = 'rdp';
|
||||||
|
|
||||||
|
export const TYPES_TARGET = Object.freeze([
|
||||||
|
TYPE_TARGET_TCP,
|
||||||
|
TYPE_TARGET_SSH,
|
||||||
|
TYPE_TARGET_RDP,
|
||||||
|
]);
|
||||||
|
|
||||||
export default class TargetModel extends GeneratedTargetModel {
|
export default class TargetModel extends GeneratedTargetModel {
|
||||||
// =services
|
// =services
|
||||||
@@ -347,4 +353,12 @@ export default class TargetModel extends GeneratedTargetModel {
|
|||||||
get isSSH() {
|
get isSSH() {
|
||||||
return this.type === TYPE_TARGET_SSH;
|
return this.type === TYPE_TARGET_SSH;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the target type is rdp.
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
get isRDP() {
|
||||||
|
return this.type === TYPE_TARGET_RDP;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -98,8 +98,9 @@ export default class AccountSerializer extends ApplicationSerializer {
|
|||||||
serialized.attributes = {};
|
serialized.attributes = {};
|
||||||
}
|
}
|
||||||
// New record case
|
// New record case
|
||||||
if (password && snapshot?.record?.isNew)
|
if (password && snapshot?.record?.isNew) {
|
||||||
serialized.attributes.password = password;
|
serialized.attributes.password = password;
|
||||||
|
}
|
||||||
// Set password custom method
|
// Set password custom method
|
||||||
if (snapshot?.adapterOptions?.method === 'set-password') {
|
if (snapshot?.adapterOptions?.method === 'set-password') {
|
||||||
serialized = this.serializeForSetPassword(snapshot, password);
|
serialized = this.serializeForSetPassword(snapshot, password);
|
||||||
|
|||||||
@@ -44,21 +44,7 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
|||||||
if (!serialized.attributes?.http_method?.match(/post/i)) {
|
if (!serialized.attributes?.http_method?.match(/post/i)) {
|
||||||
serialized.attributes.http_request_body = null;
|
serialized.attributes.http_request_body = null;
|
||||||
}
|
}
|
||||||
const { credential_type, credential_mapping_overrides } = serialized;
|
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||||
|
|
||||||
// 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[
|
|
||||||
credential_type
|
|
||||||
].reduce((obj, key) => {
|
|
||||||
if (credential_mapping_overrides[key]) {
|
|
||||||
obj[key] = credential_mapping_overrides[key];
|
|
||||||
} else {
|
|
||||||
obj[key] = null;
|
|
||||||
}
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
return serialized;
|
return serialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,4 +60,29 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
|||||||
return serialized;
|
return serialized;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle credential mapping overrides for serialization.
|
||||||
|
* @param {Object} serialized - The serialized object.
|
||||||
|
* @param {boolean} isNew - Indicates if the record is new.
|
||||||
|
*/
|
||||||
|
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[
|
||||||
|
credential_type
|
||||||
|
].reduce((obj, key) => {
|
||||||
|
if (credential_mapping_overrides[key]) {
|
||||||
|
obj[key] = credential_mapping_overrides[key];
|
||||||
|
} else {
|
||||||
|
obj[key] = null;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,9 @@ export default class CredentialStoreSerializer extends ApplicationSerializer {
|
|||||||
if (
|
if (
|
||||||
!serialized.attributes?.client_certificate_key &&
|
!serialized.attributes?.client_certificate_key &&
|
||||||
serialized.attributes?.client_certificate
|
serialized.attributes?.client_certificate
|
||||||
)
|
) {
|
||||||
delete serialized.attributes.client_certificate_key;
|
delete serialized.attributes.client_certificate_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return serialized;
|
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
|
// This deletes any fields that don't belong to the record's credential type
|
||||||
if (options.isNestedAttribute && json.attributes) {
|
if (options.isNestedAttribute && json.attributes) {
|
||||||
// The key must be included in the fieldsByType list above
|
// 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
|
// API requires these fields to be null
|
||||||
json.attributes[key] = null;
|
json.attributes[key] = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//json.secrets is only present, if there are any updates to the secret fields
|
//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 =
|
const injectedApplicationCredentialSourceIDs =
|
||||||
snapshot?.adapterOptions?.injectedApplicationCredentialSourceIDs;
|
snapshot?.adapterOptions?.injectedApplicationCredentialSourceIDs;
|
||||||
|
|
||||||
if (brokeredCredentialSourceIDs)
|
if (brokeredCredentialSourceIDs) {
|
||||||
serialized = this.serializeWithBrokeredCredentialSources(
|
serialized = this.serializeWithBrokeredCredentialSources(
|
||||||
snapshot,
|
snapshot,
|
||||||
brokeredCredentialSourceIDs,
|
brokeredCredentialSourceIDs,
|
||||||
);
|
);
|
||||||
if (injectedApplicationCredentialSourceIDs)
|
}
|
||||||
|
if (injectedApplicationCredentialSourceIDs) {
|
||||||
serialized = this.serializeWithInjectedApplicationCredentialSources(
|
serialized = this.serializeWithInjectedApplicationCredentialSources(
|
||||||
snapshot,
|
snapshot,
|
||||||
injectedApplicationCredentialSourceIDs,
|
injectedApplicationCredentialSourceIDs,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isNew && serialized?.with_aliases) {
|
if (isNew && serialized?.with_aliases) {
|
||||||
// API expects scope id along with every alias value
|
// API expects scope id along with every alias value
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export default class UserSerializer extends ApplicationSerializer {
|
|||||||
serialize(snapshot) {
|
serialize(snapshot) {
|
||||||
let serialized = super.serialize(...arguments);
|
let serialized = super.serialize(...arguments);
|
||||||
const accountIDs = snapshot?.adapterOptions?.accountIDs;
|
const accountIDs = snapshot?.adapterOptions?.accountIDs;
|
||||||
if (accountIDs)
|
if (accountIDs) {
|
||||||
serialized = this.serializewithAccounts(snapshot, accountIDs);
|
serialized = this.serializewithAccounts(snapshot, accountIDs);
|
||||||
|
}
|
||||||
return serialized;
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Service from '@ember/service';
|
|
||||||
import Dexie from 'dexie';
|
|
||||||
import { typeOf } from '@ember/utils';
|
|
||||||
|
|
||||||
// List of resources we support to be cached and searched in
|
|
||||||
// indexedDb. Any field that can be searched upon should be added to
|
|
||||||
// this index. Increment the version number of the database whenever
|
|
||||||
// indexes change.
|
|
||||||
export const modelIndexes = {
|
|
||||||
token: '&id, token',
|
|
||||||
target:
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.address, attributes.scope.scope_id',
|
|
||||||
session:
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.status, attributes.endpoint, attributes.target_id, attributes.user_id, attributes.scope.scope_id',
|
|
||||||
user: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
|
||||||
group:
|
|
||||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
|
||||||
role: '&id, attributes.created_time, attributes.name, attributes.description, attributes.scope.scope_id',
|
|
||||||
scope:
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id',
|
|
||||||
'credential-store':
|
|
||||||
'&id, attributes.created_time, attributes.name, attributes.description, attributes.type, attributes.scope.scope_id',
|
|
||||||
'host-catalog':
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.scope.scope_id, attributes.plugin.name',
|
|
||||||
'auth-method':
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.name, attributes.description, attributes.is_primary, attributes.scope.scope_id',
|
|
||||||
'session-recording':
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.state, attributes.start_time, attributes.end_time, attributes.duration, attributes.scope.scope_id, attributes.create_time_values.user.id, attributes.create_time_values.user.name, attributes.create_time_values.target.id, attributes.create_time_values.target.name, attributes.create_time_values.target.scope.id, attributes.create_time_values.target.scope.name',
|
|
||||||
alias:
|
|
||||||
'&id, attributes.created_time, attributes.type, attributes.value, attributes.name, attributes.description, attributes.destination_id, attributes.scope.scope_id',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const formatDbName = (userId, clusterUrl) =>
|
|
||||||
`boundary-${userId}-${clusterUrl}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively copy over the object and convert any booleans to "true" or "false" so they can be indexed.
|
|
||||||
* Also converts any ember-like arrays back to a standard array.
|
|
||||||
* @param result
|
|
||||||
* @param object
|
|
||||||
* @returns {*}
|
|
||||||
*/
|
|
||||||
const convertFields = (result, object) => {
|
|
||||||
Object.entries(object).forEach(([key, value]) => {
|
|
||||||
if (typeOf(value) === 'object') {
|
|
||||||
result[key] = convertFields({}, value);
|
|
||||||
} else if (typeOf(value) === 'boolean') {
|
|
||||||
result[key] = value ? 'true' : 'false';
|
|
||||||
} else if (typeOf(value) === 'array') {
|
|
||||||
result[key] = [...value];
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively copy over the object and convert any string "true" or "false" back to booleans.
|
|
||||||
* Will also convert any array fields back using their model transformation.
|
|
||||||
* @param result
|
|
||||||
* @param object
|
|
||||||
* @param schema
|
|
||||||
* @param serializer
|
|
||||||
* @returns {*}
|
|
||||||
*/
|
|
||||||
const unconvertFields = (result, object, schema, serializer) => {
|
|
||||||
Object.entries(object).forEach(([key, value]) => {
|
|
||||||
if (typeOf(value) === 'object') {
|
|
||||||
result[key] = unconvertFields({}, value, schema, serializer);
|
|
||||||
} else if (value === 'true' || value === 'false') {
|
|
||||||
result[key] = value === 'true';
|
|
||||||
} else if (typeOf(value) === 'array') {
|
|
||||||
const attribute = schema.attributes.get(key);
|
|
||||||
|
|
||||||
// If attribute type is array, we transform it back to a tracked array
|
|
||||||
if (attribute?.type === 'array') {
|
|
||||||
const transformer = serializer.transformFor(attribute.type);
|
|
||||||
result[key] = transformer.deserialize(value, attribute.options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
result[key] = value;
|
|
||||||
} else {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service to encapsulate the IndexedDB implementation. To use this service, call
|
|
||||||
* `setup` from the application root. This will create the database and the necessary indexes.
|
|
||||||
*
|
|
||||||
* Make sure to increment the version number of the database whenever the indexes change.
|
|
||||||
* This will delete the old database and create a new one with the new indexes.
|
|
||||||
*/
|
|
||||||
export default class IndexedDbService extends Service {
|
|
||||||
// =attributes
|
|
||||||
#db;
|
|
||||||
#version = 2;
|
|
||||||
|
|
||||||
get db() {
|
|
||||||
return this.#db;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setup(dbName) {
|
|
||||||
// Don't run setup again if we already have one or if we didn't get a name
|
|
||||||
if (this.#db || !dbName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doesDbExist = await Dexie.exists(dbName);
|
|
||||||
|
|
||||||
// If the database already exists, open the database and
|
|
||||||
// check if the version is a lower version than the current one
|
|
||||||
if (doesDbExist) {
|
|
||||||
const dbVerifier = new Dexie(dbName);
|
|
||||||
await dbVerifier.open();
|
|
||||||
if (dbVerifier.verno < this.#version) {
|
|
||||||
await dbVerifier.delete();
|
|
||||||
} else {
|
|
||||||
dbVerifier.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.#db = new Dexie(dbName);
|
|
||||||
this.#db.version(this.#version).stores(modelIndexes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup the data by converting all fields to plain javascript objects.
|
|
||||||
* We will also convert booleans to "false" or "true".
|
|
||||||
* IndexedDB doesn't support indexes on boolean values so we have to
|
|
||||||
* convert them to be able to support using boolean values.
|
|
||||||
*
|
|
||||||
* Undo the operation by setting `convertFields` to false.
|
|
||||||
*
|
|
||||||
* @param {Object} data
|
|
||||||
* @param {Boolean} cleanData
|
|
||||||
* @param {Object} schema
|
|
||||||
* @param {Object} serializer
|
|
||||||
* @return {{id, type, attributes: {}, relationships: {}}}
|
|
||||||
*/
|
|
||||||
normalizeData({ data, cleanData, schema, serializer }) {
|
|
||||||
if (!data) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { id, type, attributes = {}, relationships = {} } = data;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
attributes: cleanData
|
|
||||||
? convertFields({}, attributes)
|
|
||||||
: unconvertFields({}, attributes, schema, serializer),
|
|
||||||
relationships: cleanData
|
|
||||||
? convertFields({}, relationships)
|
|
||||||
: unconvertFields({}, relationships, schema, serializer),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { PWBHost } from 'promise-worker-bi';
|
||||||
|
|
||||||
|
export const formatDbName = (userId, clusterUrl) =>
|
||||||
|
`boundary-${userId}-${clusterUrl}`;
|
||||||
|
|
||||||
|
// A mapping of columns to their expected model attributes for supported models
|
||||||
|
// The JSON data column is not included here but is assumed to always be last.
|
||||||
|
export const modelMapping = {
|
||||||
|
target: {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
address: 'address',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
alias: {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
destination_id: 'destination_id',
|
||||||
|
value: 'value',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
id: 'id',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
id: 'id',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: 'id',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
'credential-store': {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
scope: {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
'auth-method': {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
is_primary: 'is_primary',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
'host-catalog': {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
name: 'name',
|
||||||
|
description: 'description',
|
||||||
|
plugin_name: 'plugin.name',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
'session-recording': {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
state: 'state',
|
||||||
|
start_time: 'start_time',
|
||||||
|
end_time: 'end_time',
|
||||||
|
duration: 'duration',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
user_id: 'create_time_values.user.id',
|
||||||
|
user_name: 'create_time_values.user.name',
|
||||||
|
target_id: 'create_time_values.target.id',
|
||||||
|
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: {
|
||||||
|
id: 'id',
|
||||||
|
type: 'type',
|
||||||
|
status: 'status',
|
||||||
|
endpoint: 'endpoint',
|
||||||
|
target_id: 'target_id',
|
||||||
|
user_id: 'user_id',
|
||||||
|
scope_id: 'scope.scope_id',
|
||||||
|
created_time: 'created_time',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class SqliteDbService extends Service {
|
||||||
|
// =attributes
|
||||||
|
|
||||||
|
// We need a handle on the original web worker to properly clean it up.
|
||||||
|
webWorker;
|
||||||
|
// This will be the worker we interact with that is wrapped by PWBHost.
|
||||||
|
worker;
|
||||||
|
|
||||||
|
setup(dbName) {
|
||||||
|
if (this.worker) {
|
||||||
|
// If the worker already exists, we don't need to do anything.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, our JS files will be fingerprinted with an MD5 hash
|
||||||
|
// so we need a way to map the worker file name to be able to find it.
|
||||||
|
//
|
||||||
|
// Ember is automagically replacing any URL path with the fingerprinted
|
||||||
|
// asset, and this is done by broccoli-asset-rev.
|
||||||
|
//
|
||||||
|
// The problem is it's a bit finicky as seen in this issue
|
||||||
|
// https://github.com/ember-cli/broccoli-asset-rewrite/issues/66
|
||||||
|
// and will not work properly if it's part of a template string or interpolated.
|
||||||
|
// Therefore, we need the URL path in a string somewhere to be replaced properly.
|
||||||
|
this.webWorker = new Worker('/workers/sqlite-worker.js', {
|
||||||
|
type: 'module',
|
||||||
|
});
|
||||||
|
|
||||||
|
// We use PWBHost to simplify communication so we can just send and receive
|
||||||
|
// messages with promises rather than having to manually set up event listeners
|
||||||
|
this.worker = new PWBHost(this.webWorker);
|
||||||
|
this.worker.register(async ({ method }) => {
|
||||||
|
if (method === 'getDatabaseName') {
|
||||||
|
return dbName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchResource({ sql, parameters }) {
|
||||||
|
return this.worker.postMessage({
|
||||||
|
method: 'fetchResource',
|
||||||
|
payload: { sql, parameters },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
insertResource(resource, items) {
|
||||||
|
return this.worker.postMessage({
|
||||||
|
method: 'insertResource',
|
||||||
|
payload: { resource, items, modelMapping },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteResource(resource, ids) {
|
||||||
|
return this.worker.postMessage({
|
||||||
|
method: 'deleteResource',
|
||||||
|
payload: { resource, ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
analyzeDatabase() {
|
||||||
|
return this.worker.postMessage({ method: 'analyzeDatabase' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadDatabase() {
|
||||||
|
const byteArray = await this.worker.postMessage({
|
||||||
|
method: 'downloadDatabase',
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = new Blob([byteArray.buffer], {
|
||||||
|
type: 'application/x-sqlite3',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create temporary elements to initialize the download
|
||||||
|
const a = window.document.createElement('a');
|
||||||
|
window.document.body.appendChild(a);
|
||||||
|
a.href = window.URL.createObjectURL(blob);
|
||||||
|
a.download = 'boundary.db';
|
||||||
|
a.addEventListener('click', function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
window.URL.revokeObjectURL(a.href);
|
||||||
|
a.remove();
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
a.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearDatabase() {
|
||||||
|
await this.worker.postMessage({ method: 'clearDatabase' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDatabase() {
|
||||||
|
await this.worker.postMessage({ method: 'deleteDatabase' });
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
this.webWorker?.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import Store from 'ember-data/store';
|
|||||||
import { CacheHandler } from '@ember-data/store';
|
import { CacheHandler } from '@ember-data/store';
|
||||||
import RequestManager from '@ember-data/request';
|
import RequestManager from '@ember-data/request';
|
||||||
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
|
import { LegacyNetworkHandler } from '@ember-data/legacy-compat';
|
||||||
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
|
import SqliteHandler from 'api/handlers/sqlite-handler';
|
||||||
|
|
||||||
export default class extends Store {
|
export default class extends Store {
|
||||||
requestManager = new RequestManager();
|
requestManager = new RequestManager();
|
||||||
@@ -18,9 +18,9 @@ export default class extends Store {
|
|||||||
constructor(args) {
|
constructor(args) {
|
||||||
super(args);
|
super(args);
|
||||||
|
|
||||||
const indexedDbHandler = new IndexedDbHandler(this);
|
const sqliteHandler = new SqliteHandler(this);
|
||||||
|
|
||||||
this.requestManager.use([indexedDbHandler, LegacyNetworkHandler]);
|
this.requestManager.use([sqliteHandler, LegacyNetworkHandler]);
|
||||||
this.requestManager.useCache(CacheHandler);
|
this.requestManager.useCache(CacheHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { get } from '@ember/object';
|
|
||||||
import { modelIndexes } from '../services/indexed-db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Takes a POJO representing a filter query and builds a query to
|
|
||||||
* query indexedDb with. Note that the first filter is important as that
|
|
||||||
* will use the actual index in indexedDb while any subsequent ones will be less efficient.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const filter = queryIndexedDb(indexedDb, type, {
|
|
||||||
* search: generated',
|
|
||||||
* filters: {
|
|
||||||
* scope_id: {
|
|
||||||
* logicalOperator: 'and',
|
|
||||||
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
|
||||||
* },
|
|
||||||
* },
|
|
||||||
* });
|
|
||||||
*/
|
|
||||||
export async function queryIndexedDb(indexedDb, resource, query) {
|
|
||||||
let { search, filters = {} } = query ?? {};
|
|
||||||
let filterCollection = indexedDb[resource];
|
|
||||||
|
|
||||||
search = getSearchObject(search, resource);
|
|
||||||
filters = cleanObject(filters);
|
|
||||||
|
|
||||||
if (Object.keys(filters).length) {
|
|
||||||
// Remove the first item to use as the initial collection and then reduce the remaining ones
|
|
||||||
const entries = Object.entries(filters);
|
|
||||||
const [firstKey, firstFilterArrayOrObject] = entries.shift();
|
|
||||||
|
|
||||||
filterCollection = await entries.reduce(
|
|
||||||
async (currCollection, [key, filterArrayOrObject]) => {
|
|
||||||
// Grab the results from the set of one filters to be `AND` together with the rest of the filters
|
|
||||||
const filterResults = await buildInitialWhereClause({
|
|
||||||
table: indexedDb[resource],
|
|
||||||
key,
|
|
||||||
filterArrayOrObject,
|
|
||||||
}).primaryKeys();
|
|
||||||
const resultIdsSet = new Set(filterResults);
|
|
||||||
|
|
||||||
return (await currCollection).and((record) => {
|
|
||||||
return resultIdsSet.has(record.id);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
buildInitialWhereClause({
|
|
||||||
key: firstKey,
|
|
||||||
table: indexedDb[resource],
|
|
||||||
filterArrayOrObject: firstFilterArrayOrObject,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search) {
|
|
||||||
const { text, fields } = search;
|
|
||||||
|
|
||||||
filterCollection = filterCollection.filter((record) =>
|
|
||||||
fields
|
|
||||||
.map((key) => get(record, key))
|
|
||||||
.some((element) => {
|
|
||||||
if (typeof element === 'string') {
|
|
||||||
return element?.toLowerCase().includes(text?.toLowerCase());
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterCollection can be Dexie Table or a Dexie Collection.
|
|
||||||
// This is an important detail you need to keep in mind when working on it.
|
|
||||||
return filterCollection.toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildInitialWhereClause = ({ filterArrayOrObject, table, key }) => {
|
|
||||||
const filterValueArray = Array.isArray(filterArrayOrObject)
|
|
||||||
? filterArrayOrObject
|
|
||||||
: filterArrayOrObject.values;
|
|
||||||
|
|
||||||
const { logicalOperator } = filterArrayOrObject;
|
|
||||||
|
|
||||||
// Short circuit if all the filter types were equals or notEquals since indexedDb
|
|
||||||
// can more efficiently query if we pass all values at once
|
|
||||||
const checkAllFilterType = (type) =>
|
|
||||||
filterValueArray.every((obj) => Object.keys(obj)[0] === type);
|
|
||||||
if (checkAllFilterType('equals')) {
|
|
||||||
return table
|
|
||||||
.where(getKey(key))
|
|
||||||
.anyOf(filterValueArray.map((obj) => obj['equals']));
|
|
||||||
}
|
|
||||||
if (checkAllFilterType('notEquals')) {
|
|
||||||
return table
|
|
||||||
.where(getKey(key))
|
|
||||||
.noneOf(filterValueArray.map((obj) => obj['notEquals']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove the first item to use as the initial where clause and then reduce the remaining ones
|
|
||||||
const [firstOperation, firstFilterValue] = Object.entries(
|
|
||||||
filterValueArray[0],
|
|
||||||
)[0];
|
|
||||||
|
|
||||||
// Not all are filters are equals or notEquals,
|
|
||||||
// manually build the initial where clause and resulting collections
|
|
||||||
return filterValueArray.slice(1).reduce(
|
|
||||||
(result, currObj) => {
|
|
||||||
const [operation, value] = Object.entries(currObj)[0];
|
|
||||||
|
|
||||||
return buildIndexedDbCollection({
|
|
||||||
key: getKey(key),
|
|
||||||
value,
|
|
||||||
collection: result,
|
|
||||||
operation,
|
|
||||||
logicalOperator,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
buildIndexedDbWhere({
|
|
||||||
table,
|
|
||||||
key: getKey(key),
|
|
||||||
operation: firstOperation,
|
|
||||||
value: firstFilterValue,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the initial where clause for indexed DB to filter on. Note that this makes
|
|
||||||
* the first filter important as that will do the real filtering from indexed DB while
|
|
||||||
* any subsequent filter has to be done in memory.
|
|
||||||
*/
|
|
||||||
const buildIndexedDbWhere = ({ table, key, value, operation }) => {
|
|
||||||
const baseWhereClause = table.where(key);
|
|
||||||
switch (operation) {
|
|
||||||
case 'contains':
|
|
||||||
// We return an error due to not being able to support contains on first filter due to performance.
|
|
||||||
throw new Error('Contains is not supported as the first filter.');
|
|
||||||
case 'gt':
|
|
||||||
return baseWhereClause.above(value);
|
|
||||||
case 'gte':
|
|
||||||
return baseWhereClause.aboveOrEqual(value);
|
|
||||||
case 'lt':
|
|
||||||
return baseWhereClause.below(value);
|
|
||||||
case 'lte':
|
|
||||||
return baseWhereClause.belowOrEqual(value);
|
|
||||||
case 'notEquals':
|
|
||||||
return baseWhereClause.notEqual(value);
|
|
||||||
default:
|
|
||||||
return baseWhereClause.equals(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continues building the queries for indexed DB to filter on. Note that these are not true
|
|
||||||
* indexed DB queries and in fact just standard JS filters for logical "and" queries and parallel queries
|
|
||||||
* to indexed DB for logical "or" queries that get deduped.
|
|
||||||
*/
|
|
||||||
const buildIndexedDbCollection = ({
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
collection,
|
|
||||||
operation,
|
|
||||||
logicalOperator,
|
|
||||||
}) => {
|
|
||||||
if (logicalOperator === 'and') {
|
|
||||||
switch (operation) {
|
|
||||||
case 'contains':
|
|
||||||
return collection.and((record) => get(record, key)?.includes(value));
|
|
||||||
case 'gt':
|
|
||||||
return collection.and((record) => get(record, key) > value);
|
|
||||||
case 'gte':
|
|
||||||
return collection.and((record) => get(record, key) >= value);
|
|
||||||
case 'lt':
|
|
||||||
return collection.and((record) => get(record, key) < value);
|
|
||||||
case 'lte':
|
|
||||||
return collection.and((record) => get(record, key) <= value);
|
|
||||||
case 'notEquals':
|
|
||||||
return collection.and((record) => get(record, key) !== value);
|
|
||||||
default:
|
|
||||||
return collection.and((record) => get(record, key) === value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (operation) {
|
|
||||||
case 'contains':
|
|
||||||
// We return an error due to not being able to support contains on this filter due to performance.
|
|
||||||
throw new Error(
|
|
||||||
'Contains is not supported as a filter option with "or" operator.',
|
|
||||||
);
|
|
||||||
case 'gt':
|
|
||||||
return collection.or(key).above(value);
|
|
||||||
case 'gte':
|
|
||||||
return collection.or(key).aboveOrEqual(value);
|
|
||||||
case 'lt':
|
|
||||||
return collection.or(key).below(value);
|
|
||||||
case 'lte':
|
|
||||||
return collection.or(key).belowOrEqual(value);
|
|
||||||
case 'notEquals':
|
|
||||||
return collection.or(key).notEqual(value);
|
|
||||||
default:
|
|
||||||
return collection.or(key).equals(value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Translates the fields passed in from the UI for resources to the keys indexed DB knows them as.
|
|
||||||
* Usually it's the path of the actual normalized model.
|
|
||||||
*/
|
|
||||||
const getKey = (key) => {
|
|
||||||
switch (key) {
|
|
||||||
case 'scope_id':
|
|
||||||
return `attributes.scope.${key}`;
|
|
||||||
case 'id':
|
|
||||||
return key;
|
|
||||||
default:
|
|
||||||
return `attributes.${key}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans the object to remove any keys that have a value of an empty array. This makes
|
|
||||||
* it easier so we don't have to worry about empty arrays getting queried in indexed db.
|
|
||||||
*/
|
|
||||||
const cleanObject = (obj) => {
|
|
||||||
if (!obj) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(obj).reduce((result, [key, value]) => {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
if (value.length > 0) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
} else if (value.values.length > 0) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes the search object that is expected when a user searches. Uses
|
|
||||||
* the indexes we setup in indexed db.
|
|
||||||
*/
|
|
||||||
const getSearchObject = (text, resource) => {
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
fields: modelIndexes[resource]
|
|
||||||
.split(', ')
|
|
||||||
.map((index) => index.replace('&', '')),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
// created_time is not an attribute defined on all models, but is present in all the resources
|
|
||||||
// coming from API
|
|
||||||
const SORT_DEFAULT_ATTRIBUTE = 'created_time';
|
|
||||||
const SORT_DIRECTION_ASCENDING = 'asc';
|
|
||||||
const SORT_DIRECTION_DESCENDING = 'desc';
|
|
||||||
|
|
||||||
const sortFunctions = {
|
|
||||||
string: (a, b) => String(a).localeCompare(String(b)),
|
|
||||||
date: (a, b) => new Date(a).getTime() - new Date(b).getTime(),
|
|
||||||
number: (a, b) => Number(a) - Number(b),
|
|
||||||
boolean: (a, b) => Number(a) - Number(b),
|
|
||||||
};
|
|
||||||
|
|
||||||
function getSortableValue(schema, record, attribute) {
|
|
||||||
if (attribute === 'id') {
|
|
||||||
return record.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
// all models have a created_time attribute but not all models have it defined
|
|
||||||
if (attribute === 'created_time') {
|
|
||||||
return record.attributes.created_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema.attributes.has(attribute)) {
|
|
||||||
return record.attributes[attribute];
|
|
||||||
}
|
|
||||||
|
|
||||||
const attributes = Array.from(schema.attributes.keys());
|
|
||||||
throw new Error(
|
|
||||||
`The attribute "${attribute}" does not map to the model definition of type "${record.type}". Supported sortable attributes are 'id', 'created_time', or ${attributes.map((attr) => `'${attr}'`).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sortResults = (results, { querySort, schema }) => {
|
|
||||||
querySort = querySort ?? {};
|
|
||||||
const sortAttribute = querySort.attribute || SORT_DEFAULT_ATTRIBUTE;
|
|
||||||
|
|
||||||
// Default sort direction is ascending unless we are sorting by `created_time` (default sort attribute)
|
|
||||||
const defaultSortDirection =
|
|
||||||
sortAttribute === SORT_DEFAULT_ATTRIBUTE && !querySort.sortFunction
|
|
||||||
? SORT_DIRECTION_DESCENDING
|
|
||||||
: SORT_DIRECTION_ASCENDING;
|
|
||||||
const sortDirection = querySort.direction || defaultSortDirection;
|
|
||||||
|
|
||||||
if (
|
|
||||||
![SORT_DIRECTION_ASCENDING, SORT_DIRECTION_DESCENDING].includes(
|
|
||||||
sortDirection,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error('Invalid sort direction');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute custom sort function if one is provided.
|
|
||||||
if (querySort.sortFunction) {
|
|
||||||
return results.toSorted((a, b) => {
|
|
||||||
const sortResult = querySort.sortFunction(a, b);
|
|
||||||
|
|
||||||
return sortDirection === SORT_DIRECTION_ASCENDING
|
|
||||||
? sortResult
|
|
||||||
: -1 * sortResult;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortAttributeDataType = schema.attributes.get(sortAttribute)?.type;
|
|
||||||
|
|
||||||
const sortFunction =
|
|
||||||
sortFunctions[sortAttributeDataType] ?? sortFunctions.string;
|
|
||||||
|
|
||||||
return results.toSorted((a, b) => {
|
|
||||||
// Extract the values to sort and sort them.
|
|
||||||
const sortValueA = getSortableValue(schema, a, sortAttribute);
|
|
||||||
const sortValueB = getSortableValue(schema, b, sortAttribute);
|
|
||||||
const sortResult = sortFunction(sortValueA, sortValueB);
|
|
||||||
|
|
||||||
return sortDirection === SORT_DIRECTION_ASCENDING
|
|
||||||
? sortResult
|
|
||||||
: -1 * sortResult;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { modelMapping } 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.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const filter = generateSQLExpressions(type, {
|
||||||
|
* search: generated',
|
||||||
|
* filters: {
|
||||||
|
* scope_id: {
|
||||||
|
* logicalOperator: 'and',
|
||||||
|
* values: [{ notEquals: 'scope1' }, { notEquals: 'scope2' }],
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function generateSQLExpressions(
|
||||||
|
resource,
|
||||||
|
query = {},
|
||||||
|
{ page, pageSize, select } = {},
|
||||||
|
) {
|
||||||
|
let { search, filters, sort = {} } = query;
|
||||||
|
const conditions = [];
|
||||||
|
const parameters = [];
|
||||||
|
const tableName = underscore(resource);
|
||||||
|
const joins = [];
|
||||||
|
|
||||||
|
addFilterConditions({ filters, parameters, conditions, joins, tableName });
|
||||||
|
addSearchConditions({ search, resource, tableName, parameters, conditions });
|
||||||
|
|
||||||
|
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 ?` : '';
|
||||||
|
if (paginationClause) {
|
||||||
|
parameters.push(pageSize, (page - 1) * pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
.replace(/^\s+/gm, '')
|
||||||
|
.trim(),
|
||||||
|
parameters,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFilterConditions({
|
||||||
|
filters,
|
||||||
|
parameters,
|
||||||
|
conditions,
|
||||||
|
joins,
|
||||||
|
tableName,
|
||||||
|
}) {
|
||||||
|
if (!filters) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, filterArrayOrObject] of Object.entries(filters)) {
|
||||||
|
// Filter can be an array or an object with a `values` property
|
||||||
|
const filterValueArray = Array.isArray(filterArrayOrObject)
|
||||||
|
? filterArrayOrObject
|
||||||
|
: filterArrayOrObject.values;
|
||||||
|
|
||||||
|
if (!filterValueArray || !filterValueArray.length || key === 'joins') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When attribute conditions are a series of equals/notEquals
|
||||||
|
// replace with in/notIn to avoid hitting the sqlite maximum
|
||||||
|
// expression tree depth.
|
||||||
|
const firstOperator = Object.keys(filterValueArray[0])[0];
|
||||||
|
const allOperatorsEqual = filterValueArray
|
||||||
|
.flatMap((item) => Object.keys(item))
|
||||||
|
.every((op) => op === firstOperator);
|
||||||
|
if (
|
||||||
|
filterValueArray.length > 1 &&
|
||||||
|
(firstOperator === 'equals' || firstOperator === 'notEquals') &&
|
||||||
|
allOperatorsEqual
|
||||||
|
) {
|
||||||
|
const operation = firstOperator === 'equals' ? 'in' : 'notIn';
|
||||||
|
const nullOperator =
|
||||||
|
firstOperator === 'equals' ? 'IS NULL' : 'IS NOT NULL';
|
||||||
|
const values = filterValueArray
|
||||||
|
.filter(
|
||||||
|
(filterObjValue) =>
|
||||||
|
filterObjValue && Object.values(filterObjValue)[0] !== null,
|
||||||
|
)
|
||||||
|
.map((filterObjValue) => {
|
||||||
|
let value = Object.values(filterObjValue)[0];
|
||||||
|
if (typeOf(value) === 'date') {
|
||||||
|
value = value.toISOString();
|
||||||
|
}
|
||||||
|
parameters.push(value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterConditions = filterValueArray
|
||||||
|
.filter((f) => f)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle LIKE operator separately
|
||||||
|
if (operation === 'contains') {
|
||||||
|
parameters.push(`%${value}%`);
|
||||||
|
} else {
|
||||||
|
parameters.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${tableName}".${key}${OPERATORS[operation]}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { logicalOperator } = filterArrayOrObject;
|
||||||
|
// Default to `or` if we don't have a logical operator
|
||||||
|
conditions.push(
|
||||||
|
parenthetical(
|
||||||
|
logicalOperator === 'and'
|
||||||
|
? and(filterConditions)
|
||||||
|
: or(filterConditions),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
||||||
|
search,
|
||||||
|
resource,
|
||||||
|
tableName,
|
||||||
|
parameters,
|
||||||
|
conditions,
|
||||||
|
}) {
|
||||||
|
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}"*`;
|
||||||
|
|
||||||
|
// 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}"*`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
const validAttributes = Object.keys(modelMapping[resource] ?? {});
|
||||||
|
if (attributes?.some((attr) => !validAttributes.includes(attr))) {
|
||||||
|
return modelMapping[resource]?.created_time ? defaultOrderByClause : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customSort?.attributeMap) {
|
||||||
|
const whenClauses = Object.keys(customSort.attributeMap).reduce(
|
||||||
|
(acc, key) => {
|
||||||
|
return acc + `WHEN '${key}' THEN '${customSort.attributeMap[key]}' `;
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return `ORDER BY CASE ${attributesWithTableName.join(', ')} ${whenClauses}END ${sortDirection}`;
|
||||||
|
} else if (attributes?.length > 0) {
|
||||||
|
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(${commaSeparatedVals}) COLLATE NOCASE ${sortDirection}, COALESCE(${commaSeparatedVals}) ${sortDirection}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributesWithNoCollate = attributes
|
||||||
|
.map((attr) => `"${tableName}".${attr} COLLATE NOCASE ${sortDirection}`)
|
||||||
|
.join(', ');
|
||||||
|
const attributesWithDirection = attributes
|
||||||
|
.map((attr) => `"${tableName}".${attr} ${sortDirection}`)
|
||||||
|
.join(', ');
|
||||||
|
return `ORDER BY ${attributesWithNoCollate}, ${attributesWithDirection}`;
|
||||||
|
} else if (modelMapping[resource]?.created_time) {
|
||||||
|
return defaultOrderByClause;
|
||||||
|
} else {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comparison Operators
|
||||||
|
const OPERATORS = {
|
||||||
|
equals: ' = ?',
|
||||||
|
notEquals: ' != ?',
|
||||||
|
gt: ' > ?',
|
||||||
|
gte: ' >= ?',
|
||||||
|
lt: ' < ?',
|
||||||
|
lte: ' <= ?',
|
||||||
|
contains: ' LIKE ?',
|
||||||
|
in: (values) => ` IN (${values.map(() => '?').join(', ')})`,
|
||||||
|
notIn: (values) => ` NOT IN (${values.map(() => '?').join(', ')})`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logical Operators
|
||||||
|
const and = (clauses) => clauses.join(' AND ');
|
||||||
|
const or = (clauses) => clauses.join(' OR ');
|
||||||
|
const parenthetical = (expression) => (expression ? `(${expression})` : '');
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PWBWorker } from 'promise-worker-bi';
|
||||||
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
|
import sharedServiceInit from './utils/shared-service';
|
||||||
|
import {
|
||||||
|
CLEAR_DB,
|
||||||
|
CREATE_TABLES,
|
||||||
|
DELETE_STATEMENT,
|
||||||
|
INSERT_STATEMENTS,
|
||||||
|
} from './utils/schema';
|
||||||
|
|
||||||
|
// Production builds error out when using top level awaits so we'll just use an async IIFE
|
||||||
|
(async () => {
|
||||||
|
/** @type {import('@sqlite.org/sqlite-wasm').OpfsSAHPoolDatabase} */
|
||||||
|
let db;
|
||||||
|
/** @type {import('@sqlite.org/sqlite-wasm').SAHPoolUtil} */
|
||||||
|
let poolUtil;
|
||||||
|
/** @type {import('@sqlite.org/sqlite-wasm').Sqlite3Static} */
|
||||||
|
let sqlite3;
|
||||||
|
|
||||||
|
const { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||||
|
|
||||||
|
// Maximum number of host parameters for SQLite prepared statements.
|
||||||
|
// 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 = 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
|
||||||
|
// check ahead of time to see if we're able to call the API.
|
||||||
|
let canCallGetDirectory;
|
||||||
|
try {
|
||||||
|
canCallGetDirectory = Boolean(await navigator.storage.getDirectory());
|
||||||
|
} catch {
|
||||||
|
canCallGetDirectory = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecure = self.isSecureContext && canCallGetDirectory;
|
||||||
|
|
||||||
|
// Error code for sqlite corruption errors.
|
||||||
|
// See https://sqlite.org/rescode.html#corrupt
|
||||||
|
const SQLITE_CORRUPT = 11;
|
||||||
|
|
||||||
|
const methods = {
|
||||||
|
initializeSQLite: async () => {
|
||||||
|
sqlite3 = await sqlite3InitModule({
|
||||||
|
print: console.log,
|
||||||
|
printErr: console.error,
|
||||||
|
});
|
||||||
|
const dbName = await promiseWorker.postMessage({
|
||||||
|
method: 'getDatabaseName',
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupDb() {
|
||||||
|
const [row] = db.exec('PRAGMA user_version;', { rowMode: 'object' });
|
||||||
|
// Check if we're on a newer schema version and clear the database if so
|
||||||
|
if (SCHEMA_VERSION > row.user_version) {
|
||||||
|
db.exec(CLEAR_DB);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||||
|
// Force an analyze on initial connection
|
||||||
|
db.exec('PRAGMA optimize=0x10002;');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate an in memory DB if we're not in a secure context as OPFS is unavailable otherwise
|
||||||
|
if (!isSecure) {
|
||||||
|
db = new sqlite3.oo1.DB(':memory:');
|
||||||
|
setupDb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
try {
|
||||||
|
poolUtil = await sqlite3.installOpfsSAHPoolVfs();
|
||||||
|
db = new poolUtil.OpfsSAHPoolDb(dbName);
|
||||||
|
|
||||||
|
setupDb();
|
||||||
|
done = true;
|
||||||
|
} catch (/** @type {import('@sqlite.org/sqlite-wasm').SQLite3Error} */ err) {
|
||||||
|
// If we get a SQLite3Error and the pool is at capacity, we'll assume the error is
|
||||||
|
// because we hit the cap so we add more capacity and see if that solves it.
|
||||||
|
if (
|
||||||
|
err.name === 'SQLite3Error' &&
|
||||||
|
poolUtil.getCapacity() === poolUtil.getFileCount()
|
||||||
|
) {
|
||||||
|
await poolUtil.addCapacity(1);
|
||||||
|
|
||||||
|
// If we have more than 20 files, that might indicate a problem so let's just
|
||||||
|
// remove the VFS and wipe all the client data across users and restart.
|
||||||
|
if (poolUtil.getCapacity() > 20) {
|
||||||
|
await poolUtil.removeVfs();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('SQLite initialization error:', err);
|
||||||
|
|
||||||
|
// We might have gotten ourselves into a bad state which corrupted the DB so clear it out and retry
|
||||||
|
if (err?.resultCode === SQLITE_CORRUPT) {
|
||||||
|
poolUtil.unlink(`/${dbName}`);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
analyzeDatabase: () => {
|
||||||
|
db.exec('PRAGMA optimize');
|
||||||
|
},
|
||||||
|
clearDatabase: () => {
|
||||||
|
db.exec(CLEAR_DB);
|
||||||
|
db.exec(CREATE_TABLES(SCHEMA_VERSION));
|
||||||
|
},
|
||||||
|
deleteDatabase: () => {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error('No database was initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = db.dbFilename();
|
||||||
|
db.close();
|
||||||
|
db = null;
|
||||||
|
poolUtil.unlink(`/${name}`);
|
||||||
|
},
|
||||||
|
downloadDatabase: () => {
|
||||||
|
return sqlite3.capi.sqlite3_js_db_export(db);
|
||||||
|
},
|
||||||
|
fetchResource: ({ sql, parameters = [] }) => {
|
||||||
|
try {
|
||||||
|
return db.exec({
|
||||||
|
sql,
|
||||||
|
bind: parameters,
|
||||||
|
rowMode: 'object',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// TODO: Add error handling to rest of methods and properly return the error back to caller
|
||||||
|
console.error('Fetch resource error:', err, sql, parameters);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
insertResource: ({ resource, items, modelMapping }) => {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const numberOfParameters = items.reduce((acc, item) => {
|
||||||
|
return acc + item.length;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// We want to use as many VALUES as part of the insert statement as it's quicker
|
||||||
|
// than doing multiple inserts. If we have too many rows to insert, we'll manually
|
||||||
|
// chunk them up to avoid hitting the maximum number of host parameters
|
||||||
|
// and execute these separate insert statements in a transaction
|
||||||
|
if (numberOfParameters > MAX_HOST_PARAMETERS) {
|
||||||
|
const chunkSize = Math.floor(MAX_HOST_PARAMETERS / items[0].length);
|
||||||
|
|
||||||
|
return db.transaction(() => {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < items.length; i += chunkSize) {
|
||||||
|
const chunk = items.slice(i, i + chunkSize);
|
||||||
|
const result = db.exec({
|
||||||
|
sql: INSERT_STATEMENTS(resource, chunk, modelMapping),
|
||||||
|
bind: chunk.flat(),
|
||||||
|
rowMode: 'object',
|
||||||
|
});
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec({
|
||||||
|
sql: INSERT_STATEMENTS(resource, items, modelMapping),
|
||||||
|
bind: items.flat(),
|
||||||
|
rowMode: 'object',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteResource: ({ resource, ids }) => {
|
||||||
|
// Check if we have too many parameters for the deletion and chunk if so
|
||||||
|
if (ids?.length > MAX_HOST_PARAMETERS) {
|
||||||
|
return db.transaction(() => {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < ids.length; i += MAX_HOST_PARAMETERS) {
|
||||||
|
const chunk = ids.slice(i, i + MAX_HOST_PARAMETERS);
|
||||||
|
const result = db.exec({
|
||||||
|
sql: DELETE_STATEMENT(resource, chunk),
|
||||||
|
bind: chunk,
|
||||||
|
rowMode: 'object',
|
||||||
|
});
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.exec({
|
||||||
|
sql: DELETE_STATEMENT(resource, ids),
|
||||||
|
bind: ids,
|
||||||
|
rowMode: 'object',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const promiseWorker = new PWBWorker();
|
||||||
|
promiseWorker.register(async ({ method, payload }) => {
|
||||||
|
if (!sharedService) {
|
||||||
|
await ready;
|
||||||
|
return methods[method](payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharedService.status.ready;
|
||||||
|
return sharedService.proxy[method](payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initializeOnProviderChange = async (isProvider) => {
|
||||||
|
if (!isProvider) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Initialize the SQLite database when we become a provider
|
||||||
|
await sharedService.proxy.initializeSQLite();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only use the shared service in a secure context as weblocks aren't available otherwise
|
||||||
|
let sharedService;
|
||||||
|
if (isSecure) {
|
||||||
|
sharedService = sharedServiceInit(
|
||||||
|
'sqlite',
|
||||||
|
methods,
|
||||||
|
initializeOnProviderChange,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await methods.initializeSQLite();
|
||||||
|
readyResolve();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { underscore } from '@ember/string';
|
||||||
|
|
||||||
|
export const CLEAR_DB = `
|
||||||
|
PRAGMA writable_schema = 1;
|
||||||
|
DELETE FROM sqlite_master;
|
||||||
|
PRAGMA writable_schema = 0;
|
||||||
|
VACUUM;
|
||||||
|
PRAGMA integrity_check;`;
|
||||||
|
|
||||||
|
const createTargetTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS target (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
address TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_target_scope_id_created_time ON target(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
-- Create a content FTS table that references the original table
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS target_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
address,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='target',
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create triggers to keep the FTS table in sync with the target table
|
||||||
|
-- Note: The FTS table does not have an update trigger, as it's updated only on
|
||||||
|
-- INSERT and DELETE as we do an INSERT OR REPLACE INTO on the table
|
||||||
|
-- which replaces the row and doesn't execute an UPDATE trigger.
|
||||||
|
CREATE TRIGGER IF NOT EXISTS target_ai AFTER INSERT ON target BEGIN
|
||||||
|
INSERT INTO target_fts(
|
||||||
|
rowid, id, type, name, description, address, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.address, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS target_ad AFTER DELETE ON target BEGIN
|
||||||
|
INSERT INTO target_fts(target_fts, rowid, id, type, name, description, address, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.address, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createAliasTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS alias (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
destination_id TEXT,
|
||||||
|
value TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
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,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
destination_id,
|
||||||
|
value,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='alias',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS alias_ai AFTER INSERT ON alias BEGIN
|
||||||
|
INSERT INTO alias_fts(
|
||||||
|
rowid, id, type, name, description, destination_id, value, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.destination_id, new.value, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS alias_ad AFTER DELETE ON alias BEGIN
|
||||||
|
INSERT INTO alias_fts(alias_fts, rowid, id, type, name, description, destination_id, value, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.destination_id, old.value, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createGroupTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS "group" (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_group_scope_id_created_time ON "group"(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS group_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='group',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS group_ai AFTER INSERT ON "group" BEGIN
|
||||||
|
INSERT INTO group_fts(
|
||||||
|
rowid, id, name, description, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS group_ad AFTER DELETE ON "group" BEGIN
|
||||||
|
INSERT INTO group_fts(group_fts, rowid, id, name, description, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createRoleTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS role (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_scope_id_created_time ON role(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS role_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='role',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS role_ai AFTER INSERT ON role BEGIN
|
||||||
|
INSERT INTO role_fts(
|
||||||
|
rowid, id, name, description, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS role_ad AFTER DELETE ON role BEGIN
|
||||||
|
INSERT INTO role_fts(role_fts, rowid, id, name, description, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createUserTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS user (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_scope_id_created_time ON user(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS user_fts USING fts5(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='user',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS user_ai AFTER INSERT ON user BEGIN
|
||||||
|
INSERT INTO user_fts(
|
||||||
|
rowid, id, name, description, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.name, new.description, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS user_ad AFTER DELETE ON user BEGIN
|
||||||
|
INSERT INTO user_fts(user_fts, rowid, id, name, description, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.name, old.description, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createCredentialStoreTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS credential_store (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_credential_store_scope_id_created_time ON credential_store(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS credential_store_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='credential_store',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS credential_store_ai AFTER INSERT ON credential_store BEGIN
|
||||||
|
INSERT INTO credential_store_fts(
|
||||||
|
rowid, id, type, name, description, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS credential_store_ad AFTER DELETE ON credential_store BEGIN
|
||||||
|
INSERT INTO credential_store_fts(credential_store_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createScopeTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS scope (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scope_scope_id_created_time ON scope(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS scope_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='scope',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS scope_ai AFTER INSERT ON scope BEGIN
|
||||||
|
INSERT INTO scope_fts(
|
||||||
|
rowid, id, type, name, description, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS scope_ad AFTER DELETE ON scope BEGIN
|
||||||
|
INSERT INTO scope_fts(scope_fts, rowid, id, type, name, description, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createAuthMethodTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_method (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
is_primary INT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_auth_method_scope_id_created_time ON auth_method(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS auth_method_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
is_primary,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='auth_method',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS auth_method_ai AFTER INSERT ON auth_method BEGIN
|
||||||
|
INSERT INTO auth_method_fts(
|
||||||
|
rowid, id, type, name, description, is_primary, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.is_primary, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS auth_method_ad AFTER DELETE ON auth_method BEGIN
|
||||||
|
INSERT INTO auth_method_fts(auth_method_fts, rowid, id, type, name, description, is_primary, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.is_primary, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createHostCatalogTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS host_catalog (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
description TEXT,
|
||||||
|
plugin_name TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_host_catalog_scope_id_created_time ON host_catalog(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS host_catalog_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
plugin_name,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='host_catalog',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS host_catalog_ai AFTER INSERT ON host_catalog BEGIN
|
||||||
|
INSERT INTO host_catalog_fts(
|
||||||
|
rowid, id, type, name, description, plugin_name, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.name, new.description, new.plugin_name, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS host_catalog_ad AFTER DELETE ON host_catalog BEGIN
|
||||||
|
INSERT INTO host_catalog_fts(host_catalog_fts, rowid, id, type, name, description, plugin_name, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.name, old.description, old.plugin_name, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
const createSessionRecordingTables = `
|
||||||
|
CREATE TABLE IF NOT EXISTS session_recording (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
state TEXT,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
duration TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
user_name TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
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
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_recording_created_time ON session_recording(created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_recording_fts USING fts5(
|
||||||
|
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,
|
||||||
|
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, 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.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, 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 = `
|
||||||
|
CREATE TABLE IF NOT EXISTS session (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
type TEXT,
|
||||||
|
status TEXT,
|
||||||
|
endpoint TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
scope_id TEXT NOT NULL,
|
||||||
|
created_time TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_scope_id_created_time ON session(scope_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
status,
|
||||||
|
endpoint,
|
||||||
|
target_id,
|
||||||
|
user_id,
|
||||||
|
scope_id,
|
||||||
|
created_time,
|
||||||
|
content='session',
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS session_ai AFTER INSERT ON session BEGIN
|
||||||
|
INSERT INTO session_fts(
|
||||||
|
rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time
|
||||||
|
) VALUES (
|
||||||
|
new.rowid, new.id, new.type, new.status, new.endpoint, new.target_id, new.user_id, new.scope_id, new.created_time
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER IF NOT EXISTS session_ad AFTER DELETE ON session BEGIN
|
||||||
|
INSERT INTO session_fts(session_fts, rowid, id, type, status, endpoint, target_id, user_id, scope_id, created_time)
|
||||||
|
VALUES('delete', old.rowid, old.id, old.type, old.status, old.endpoint, old.target_id, old.user_id, old.scope_id, old.created_time);
|
||||||
|
END;`;
|
||||||
|
|
||||||
|
export const CREATE_TABLES = (version) => `
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- This ensures multiple triggers can activate in the scenario of using REPLACE INTO
|
||||||
|
PRAGMA recursive_triggers = true;
|
||||||
|
PRAGMA user_version = ${version};
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
token TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
${createTargetTables}
|
||||||
|
${createAliasTables}
|
||||||
|
${createGroupTables}
|
||||||
|
${createRoleTables}
|
||||||
|
${createUserTables}
|
||||||
|
${createCredentialStoreTables}
|
||||||
|
${createScopeTables}
|
||||||
|
${createAuthMethodTables}
|
||||||
|
${createHostCatalogTables}
|
||||||
|
${createSessionRecordingTables}
|
||||||
|
${createSessionTables}
|
||||||
|
|
||||||
|
COMMIT;`;
|
||||||
|
|
||||||
|
export const INSERT_STATEMENTS = (resource, items, modelMapping) => {
|
||||||
|
if (resource === 'token') {
|
||||||
|
return `REPLACE INTO token (id, token) VALUES ${items.map(() => '(?, ?)').join(', ')};`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelMapping?.[resource]) {
|
||||||
|
throw new Error(`modelMapping is required for ${resource} insertions.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(modelMapping[resource]);
|
||||||
|
// Extra column for the data column
|
||||||
|
const numColumns = Array(columns.length + 1);
|
||||||
|
const placeholders = `(${numColumns.fill('?').join(', ')})`;
|
||||||
|
|
||||||
|
return `REPLACE INTO "${underscore(resource)}" (${columns.join(', ')}, data) VALUES ${items.map(() => placeholders).join(', ')};`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE_STATEMENT = (resource, ids) => {
|
||||||
|
const baseDeleteStatement = `DELETE FROM "${underscore(resource)}"`;
|
||||||
|
return ids?.length > 0
|
||||||
|
? `${baseDeleteStatement} WHERE id IN (${ids.map(() => '?').join(',')})`
|
||||||
|
: baseDeleteStatement;
|
||||||
|
};
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
let clientChannel = null;
|
||||||
|
let commonChannel = null;
|
||||||
|
const requestsInFlight = new Map();
|
||||||
|
|
||||||
|
export default (serviceName, target, onProviderChange) => {
|
||||||
|
commonChannel = new BroadcastChannel(
|
||||||
|
`shared-service-common-channel-${serviceName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let { promise: ready, resolve: readyResolve } = Promise.withResolvers();
|
||||||
|
const status = {
|
||||||
|
ready,
|
||||||
|
isServiceProvider: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxy = getProxy(target, status);
|
||||||
|
|
||||||
|
// This lock is used to ensure that only one tab can become the provider with an indefinite lock.
|
||||||
|
navigator.locks.request(serviceName, { mode: 'exclusive' }, async () => {
|
||||||
|
// If a client was promoted to provider, reset the ready status and resolver
|
||||||
|
if (readyResolve === null) {
|
||||||
|
const { promise: newReady, resolve: newResolve } =
|
||||||
|
Promise.withResolvers();
|
||||||
|
status.ready = newReady;
|
||||||
|
readyResolve = newResolve;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.isServiceProvider = true;
|
||||||
|
await onBecomeProvider({
|
||||||
|
serviceName,
|
||||||
|
onProviderChange,
|
||||||
|
target,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
readyResolve();
|
||||||
|
readyResolve = null;
|
||||||
|
await new Promise(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let providerExists = false;
|
||||||
|
|
||||||
|
// Make sure a lock was acquired which means we have a provider
|
||||||
|
while (!providerExists) {
|
||||||
|
const locks = await navigator.locks.query();
|
||||||
|
providerExists = locks.held?.some((lock) => lock.name === serviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.isServiceProvider) {
|
||||||
|
await onNotProvider({
|
||||||
|
serviceName,
|
||||||
|
onProviderChange,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
readyResolve();
|
||||||
|
readyResolve = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { proxy, status };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientBroadcastChannelName = (clientId, serviceName) =>
|
||||||
|
`${clientId}-${serviceName}`;
|
||||||
|
|
||||||
|
// Generate a random ID for the tab to use as a lock name to hold indefinitely until the tab is closed
|
||||||
|
const getClientId = () => {
|
||||||
|
const clientId = uuidv4();
|
||||||
|
|
||||||
|
// Keep the lock until this context is destroyed (which means the tab was closed).
|
||||||
|
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||||
|
await new Promise(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return clientId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sends a message to the provider to call the method on behalf of a client.
|
||||||
|
const requestFromProvider = (uuid, resolve, reject, method, args) => {
|
||||||
|
const onRequestListener = getOnRequestListener(uuid, resolve, reject);
|
||||||
|
|
||||||
|
// Listen for a response to know we finished and remove from the list of requests in flight
|
||||||
|
clientChannel?.addEventListener('message', onRequestListener);
|
||||||
|
clientChannel?.postMessage({
|
||||||
|
id: uuid,
|
||||||
|
type: 'request',
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the listener used for responding to requests. Only listens for response messages.
|
||||||
|
const getOnRequestListener = (uuid, resolve, reject) => {
|
||||||
|
const listener = ({ data }) => {
|
||||||
|
const { id, type } = data;
|
||||||
|
if (id !== uuid || type === 'request') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestsInFlight.delete(uuid);
|
||||||
|
clientChannel?.removeEventListener('message', listener);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
reject(data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(data.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return listener;
|
||||||
|
};
|
||||||
|
|
||||||
|
// When we become a client instead of a provider we do two things:
|
||||||
|
// 1. Register ourselves with the provider and wait for a response that says we registered.
|
||||||
|
// 2. Setup an event listener to know when a provider has changed.
|
||||||
|
const onNotProvider = async ({ serviceName, onProviderChange, status }) => {
|
||||||
|
const clientId = getClientId();
|
||||||
|
clientChannel = new BroadcastChannel(
|
||||||
|
getClientBroadcastChannelName(clientId, serviceName),
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = async () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const onRegisteredListener = ({ data }) => {
|
||||||
|
if (data.clientId === clientId && data.type === 'registered') {
|
||||||
|
commonChannel.removeEventListener('message', onRegisteredListener);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add an event listener to check for the registration response
|
||||||
|
commonChannel.addEventListener('message', onRegisteredListener);
|
||||||
|
commonChannel.postMessage({ type: 'register', clientId });
|
||||||
|
});
|
||||||
|
|
||||||
|
commonChannel.addEventListener('message', async ({ data }) => {
|
||||||
|
// Check if this was a provider change. If so and we're not the new provider, re-register.
|
||||||
|
if (data.type === 'providerChange' && !status.isServiceProvider) {
|
||||||
|
await onProviderChange?.(status.isServiceProvider);
|
||||||
|
await register();
|
||||||
|
|
||||||
|
// If there were requests in flight when the provider was changed,
|
||||||
|
// we need to resend them to the new provider.
|
||||||
|
if (requestsInFlight.size > 0) {
|
||||||
|
requestsInFlight.forEach(({ method, args, resolve, reject }, uuid) => {
|
||||||
|
requestFromProvider(uuid, resolve, reject, method, args);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await register();
|
||||||
|
};
|
||||||
|
|
||||||
|
// When we become the provider, we do two things:
|
||||||
|
// 1. Set an event listener to check for clients trying to register with us. As part of registration,
|
||||||
|
// we also setup a listener with the individual client to listen for requests.
|
||||||
|
// 2. Send a message to all tabs that we've now become the provider
|
||||||
|
const onBecomeProvider = async ({
|
||||||
|
serviceName,
|
||||||
|
onProviderChange,
|
||||||
|
target,
|
||||||
|
status,
|
||||||
|
}) => {
|
||||||
|
// Set an event listener for other tabs to register with the provider tab
|
||||||
|
commonChannel.addEventListener('message', async (event) => {
|
||||||
|
const { clientId, type } = event.data;
|
||||||
|
if (type !== 'register') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientChannel = new BroadcastChannel(
|
||||||
|
getClientBroadcastChannelName(clientId, serviceName),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Acquire another lock that immediately is waiting for the
|
||||||
|
// initial lock on the client tab to be resolved to handle cleanup.
|
||||||
|
navigator.locks.request(clientId, { mode: 'exclusive' }, async () => {
|
||||||
|
// The client tab was closed, clean up.
|
||||||
|
clientChannel.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup an event listener for communication between the provider and subscriber to respond to requests
|
||||||
|
clientChannel.addEventListener('message', async ({ data }) => {
|
||||||
|
if (data.type === 'response') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { method, args, id } = data;
|
||||||
|
|
||||||
|
let result, error;
|
||||||
|
try {
|
||||||
|
result = await target[method](...args);
|
||||||
|
} catch (e) {
|
||||||
|
// Error objects won't be properly serialized, we need to reconstruct an error like object
|
||||||
|
error =
|
||||||
|
e instanceof Error
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.getOwnPropertyNames(e).map((k) => [k, e[k]]),
|
||||||
|
)
|
||||||
|
: e;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientChannel.postMessage({
|
||||||
|
id,
|
||||||
|
type: 'response',
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
method,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send back a message to the client that we finished registering
|
||||||
|
commonChannel.postMessage({ type: 'registered', clientId, serviceName });
|
||||||
|
});
|
||||||
|
|
||||||
|
await onProviderChange?.(status.isServiceProvider);
|
||||||
|
commonChannel.postMessage({ type: 'providerChange', serviceName });
|
||||||
|
|
||||||
|
// If we became the new provider and there were requests in flight, request it directly again.
|
||||||
|
if (requestsInFlight.size > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(
|
||||||
|
requestsInFlight,
|
||||||
|
async ([uuid, { method, args, resolve, reject }]) => {
|
||||||
|
try {
|
||||||
|
const result = await target[method](...args);
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
} finally {
|
||||||
|
requestsInFlight.delete(uuid);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns a proxy that we will have all caller methods go through.
|
||||||
|
// If we're the provider, we just call the method directly. If we're not the provider, we'll
|
||||||
|
// send the request through our established client channel to have the provider respond with the data.
|
||||||
|
const getProxy = (target, status) =>
|
||||||
|
new Proxy(target, {
|
||||||
|
get: (target, method) => {
|
||||||
|
if (method === 'then' || method === 'catch' || method === 'finally') {
|
||||||
|
// Return undefined for these methods to allow promise chaining to work correctly
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return async (...args) => {
|
||||||
|
if (status.isServiceProvider) {
|
||||||
|
return await target[method](...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const uuid = uuidv4();
|
||||||
|
requestFromProvider(uuid, resolve, reject, method, args);
|
||||||
|
requestsInFlight.set(uuid, { method, args, resolve, reject });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default } from 'api/services/indexed-db';
|
export { default } from 'api/services/db';
|
||||||
@@ -3,4 +3,4 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default } from 'rose/helpers/map-type-to-color';
|
export { default } from 'api/services/sqlite';
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Plugin = require('broccoli-plugin');
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const esbuild = require('esbuild');
|
||||||
|
|
||||||
|
module.exports = class BuildWorkers extends Plugin {
|
||||||
|
build() {
|
||||||
|
this.inputPaths.forEach((inputPath) => {
|
||||||
|
this.#buildWorkers(inputPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildWorkers(inputPath) {
|
||||||
|
const workers = this.#getWorkers(inputPath);
|
||||||
|
|
||||||
|
const workerBuilder = this.#configureWorkerBuilder({
|
||||||
|
isProduction: this.isProduction,
|
||||||
|
buildDir: this.outputPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(workers).map(workerBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
#getWorkers(inputPath) {
|
||||||
|
let workers = {};
|
||||||
|
let dir = fs.readdirSync(inputPath);
|
||||||
|
|
||||||
|
dir
|
||||||
|
.filter((name) => name.endsWith('-worker.js'))
|
||||||
|
.forEach((name) => {
|
||||||
|
workers[name] = path.join(inputPath, name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return workers;
|
||||||
|
}
|
||||||
|
|
||||||
|
#configureWorkerBuilder({ isProduction, buildDir }) {
|
||||||
|
return ([name, entryPath]) => {
|
||||||
|
esbuild.buildSync({
|
||||||
|
loader: { '.js': 'js' },
|
||||||
|
entryPoints: [entryPath],
|
||||||
|
bundle: true,
|
||||||
|
outfile: path.join(buildDir, name),
|
||||||
|
format: 'esm',
|
||||||
|
minify: isProduction,
|
||||||
|
sourcemap: !isProduction,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,69 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const { WatchedDir } = require('broccoli-source');
|
||||||
|
const funnel = require('broccoli-funnel');
|
||||||
|
const merge = require('broccoli-merge-trees');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const buildWorkers = require('./broccoli-plugins/build-workers');
|
||||||
|
|
||||||
|
const workerRoot = path.join(__dirname, 'addon', 'workers');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
name: require('./package').name,
|
name: require('./package').name,
|
||||||
|
|
||||||
|
options: {
|
||||||
|
// Don't automatically import sqlite, we need to manually control how it gets used
|
||||||
|
autoImport: {
|
||||||
|
exclude: ['@sqlite.org/sqlite-wasm'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
treeForAddon() {
|
||||||
|
// Exclude anything in the workers folder from being bundled in the final
|
||||||
|
// build as we're manually bundling the files ourselves below.
|
||||||
|
const tree = this._super.treeForAddon.apply(this, arguments);
|
||||||
|
return funnel(tree, {
|
||||||
|
exclude: ['api/workers/**/*'],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
treeForPublic(tree) {
|
||||||
|
const env = this.parent?.app?.env ?? 'production';
|
||||||
|
const { enableSqlite } = this.parent?.app?.options[this.name] ?? {};
|
||||||
|
|
||||||
|
// If sqlite is not enabled and we're not running a test directly in the addon,
|
||||||
|
// we don't need to build the workers
|
||||||
|
if (!enableSqlite && this.app?.env !== 'test') {
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to process and build our workers separately from ember as these
|
||||||
|
// workers are not part of the app and are not getting properly bundled otherwise
|
||||||
|
let workerTree = new WatchedDir(workerRoot);
|
||||||
|
workerTree = new buildWorkers([workerTree]);
|
||||||
|
workerTree.isProduction = env === 'production';
|
||||||
|
|
||||||
|
const workers = funnel(workerTree, {
|
||||||
|
destDir: 'workers',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also include the sqlite wasm file directly to be placed next to the workers
|
||||||
|
const sqliteWasmDirectoryPath = path.resolve(
|
||||||
|
require.resolve('@sqlite.org/sqlite-wasm/sqlite3.wasm'),
|
||||||
|
'..',
|
||||||
|
);
|
||||||
|
const wasmFile = funnel(sqliteWasmDirectoryPath, {
|
||||||
|
files: ['sqlite3.wasm'],
|
||||||
|
destDir: 'workers',
|
||||||
|
});
|
||||||
|
|
||||||
|
const trees = [workers, wasmFile];
|
||||||
|
if (tree) {
|
||||||
|
trees.push(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
return merge(trees);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -70,11 +70,16 @@ function routes() {
|
|||||||
let resultSet;
|
let resultSet;
|
||||||
// Default parent scope is global
|
// Default parent scope is global
|
||||||
if (!scope_id) scope_id = 'global';
|
if (!scope_id) scope_id = 'global';
|
||||||
|
// We do not return global scope to mimic our API.
|
||||||
if (recursive && scope_id === 'global') {
|
if (recursive && scope_id === 'global') {
|
||||||
resultSet = scopes.all();
|
resultSet = scopes.where((scope) => {
|
||||||
|
return scope.scope ? scope.type !== 'global' : false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
resultSet = scopes.where((scope) => {
|
resultSet = scopes.where((scope) => {
|
||||||
return scope.scope ? scope.scope.id === scope_id : false;
|
return scope.scope
|
||||||
|
? scope.scope.id === scope_id && scope.type !== 'global'
|
||||||
|
: false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const filteredResultSet = resultSet.filter(makeBooleanFilter(filter));
|
const filteredResultSet = resultSet.filter(makeBooleanFilter(filter));
|
||||||
@@ -537,10 +542,11 @@ function routes() {
|
|||||||
session.update({
|
session.update({
|
||||||
status: pickRandomStatusString(),
|
status: pickRandomStatusString(),
|
||||||
});
|
});
|
||||||
if (session.id.split('-').pop() % 2)
|
if (session.id.split('-').pop() % 2) {
|
||||||
session.update({
|
session.update({
|
||||||
userId: 'authenticateduser',
|
userId: 'authenticateduser',
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (recursive && scope_id === 'global') {
|
if (recursive && scope_id === 'global') {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import generateId from '../helpers/id';
|
|||||||
import {
|
import {
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
TYPES_CREDENTIAL_LIBRARY,
|
TYPES_CREDENTIAL_LIBRARY,
|
||||||
options,
|
options,
|
||||||
} from 'api/models/credential-library';
|
} from 'api/models/credential-library';
|
||||||
@@ -45,6 +46,10 @@ export default factory.extend({
|
|||||||
key_bits: faker.number.int(999),
|
key_bits: faker.number.int(999),
|
||||||
path: faker.system.directoryPath(),
|
path: faker.system.directoryPath(),
|
||||||
};
|
};
|
||||||
|
case TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP:
|
||||||
|
return {
|
||||||
|
path: faker.system.directoryPath(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -58,14 +58,14 @@ export default factory.extend({
|
|||||||
const { scope, type } = credentialStore;
|
const { scope, type } = credentialStore;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'vault':
|
case 'vault':
|
||||||
server.createList('credential-library', 2, {
|
server.createList('credential-library', 3, {
|
||||||
scope,
|
scope,
|
||||||
credentialStore,
|
credentialStore,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'static':
|
case 'static':
|
||||||
default:
|
default:
|
||||||
server.createList('credential', 3, {
|
server.createList('credential', 5, {
|
||||||
scope,
|
scope,
|
||||||
credentialStore,
|
credentialStore,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,19 +7,31 @@ import factory from '../generated/factories/credential';
|
|||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import permissions from '../helpers/permissions';
|
import permissions from '../helpers/permissions';
|
||||||
import generatedId from '../helpers/id';
|
import generatedId from '../helpers/id';
|
||||||
|
import {
|
||||||
|
TYPES_CREDENTIAL,
|
||||||
|
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 = ['username_password', 'ssh_private_key', 'json'];
|
const types = [...TYPES_CREDENTIAL];
|
||||||
|
|
||||||
export default factory.extend({
|
export default factory.extend({
|
||||||
type: (i) => types[i % types.length],
|
type: (i) => types[i % types.length],
|
||||||
id() {
|
id() {
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
case 'ssh_private_key':
|
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||||
return generatedId('credspk_');
|
return generatedId('credspk_');
|
||||||
case 'username_password':
|
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||||
return generatedId('credup_');
|
return generatedId('credup_');
|
||||||
case 'json':
|
case TYPE_CREDENTIAL_JSON:
|
||||||
return generatedId('credjson_');
|
return generatedId('credjson_');
|
||||||
|
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||||
|
return generatedId('credupd_');
|
||||||
|
case TYPE_CREDENTIAL_PASSWORD:
|
||||||
|
return generatedId('credp_');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
authorized_actions: () =>
|
authorized_actions: () =>
|
||||||
@@ -30,8 +42,21 @@ export default factory.extend({
|
|||||||
'delete',
|
'delete',
|
||||||
],
|
],
|
||||||
attributes() {
|
attributes() {
|
||||||
return {
|
switch (this.type) {
|
||||||
username: faker.internet.userName(),
|
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||||
};
|
return { username: faker.internet.userName() };
|
||||||
|
|
||||||
|
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||||
|
return {
|
||||||
|
username: faker.internet.userName(),
|
||||||
|
domain: faker.internet.domainName(),
|
||||||
|
};
|
||||||
|
|
||||||
|
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||||
|
return { username: faker.internet.userName() };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { trait } from 'miragejs';
|
|||||||
import permissions from '../helpers/permissions';
|
import permissions from '../helpers/permissions';
|
||||||
import generateId from '../helpers/id';
|
import generateId from '../helpers/id';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { TYPE_AUTH_METHOD_PASSWORD } from 'api/models/auth-method';
|
||||||
|
|
||||||
export default factory.extend({
|
export default factory.extend({
|
||||||
type: 'global',
|
type: 'global',
|
||||||
@@ -78,6 +79,15 @@ export default factory.extend({
|
|||||||
return name;
|
return name;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scope() {
|
||||||
|
if (this.type === 'global') {
|
||||||
|
return {
|
||||||
|
id: 'global',
|
||||||
|
type: 'global',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
withChildren: trait({
|
withChildren: trait({
|
||||||
afterCreate(record, server) {
|
afterCreate(record, server) {
|
||||||
if (record.type === 'global') {
|
if (record.type === 'global') {
|
||||||
@@ -99,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,18 @@ import { trait } from 'miragejs';
|
|||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import permissions from '../helpers/permissions';
|
import permissions from '../helpers/permissions';
|
||||||
import generateId from '../helpers/id';
|
import generateId from '../helpers/id';
|
||||||
import { TYPES_TARGET, TYPE_TARGET_SSH } from 'api/models/target';
|
import {
|
||||||
|
TYPES_TARGET,
|
||||||
|
TYPE_TARGET_SSH,
|
||||||
|
TYPE_TARGET_RDP,
|
||||||
|
} from 'api/models/target';
|
||||||
|
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 randomBoolean = (chance = 0.5) => Math.random() < chance;
|
||||||
const randomFilter = () =>
|
const randomFilter = () =>
|
||||||
@@ -16,6 +27,35 @@ const randomFilter = () =>
|
|||||||
const hostSetChance = 0.3;
|
const hostSetChance = 0.3;
|
||||||
const types = [...TYPES_TARGET];
|
const types = [...TYPES_TARGET];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to select items from a collection based on a condition.
|
||||||
|
*/
|
||||||
|
const selectItems = (collection, condition) =>
|
||||||
|
collection
|
||||||
|
.where(condition)
|
||||||
|
.models.filter(() => randomBoolean())
|
||||||
|
.map((item) => item.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to update target with selected credential sources and host sets.
|
||||||
|
*/
|
||||||
|
const updateTarget = (
|
||||||
|
target,
|
||||||
|
hostSets,
|
||||||
|
isInjectable,
|
||||||
|
injectedSources,
|
||||||
|
brokeredSources,
|
||||||
|
) => {
|
||||||
|
const fieldsToUpdate = {
|
||||||
|
hostSets,
|
||||||
|
brokeredCredentialSourceIds: brokeredSources,
|
||||||
|
};
|
||||||
|
if (isInjectable) {
|
||||||
|
fieldsToUpdate.injectedApplicationCredentialSourceIds = injectedSources;
|
||||||
|
}
|
||||||
|
target.update(fieldsToUpdate);
|
||||||
|
};
|
||||||
|
|
||||||
export default factory.extend({
|
export default factory.extend({
|
||||||
authorized_actions: () =>
|
authorized_actions: () =>
|
||||||
permissions.authorizedActionsFor('target') || [
|
permissions.authorizedActionsFor('target') || [
|
||||||
@@ -45,29 +85,87 @@ export default factory.extend({
|
|||||||
*/
|
*/
|
||||||
withAssociations: trait({
|
withAssociations: trait({
|
||||||
afterCreate(target, server) {
|
afterCreate(target, server) {
|
||||||
let randomlySelectedHostSets,
|
const { scope } = target;
|
||||||
randomlySelectedCredentialLibraries,
|
|
||||||
randomlySelectedCredentials;
|
const randomlySelectedHostSets = server.schema.hostSets
|
||||||
randomlySelectedHostSets = server.schema.hostSets
|
|
||||||
// BLERG: fun fact, for no reason at all, the element passed
|
// BLERG: fun fact, for no reason at all, the element passed
|
||||||
// into a where function is not a full model instance, as you might
|
// into a where function is not a full model instance, as you might
|
||||||
// expect at this level of abstraction, but appears to be a serialized
|
// expect at this level of abstraction, but appears to be a serialized
|
||||||
// representation of the model instance. It's very confusing since
|
// representation of the model instance. It's very confusing since
|
||||||
// the result set of `where` _is a collection of model instances_.
|
// the result set of `where` _is a collection of model instances_.
|
||||||
.where((hostSet) => hostSet.scopeId === target.scope.id)
|
.where((hostSet) => hostSet.scopeId === scope.id)
|
||||||
.models.filter(() => randomBoolean(hostSetChance));
|
.models.filter(() => randomBoolean(hostSetChance));
|
||||||
|
|
||||||
randomlySelectedCredentialLibraries = server.schema.credentialLibraries
|
const randomlySelectedBrokeredCredentialSources = [
|
||||||
.where(
|
...selectItems(
|
||||||
|
server.schema.credentialLibraries,
|
||||||
(credentialLibrary) => credentialLibrary.scopeId === target.scope.id,
|
(credentialLibrary) => credentialLibrary.scopeId === target.scope.id,
|
||||||
)
|
),
|
||||||
.models.filter(() => randomBoolean())
|
...selectItems(
|
||||||
.map((cred) => cred.id);
|
server.schema.credentials,
|
||||||
randomlySelectedCredentials = server.schema.credentials
|
(credential) => credential.scopeId === target.scope.id,
|
||||||
.where((credential) => credential.scopeId === target.scope.id)
|
),
|
||||||
.models.filter(() => randomBoolean())
|
];
|
||||||
.map((cred) => cred.id);
|
|
||||||
|
|
||||||
|
// Select compatible credential libraries and credentials for injection
|
||||||
|
// Since not every credential or credential library can be injected, we filter them to mimic the expected behavior.
|
||||||
|
const filteredCredentialLibraries = selectItems(
|
||||||
|
server.schema.credentialLibraries,
|
||||||
|
(cred) =>
|
||||||
|
cred.scopeId === scope.id &&
|
||||||
|
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,
|
||||||
|
(cred) =>
|
||||||
|
cred.scopeId === scope.id &&
|
||||||
|
![
|
||||||
|
TYPE_CREDENTIAL_JSON,
|
||||||
|
TYPE_CREDENTIAL_PASSWORD,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||||
|
].includes(cred.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
// RDP specific credential libraries and credentials
|
||||||
|
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.type == TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
);
|
||||||
|
const filteredCredentialsForRDP = selectItems(
|
||||||
|
server.schema.credentials,
|
||||||
|
(cred) =>
|
||||||
|
cred.scopeId === scope.id &&
|
||||||
|
[
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||||
|
].includes(cred.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update target based on type
|
||||||
|
const isInjectable =
|
||||||
|
target.type === TYPE_TARGET_SSH || target.type === TYPE_TARGET_RDP;
|
||||||
|
const injectedCredentialSources =
|
||||||
|
target.type === TYPE_TARGET_RDP
|
||||||
|
? [...filteredCredentialLibrariesForRDP, ...filteredCredentialsForRDP]
|
||||||
|
: [...filteredCredentialLibraries, ...filteredCredentials];
|
||||||
|
|
||||||
|
updateTarget(
|
||||||
|
target,
|
||||||
|
randomlySelectedHostSets,
|
||||||
|
isInjectable,
|
||||||
|
injectedCredentialSources,
|
||||||
|
randomlySelectedBrokeredCredentialSources,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add storage bucket for SSH targets
|
||||||
const storageBuckets = server.schema.storageBuckets.all().models;
|
const storageBuckets = server.schema.storageBuckets.all().models;
|
||||||
const randomlySelectedStorageBucket =
|
const randomlySelectedStorageBucket =
|
||||||
storageBuckets.length === 0
|
storageBuckets.length === 0
|
||||||
@@ -81,6 +179,7 @@ export default factory.extend({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Aliases
|
||||||
const aliases = server.schema.aliases.all().models;
|
const aliases = server.schema.aliases.all().models;
|
||||||
const randomlySelectedAliases =
|
const randomlySelectedAliases =
|
||||||
aliases.length === 0 ? undefined : faker.helpers.arrayElement(aliases);
|
aliases.length === 0 ? undefined : faker.helpers.arrayElement(aliases);
|
||||||
@@ -89,17 +188,6 @@ export default factory.extend({
|
|||||||
aliases: randomlySelectedAliases.aliases,
|
aliases: randomlySelectedAliases.aliases,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
target.update({
|
|
||||||
hostSets: randomlySelectedHostSets,
|
|
||||||
brokeredCredentialSourceIds: [
|
|
||||||
...randomlySelectedCredentialLibraries,
|
|
||||||
...randomlySelectedCredentials,
|
|
||||||
],
|
|
||||||
injectedApplicationCredentialSourceIds: [
|
|
||||||
...randomlySelectedCredentialLibraries,
|
|
||||||
...randomlySelectedCredentials,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,6 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default } from 'rose/modifiers/on-click-inside';
|
import Model from './base';
|
||||||
|
|
||||||
|
export default Model.extend({});
|
||||||
@@ -13,7 +13,7 @@ const oidcRequiredAttempts = 3;
|
|||||||
|
|
||||||
const commandHandlers = {
|
const commandHandlers = {
|
||||||
password: {
|
password: {
|
||||||
login: (payload, scopeAttrs) => {
|
login: (payload, auth_method_id, account_id, scopeAttrs) => {
|
||||||
if (payload.attributes.login_name === 'error') {
|
if (payload.attributes.login_name === 'error') {
|
||||||
return new Response(400);
|
return new Response(400);
|
||||||
} else {
|
} else {
|
||||||
@@ -25,9 +25,9 @@ const commandHandlers = {
|
|||||||
scope: scopeAttrs,
|
scope: scopeAttrs,
|
||||||
id: 'token123',
|
id: 'token123',
|
||||||
token: 'thetokenstring',
|
token: 'thetokenstring',
|
||||||
account_id: 'authenticatedaccount',
|
account_id: account_id || 'authenticatedaccount',
|
||||||
user_id: 'authenticateduser',
|
user_id: 'authenticateduser',
|
||||||
auth_method_id: 'authmethod123',
|
auth_method_id: auth_method_id || 'authmethod123',
|
||||||
created_time: '',
|
created_time: '',
|
||||||
updated_time: '',
|
updated_time: '',
|
||||||
last_used_time: '',
|
last_used_time: '',
|
||||||
@@ -61,7 +61,7 @@ const commandHandlers = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
token: (_, scopeAttrs) => {
|
token: (_, auth_method_id, account_id, scopeAttrs) => {
|
||||||
oidcAttemptCounter++;
|
oidcAttemptCounter++;
|
||||||
if (oidcAttemptCounter < oidcRequiredAttempts) {
|
if (oidcAttemptCounter < oidcRequiredAttempts) {
|
||||||
return new Response(202);
|
return new Response(202);
|
||||||
@@ -74,9 +74,9 @@ const commandHandlers = {
|
|||||||
scope: scopeAttrs,
|
scope: scopeAttrs,
|
||||||
id: 'token123',
|
id: 'token123',
|
||||||
token: 'thetokenstring',
|
token: 'thetokenstring',
|
||||||
account_id: '1',
|
account_id: account_id || '1',
|
||||||
user_id: 'authenticateduser',
|
user_id: 'authenticateduser',
|
||||||
auth_method_id: 'authmethod123',
|
auth_method_id: auth_method_id || 'authmethod123',
|
||||||
created_time: '',
|
created_time: '',
|
||||||
updated_time: '',
|
updated_time: '',
|
||||||
last_used_time: '',
|
last_used_time: '',
|
||||||
@@ -90,7 +90,7 @@ const commandHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handles all custom methods on /auth-methods/:id route
|
// 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(
|
const [, id, method] = request.params.id_method.match(
|
||||||
/(?<id>.[^:]*):(?<method>(.*))/,
|
/(?<id>.[^:]*):(?<method>(.*))/,
|
||||||
);
|
);
|
||||||
@@ -100,8 +100,14 @@ export function authHandler({ scopes, authMethods }, request) {
|
|||||||
const payload = JSON.parse(request.requestBody);
|
const payload = JSON.parse(request.requestBody);
|
||||||
const { command } = payload;
|
const { command } = payload;
|
||||||
const scope = scopes.find(authMethod.scopeId);
|
const scope = scopes.find(authMethod.scopeId);
|
||||||
|
const account = accounts.where({ authMethodId: authMethod.id })?.models[0];
|
||||||
const scopeAttrs = this.serialize(scopes.find(scope.id));
|
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
|
// TODO: this handler doesn't really belong here, but we already route
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ export default function (server) {
|
|||||||
{ scope, type: 'plugin' },
|
{ scope, type: 'plugin' },
|
||||||
'withChildren',
|
'withChildren',
|
||||||
);
|
);
|
||||||
server.createList('credential-store', 3, { scope }, 'withAssociations');
|
server.createList('credential-store', 5, { scope }, 'withAssociations');
|
||||||
server.createList('target', 2, { scope }, 'withAssociations');
|
server.createList('target', 4, { scope }, 'withAssociations');
|
||||||
server.create('target', { scope, address: '0.0.0.0' });
|
server.create('target', { scope, address: '0.0.0.0' });
|
||||||
// Sessions have target data. Create it after targets.
|
// Sessions have target data. Create it after targets.
|
||||||
server.createList('session', 4, { scope }, 'withAssociations');
|
server.createList('session', 4, { scope }, 'withAssociations');
|
||||||
|
|||||||
@@ -246,6 +246,22 @@ export default function initializeMockIPC(server, config) {
|
|||||||
}
|
}
|
||||||
pauseClientAgent() {}
|
pauseClientAgent() {}
|
||||||
resumeClientAgent() {}
|
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() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+10
-3
@@ -27,15 +27,22 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.26.10",
|
"@babel/core": "^7.26.10",
|
||||||
"@ember/string": "^4.0.0",
|
"@ember/string": "^4.0.0",
|
||||||
"dexie": "^3.2.4",
|
"@sqlite.org/sqlite-wasm": "^3.49.1-build4",
|
||||||
|
"broccoli-funnel": "^3.0.8",
|
||||||
|
"broccoli-merge-trees": "^4.2.0",
|
||||||
|
"broccoli-plugin": "^4.0.7",
|
||||||
|
"broccoli-source": "^3.0.1",
|
||||||
"ember-auto-import": "^2.10.0",
|
"ember-auto-import": "^2.10.0",
|
||||||
"ember-can": "^4.2.0",
|
"ember-can": "^4.2.0",
|
||||||
"ember-cli-babel": "^8.2.0",
|
"ember-cli-babel": "^8.2.0",
|
||||||
"ember-cli-htmlbars": "^6.3.0",
|
"ember-cli-htmlbars": "^6.3.0",
|
||||||
"ember-cli-mirage": "^3.0.3",
|
"ember-cli-mirage": "^3.0.3",
|
||||||
"ember-data": "~5.3.12",
|
"ember-data": "~5.3.12",
|
||||||
|
"esbuild": "^0.25.4",
|
||||||
"miragejs": "^0.1.48",
|
"miragejs": "^0.1.48",
|
||||||
"tracked-built-ins": "^3.1.1"
|
"promise-worker-bi": "^5.0.1",
|
||||||
|
"tracked-built-ins": "^4.0.0",
|
||||||
|
"uuid": "^11.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/eslint-parser": "^7.25.1",
|
"@babel/eslint-parser": "^7.25.1",
|
||||||
@@ -45,7 +52,7 @@
|
|||||||
"@ember/test-waiters": "^4.1.0",
|
"@ember/test-waiters": "^4.1.0",
|
||||||
"@embroider/test-setup": "^4.0.0",
|
"@embroider/test-setup": "^4.0.0",
|
||||||
"@faker-js/faker": "^8.0.2",
|
"@faker-js/faker": "^8.0.2",
|
||||||
"@glimmer/component": "^1.1.2",
|
"@glimmer/component": "^2.0.0",
|
||||||
"@glimmer/tracking": "^1.1.2",
|
"@glimmer/tracking": "^1.1.2",
|
||||||
"broccoli-asset-rev": "^3.0.0",
|
"broccoli-asset-rev": "^3.0.0",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
|
|||||||
@@ -1,487 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { module, test } from 'qunit';
|
|
||||||
import { setupTest } from 'ember-qunit';
|
|
||||||
import IndexedDbHandler from 'api/handlers/indexed-db-handler';
|
|
||||||
import RequestManager from '@ember-data/request';
|
|
||||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
|
||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import sinon from 'sinon';
|
|
||||||
import { assert } from '@ember/debug';
|
|
||||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
|
||||||
import TargetModel from 'api/models/target';
|
|
||||||
|
|
||||||
function createPaginatedResponseHandler(mirageRecords, { pageSize }) {
|
|
||||||
assert('pageSize is required', pageSize);
|
|
||||||
const pagesCount = mirageRecords.length / pageSize;
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
for (let i = 0; i < pagesCount; i++) {
|
|
||||||
const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize);
|
|
||||||
const isLastPage = i === pagesCount - 1;
|
|
||||||
const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`;
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
items,
|
|
||||||
list_token,
|
|
||||||
response_type: !isLastPage ? 'delta' : 'complete',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextResult(listToken) {
|
|
||||||
const index = results.findIndex(
|
|
||||||
({ list_token }) => list_token === listToken,
|
|
||||||
);
|
|
||||||
const nextResult = index === -1 ? null : results[index + 1];
|
|
||||||
return nextResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// mirage handler compatible with paginated responses
|
|
||||||
return function (_schema, request) {
|
|
||||||
const listToken = request.queryParams.list_token;
|
|
||||||
if (!listToken) {
|
|
||||||
return results[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = nextResult(listToken);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return new Response(
|
|
||||||
404,
|
|
||||||
{},
|
|
||||||
`No results found for list token "${listToken}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module('Unit | Handler | indexed-db-handler', function (hooks) {
|
|
||||||
setupTest(hooks);
|
|
||||||
setupIndexedDb(hooks);
|
|
||||||
setupMirage(hooks);
|
|
||||||
|
|
||||||
let manager,
|
|
||||||
indexeddbHandler,
|
|
||||||
store,
|
|
||||||
applicationAdapter,
|
|
||||||
indexedbHandlerSpy,
|
|
||||||
applicationAdapterSpy;
|
|
||||||
hooks.beforeEach(async function setupIndexHandler() {
|
|
||||||
store = this.owner.lookup('service:store');
|
|
||||||
manager = new RequestManager();
|
|
||||||
indexeddbHandler = new IndexedDbHandler(store);
|
|
||||||
applicationAdapter = store.adapterFor('application');
|
|
||||||
|
|
||||||
// spy on all handler and application adapter methods
|
|
||||||
indexedbHandlerSpy = sinon.spy(indexeddbHandler);
|
|
||||||
applicationAdapterSpy = sinon.spy(applicationAdapter);
|
|
||||||
|
|
||||||
store.requestManager = manager;
|
|
||||||
manager.use([indexeddbHandler]);
|
|
||||||
});
|
|
||||||
|
|
||||||
module('fetching and batch loading', function (hooks) {
|
|
||||||
// `testBatchLimit` and `serverResultPageLimit` are intentionally difference
|
|
||||||
// so that multiple calls to the server are required to fetch a single batch,
|
|
||||||
// and these multiples can be asserted in the tests individually
|
|
||||||
const testBatchLimit = 10;
|
|
||||||
const serverResultPageLimit = 5;
|
|
||||||
let aliases, aliasResponseHandlerSpy;
|
|
||||||
|
|
||||||
hooks.beforeEach(function setupMirageData() {
|
|
||||||
indexeddbHandler.batchLimit = testBatchLimit;
|
|
||||||
|
|
||||||
aliases = this.server.createList('alias', 50);
|
|
||||||
|
|
||||||
const aliasResponseHandler = createPaginatedResponseHandler(
|
|
||||||
this.server.schema.aliases.all().models,
|
|
||||||
{ pageSize: serverResultPageLimit },
|
|
||||||
);
|
|
||||||
|
|
||||||
aliasResponseHandlerSpy = sinon.spy(aliasResponseHandler);
|
|
||||||
this.server.get('aliases', aliasResponseHandlerSpy);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it returns all data for a resource from the server', async function (assert) {
|
|
||||||
const results = await store.query('alias', {});
|
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
aliases.length,
|
|
||||||
results.length,
|
|
||||||
'all records are returned',
|
|
||||||
);
|
|
||||||
|
|
||||||
const aliasesSortedByCreatedTime = [...aliases].sort(
|
|
||||||
(a, b) => new Date(b.created_time) - new Date(a.created_time),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
aliasesSortedByCreatedTime.map(({ name }) => name),
|
|
||||||
results.map(({ name }) => name),
|
|
||||||
'`name` property matches between the returned results and the mirage records sorted by created_time',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('supports batching requests and writes to indexedDB', async function (assert) {
|
|
||||||
await store.query('alias', {});
|
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
indexedbHandlerSpy.writeToIndexedDb.callCount,
|
|
||||||
aliases.length / testBatchLimit,
|
|
||||||
'handler #writeToIndexedDb is called once for each batch',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
applicationAdapterSpy.query.callCount,
|
|
||||||
aliases.length / testBatchLimit,
|
|
||||||
'application adapter #query is called once for each batch',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(
|
|
||||||
aliasResponseHandlerSpy.callCount,
|
|
||||||
aliases.length / serverResultPageLimit,
|
|
||||||
'server handler should be called once for each page',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it loads from indexeddb only and does not fetch from api, when peekIndexedDB option is used', async function (assert) {
|
|
||||||
const callCounts = {
|
|
||||||
get writeToIndexedDb() {
|
|
||||||
return indexedbHandlerSpy.writeToIndexedDb.callCount;
|
|
||||||
},
|
|
||||||
get aliasResponseHandler() {
|
|
||||||
return aliasResponseHandlerSpy.callCount;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// before first query, no data has been fetched or written to indexeddb
|
|
||||||
// the call counts should therefore be 0
|
|
||||||
assert.strictEqual(callCounts.aliasResponseHandler, 0);
|
|
||||||
assert.strictEqual(callCounts.writeToIndexedDb, 0);
|
|
||||||
|
|
||||||
const results = await store.query('alias', {});
|
|
||||||
assert.strictEqual(results.length, 50);
|
|
||||||
|
|
||||||
// call counts expected after first query
|
|
||||||
const expectedWriteToIndexedDbCallCount = 5;
|
|
||||||
const expectedResponseHandlerCallCount = 10;
|
|
||||||
|
|
||||||
// first `store.query()` calls api and writes batches to indexededdb
|
|
||||||
assert.strictEqual(
|
|
||||||
callCounts.aliasResponseHandler,
|
|
||||||
expectedResponseHandlerCallCount,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
callCounts.writeToIndexedDb,
|
|
||||||
expectedWriteToIndexedDbCallCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.query('alias', {}, { peekIndexedDB: true });
|
|
||||||
assert.strictEqual(results.length, 50);
|
|
||||||
|
|
||||||
// second run does not increase call counts to api or writes to indexeddb
|
|
||||||
assert.strictEqual(
|
|
||||||
callCounts.aliasResponseHandler,
|
|
||||||
expectedResponseHandlerCallCount,
|
|
||||||
);
|
|
||||||
assert.strictEqual(
|
|
||||||
callCounts.writeToIndexedDb,
|
|
||||||
expectedWriteToIndexedDbCallCount,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('sorting', function () {
|
|
||||||
test('it sorts by descending `created_time` by default', async function (assert) {
|
|
||||||
const target2020 = this.server.create('target', {
|
|
||||||
name: 'Target 2020',
|
|
||||||
created_time: '2020-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const target2021 = this.server.create('target', {
|
|
||||||
name: 'Target 2021',
|
|
||||||
created_time: '2021-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const target2022 = this.server.create('target', {
|
|
||||||
name: 'Target 2022',
|
|
||||||
created_time: '2022-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const target2023 = this.server.create('target', {
|
|
||||||
name: 'Target 2023',
|
|
||||||
created_time: '2023-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledTargets = faker.helpers.shuffle([
|
|
||||||
target2020,
|
|
||||||
target2021,
|
|
||||||
target2022,
|
|
||||||
target2023,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(shuffledTargets, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await store.query('target', {});
|
|
||||||
assert.deepEqual(
|
|
||||||
results.map(({ name }) => name),
|
|
||||||
['Target 2023', 'Target 2022', 'Target 2021', 'Target 2020'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
|
||||||
const target001 = this.server.create('target', { name: 'Target 001' });
|
|
||||||
const target002 = this.server.create('target', { name: 'Target 002' });
|
|
||||||
const target003 = this.server.create('target', { name: 'Target 003' });
|
|
||||||
const target004 = this.server.create('target', { name: 'Target 004' });
|
|
||||||
const target005 = this.server.create('target', { name: 'Target 005' });
|
|
||||||
|
|
||||||
const shuffledTargets = faker.helpers.shuffle([
|
|
||||||
target001,
|
|
||||||
target002,
|
|
||||||
target003,
|
|
||||||
target004,
|
|
||||||
target005,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(shuffledTargets, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await store.query('target', {
|
|
||||||
query: { sort: { attribute: 'name' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
results.map(({ name }) => name),
|
|
||||||
['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by descending, then ascending `name` using string sorting', async function (assert) {
|
|
||||||
const alias001 = this.server.create('alias', { name: 'Alias 001' });
|
|
||||||
const alias002 = this.server.create('alias', { name: 'Alias 002' });
|
|
||||||
const alias003 = this.server.create('alias', { name: 'Alias 003' });
|
|
||||||
const alias004 = this.server.create('alias', { name: 'Alias 004' });
|
|
||||||
|
|
||||||
const shuffledAliases = faker.helpers.shuffle([
|
|
||||||
alias001,
|
|
||||||
alias002,
|
|
||||||
alias003,
|
|
||||||
alias004,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'aliases',
|
|
||||||
createPaginatedResponseHandler(shuffledAliases, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultsDesc = await store.query('alias', {
|
|
||||||
query: { sort: { attribute: 'name', direction: 'desc' } },
|
|
||||||
});
|
|
||||||
const resultsAsc = await store.query('alias', {
|
|
||||||
query: { sort: { attribute: 'name', direction: 'asc' } },
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
resultsDesc.map(({ name }) => name),
|
|
||||||
['Alias 004', 'Alias 003', 'Alias 002', 'Alias 001'],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
resultsAsc.map(({ name }) => name),
|
|
||||||
['Alias 001', 'Alias 002', 'Alias 003', 'Alias 004'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by descending, then ascending `name` using custom string sorting', async function (assert) {
|
|
||||||
const alias1 = this.server.create('alias', { name: 'alias1' });
|
|
||||||
const alias2 = this.server.create('alias', { name: 'alias2' });
|
|
||||||
const alias3 = this.server.create('alias', { name: 'alias3' });
|
|
||||||
const alias4 = this.server.create('alias', { name: 'alias4' });
|
|
||||||
|
|
||||||
const sortName = (recordA, recordB) => {
|
|
||||||
const nameMap = {
|
|
||||||
alias3: 'Alpha',
|
|
||||||
alias4: 'Beta',
|
|
||||||
alias1: 'Delta',
|
|
||||||
alias2: 'Epsilon',
|
|
||||||
};
|
|
||||||
return String(nameMap[recordA.attributes.name]).localeCompare(
|
|
||||||
String(nameMap[recordB.attributes.name]),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shuffledAliases = faker.helpers.shuffle([
|
|
||||||
alias1,
|
|
||||||
alias2,
|
|
||||||
alias3,
|
|
||||||
alias4,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'aliases',
|
|
||||||
createPaginatedResponseHandler(shuffledAliases, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultsDesc = await store.query('alias', {
|
|
||||||
query: { sort: { sortFunction: sortName, direction: 'desc' } },
|
|
||||||
});
|
|
||||||
const resultsAsc = await store.query('alias', {
|
|
||||||
query: { sort: { sortFunction: sortName, direction: 'asc' } },
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
resultsDesc.map(({ name }) => name),
|
|
||||||
['alias2', 'alias1', 'alias4', 'alias3'],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
resultsAsc.map(({ name }) => name),
|
|
||||||
['alias3', 'alias4', 'alias1', 'alias2'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('option: pushToStore', function (hooks) {
|
|
||||||
let mirageTargets;
|
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
|
||||||
mirageTargets = this.server.createList('target', 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it pushes fetched results to the store by default', async function (assert) {
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(mirageTargets, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(mirageTargets.length, 5);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 0);
|
|
||||||
const results = await store.query('target', {});
|
|
||||||
assert.strictEqual(results.length, 5);
|
|
||||||
assert.ok(
|
|
||||||
results.every((result) => result instanceof TargetModel),
|
|
||||||
'results are ember data models',
|
|
||||||
);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it pushes fethed results to the store when pushToStore is true', async function (assert) {
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(mirageTargets, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(mirageTargets.length, 5);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 0);
|
|
||||||
const results = await store.query('target', {}, { pushToStore: true });
|
|
||||||
assert.strictEqual(results.length, 5);
|
|
||||||
assert.ok(
|
|
||||||
results.every((result) => result instanceof TargetModel),
|
|
||||||
'results are ember data models',
|
|
||||||
);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it does not push fetched results to the store when pushToStore is false', async function (assert) {
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(mirageTargets, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(mirageTargets.length, 5);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 0);
|
|
||||||
const results = await store.query('target', {}, { pushToStore: false });
|
|
||||||
assert.strictEqual(results.length, 5);
|
|
||||||
assert.ok(
|
|
||||||
results.every((result) => !(result instanceof TargetModel)),
|
|
||||||
'results are not ember data models',
|
|
||||||
);
|
|
||||||
assert.strictEqual(store.peekAll('target').length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it supports searching', async function (assert) {
|
|
||||||
this.server.create('target', {
|
|
||||||
name: 'Magical Target',
|
|
||||||
description: 'A target',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.create('target', {
|
|
||||||
name: 'Target 2',
|
|
||||||
description: 'Target with magic',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.createList('target', 4);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(this.server.schema.targets.all().models, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await store.query('target', {
|
|
||||||
query: { search: 'magic' },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
results
|
|
||||||
// search results aren't returning in a consistent order so
|
|
||||||
// the sorting is used to be able to assert
|
|
||||||
.sort((a, b) => (a.name > b.name ? 1 : -1))
|
|
||||||
.map(({ name, description }) => ({ name, description })),
|
|
||||||
[
|
|
||||||
{
|
|
||||||
description: 'A target',
|
|
||||||
name: 'Magical Target',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
description: 'Target with magic',
|
|
||||||
name: 'Target 2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it supports filtering', async function (assert) {
|
|
||||||
this.server.create('target', {
|
|
||||||
name: 'specific-target',
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.createList('target', 5);
|
|
||||||
|
|
||||||
this.server.get(
|
|
||||||
'targets',
|
|
||||||
createPaginatedResponseHandler(this.server.schema.targets.all().models, {
|
|
||||||
pageSize: 1,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await store.query('target', {
|
|
||||||
query: { filters: { name: [{ equals: 'specific-target' }] } },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.strictEqual(results[0].name, 'specific-target');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'ember-qunit';
|
||||||
|
import SqliteHandler from 'api/handlers/sqlite-handler';
|
||||||
|
import RequestManager from '@ember-data/request';
|
||||||
|
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import { assert } from '@ember/debug';
|
||||||
|
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||||
|
import TargetModel from 'api/models/target';
|
||||||
|
|
||||||
|
function createPaginatedResponseHandler(mirageRecords, { pageSize }) {
|
||||||
|
assert('pageSize is required', pageSize);
|
||||||
|
assert('mirage server on `this` is required', this.server);
|
||||||
|
const pagesCount = mirageRecords.length / pageSize;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (let i = 0; i < pagesCount; i++) {
|
||||||
|
const items = mirageRecords.slice(i * pageSize, (i + 1) * pageSize);
|
||||||
|
const isLastPage = i === pagesCount - 1;
|
||||||
|
const list_token = `list-token-${faker.string.nanoid({ min: 5, max: 5 })}`;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
items: items.map((model) =>
|
||||||
|
this.server.serializerOrRegistry.serialize(model),
|
||||||
|
),
|
||||||
|
list_token,
|
||||||
|
response_type: !isLastPage ? 'delta' : 'complete',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextResult(listToken) {
|
||||||
|
const index = results.findIndex(
|
||||||
|
({ list_token }) => list_token === listToken,
|
||||||
|
);
|
||||||
|
const nextResult = index === -1 ? null : results[index + 1];
|
||||||
|
return nextResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirage handler compatible with paginated responses
|
||||||
|
return function (_schema, request) {
|
||||||
|
const listToken = request.queryParams.list_token;
|
||||||
|
if (!listToken) {
|
||||||
|
return results[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = nextResult(listToken);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return new Response(
|
||||||
|
404,
|
||||||
|
{},
|
||||||
|
`No results found for list token "${listToken}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Unit | Handler | sqlite-handler', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
setupSqlite(hooks);
|
||||||
|
|
||||||
|
let manager,
|
||||||
|
scope,
|
||||||
|
sqliteHandler,
|
||||||
|
store,
|
||||||
|
applicationAdapter,
|
||||||
|
sqliteHandlerSpy;
|
||||||
|
hooks.beforeEach(async function setupSqliteHandler() {
|
||||||
|
store = this.owner.lookup('service:store');
|
||||||
|
manager = new RequestManager();
|
||||||
|
sqliteHandler = new SqliteHandler(store);
|
||||||
|
applicationAdapter = store.adapterFor('application');
|
||||||
|
|
||||||
|
// spy on handler methods
|
||||||
|
sqliteHandlerSpy = sinon.spy(sqliteHandler);
|
||||||
|
|
||||||
|
store.requestManager = manager;
|
||||||
|
manager.use([sqliteHandler]);
|
||||||
|
|
||||||
|
scope = this.server.create('scope', {
|
||||||
|
type: 'project',
|
||||||
|
name: 'Project Scope',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(async function cleanUpSqliteHandler() {
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
module('fetching and batch loading', function (hooks) {
|
||||||
|
// `testBatchLimit` and `serverResultPageLimit` are intentionally difference
|
||||||
|
// so that multiple calls to the server are required to fetch a single batch,
|
||||||
|
// and these multiples can be asserted in the tests individually
|
||||||
|
const testBatchLimit = 10;
|
||||||
|
const serverResultPageLimit = 5;
|
||||||
|
let targets, targetsResponseHandlerSpy;
|
||||||
|
|
||||||
|
hooks.beforeEach(function setupMirageData() {
|
||||||
|
sqliteHandler.batchLimit = testBatchLimit;
|
||||||
|
|
||||||
|
targets = this.server.createList('target', 50, { scope });
|
||||||
|
|
||||||
|
// This is to just add a number to each target name to guarantee uniqueness in
|
||||||
|
// case there was a duplicate rather than increasing the number of words generated by faker.
|
||||||
|
// Because we have a UNIQUE constraint on target names and we do UPSERTS, if there's a
|
||||||
|
// duplicate name we'll end up replacing it in the DB and then have fewer targets
|
||||||
|
// than expected and flaky tests.
|
||||||
|
for (let i = 0; i < targets.length; i++) {
|
||||||
|
targets[i].name = targets[i].name + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetResponseHandler = createPaginatedResponseHandler.call(
|
||||||
|
this,
|
||||||
|
this.server.schema.targets.all().models,
|
||||||
|
{ pageSize: serverResultPageLimit },
|
||||||
|
);
|
||||||
|
|
||||||
|
targetsResponseHandlerSpy = sinon.spy(targetResponseHandler);
|
||||||
|
this.server.get('targets', targetsResponseHandlerSpy);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns all data for a resource from the server', async function (assert) {
|
||||||
|
const results = await store.query('target', {});
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
targets.length,
|
||||||
|
results.length,
|
||||||
|
'all records are returned',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports batching requests and writes to SQLite', async function (assert) {
|
||||||
|
const applicationAdapterSpy = sinon.spy(applicationAdapter);
|
||||||
|
|
||||||
|
await store.query('target', {});
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
sqliteHandlerSpy.writeToDb.callCount,
|
||||||
|
targets.length / testBatchLimit,
|
||||||
|
'handler #writeToDb is called once for each batch',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
applicationAdapterSpy.query.callCount,
|
||||||
|
targets.length / testBatchLimit,
|
||||||
|
'application adapter #query is called once for each batch',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
targetsResponseHandlerSpy.callCount,
|
||||||
|
targets.length / serverResultPageLimit,
|
||||||
|
'server handler should be called once for each page',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it loads from SQLite only and does not fetch from api, when peekDb option is used', async function (assert) {
|
||||||
|
const callCounts = {
|
||||||
|
get writeToDb() {
|
||||||
|
return sqliteHandlerSpy.writeToDb.callCount;
|
||||||
|
},
|
||||||
|
get targetResponseHandler() {
|
||||||
|
return targetsResponseHandlerSpy.callCount;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// before first query, no data has been fetched or written to sqlite
|
||||||
|
// the call counts should therefore be 0
|
||||||
|
assert.strictEqual(callCounts.targetResponseHandler, 0);
|
||||||
|
assert.strictEqual(callCounts.writeToDb, 0);
|
||||||
|
|
||||||
|
const results = await store.query('target', {});
|
||||||
|
assert.strictEqual(results.length, 50);
|
||||||
|
|
||||||
|
// call counts expected after first query
|
||||||
|
const expectedWriteToSqliteCallCount = 5;
|
||||||
|
const expectedResponseHandlerCallCount = 10;
|
||||||
|
|
||||||
|
// first `store.query()` calls api and writes batches to sqlite
|
||||||
|
assert.strictEqual(
|
||||||
|
callCounts.targetResponseHandler,
|
||||||
|
expectedResponseHandlerCallCount,
|
||||||
|
);
|
||||||
|
assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount);
|
||||||
|
|
||||||
|
await store.query('target', {}, { peekDb: true });
|
||||||
|
assert.strictEqual(results.length, 50);
|
||||||
|
|
||||||
|
// second run does not increase call counts to api or writes to sqlite
|
||||||
|
assert.strictEqual(
|
||||||
|
callCounts.targetResponseHandler,
|
||||||
|
expectedResponseHandlerCallCount,
|
||||||
|
);
|
||||||
|
assert.strictEqual(callCounts.writeToDb, expectedWriteToSqliteCallCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it stores and retrieves tokens when storeToken is true (default)', async function (assert) {
|
||||||
|
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||||
|
|
||||||
|
await store.query('target', {});
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
sqliteSpy.insertResource.withArgs('token').callCount,
|
||||||
|
targets.length / testBatchLimit,
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
sqliteSpy.insertResource.withArgs('target').callCount,
|
||||||
|
targets.length / testBatchLimit,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it clears database and does not store tokens when storeToken is false', async function (assert) {
|
||||||
|
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||||
|
|
||||||
|
await store.query('target', {}, { storeToken: false });
|
||||||
|
|
||||||
|
// Targets are all deleted one time
|
||||||
|
assert.ok(sqliteSpy.deleteResource.calledOnceWithExactly('target'));
|
||||||
|
|
||||||
|
assert.ok(sqliteSpy.insertResource.withArgs('token').notCalled);
|
||||||
|
assert.strictEqual(
|
||||||
|
sqliteSpy.insertResource.withArgs('target').callCount,
|
||||||
|
targets.length / testBatchLimit,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module('option: pushToStore', function (hooks) {
|
||||||
|
let mirageTargets;
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
mirageTargets = this.server.createList('target', 5, { scope });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it pushes fetched results to the store by default', async function (assert) {
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(mirageTargets.length, 5);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 0);
|
||||||
|
const results = await store.query('target', {});
|
||||||
|
assert.strictEqual(results.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
results.every((result) => result instanceof TargetModel),
|
||||||
|
'results are ember data models',
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it pushes fetched results to the store when pushToStore is true', async function (assert) {
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(mirageTargets.length, 5);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 0);
|
||||||
|
const results = await store.query('target', {}, { pushToStore: true });
|
||||||
|
assert.strictEqual(results.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
results.every((result) => result instanceof TargetModel),
|
||||||
|
'results are ember data models',
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not push fetched results to the store when pushToStore is false', async function (assert) {
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, mirageTargets, {
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(mirageTargets.length, 5);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 0);
|
||||||
|
const results = await store.query('target', {}, { pushToStore: false });
|
||||||
|
assert.strictEqual(results.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
results.every((result) => !(result instanceof TargetModel)),
|
||||||
|
'results are not ember data models',
|
||||||
|
);
|
||||||
|
assert.strictEqual(store.peekAll('target').length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module('sorting', function () {
|
||||||
|
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
||||||
|
const target001 = this.server.create('target', {
|
||||||
|
name: 'Target 001',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target002 = this.server.create('target', {
|
||||||
|
name: 'Target 002',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target003 = this.server.create('target', {
|
||||||
|
name: 'Target 003',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target004 = this.server.create('target', {
|
||||||
|
name: 'Target 004',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target005 = this.server.create('target', {
|
||||||
|
name: 'Target 005',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shuffledTargets = faker.helpers.shuffle([
|
||||||
|
target001,
|
||||||
|
target002,
|
||||||
|
target003,
|
||||||
|
target004,
|
||||||
|
target005,
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, shuffledTargets, {
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await store.query('target', {
|
||||||
|
query: { sort: { attributes: ['name'] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
results.map(({ name }) => name),
|
||||||
|
['Target 001', 'Target 002', 'Target 003', 'Target 004', 'Target 005'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it sorts by descending, then ascending `name` using string sorting', async function (assert) {
|
||||||
|
const target001 = this.server.create('target', {
|
||||||
|
name: 'Target 001',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target002 = this.server.create('target', {
|
||||||
|
name: 'Target 002',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target003 = this.server.create('target', {
|
||||||
|
name: 'Target 003',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
const target004 = this.server.create('target', {
|
||||||
|
name: 'Target 004',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shuffledTargets = faker.helpers.shuffle([
|
||||||
|
target001,
|
||||||
|
target002,
|
||||||
|
target003,
|
||||||
|
target004,
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, shuffledTargets, {
|
||||||
|
pageSize: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resultsDesc = await store.query('target', {
|
||||||
|
query: { sort: { attributes: ['name'], direction: 'desc' } },
|
||||||
|
});
|
||||||
|
const resultsAsc = await store.query('target', {
|
||||||
|
query: { sort: { attributes: ['name'], direction: 'asc' } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
resultsDesc.map(({ name }) => name),
|
||||||
|
['Target 004', 'Target 003', 'Target 002', 'Target 001'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
resultsAsc.map(({ name }) => name),
|
||||||
|
['Target 001', 'Target 002', 'Target 003', 'Target 004'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it supports searching', async function (assert) {
|
||||||
|
this.server.create('target', {
|
||||||
|
name: 'Magical Target',
|
||||||
|
description: 'A target',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.create('target', {
|
||||||
|
name: 'Target 2',
|
||||||
|
description: 'Target with magic',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.createList('target', 4, { scope });
|
||||||
|
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(
|
||||||
|
this,
|
||||||
|
this.server.schema.targets.all().models,
|
||||||
|
{
|
||||||
|
pageSize: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await store.query('target', {
|
||||||
|
query: { search: 'magic' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
results
|
||||||
|
// search results aren't returning in a consistent order so
|
||||||
|
// the sorting is used to be able to assert
|
||||||
|
.sort((a, b) => (a.name > b.name ? 1 : -1))
|
||||||
|
.map(({ name, description }) => ({ name, description })),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
description: 'A target',
|
||||||
|
name: 'Magical Target',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: 'Target with magic',
|
||||||
|
name: 'Target 2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it supports filtering', async function (assert) {
|
||||||
|
this.server.create('target', {
|
||||||
|
name: 'specific-target',
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.createList('target', 5, { scope });
|
||||||
|
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(
|
||||||
|
this,
|
||||||
|
this.server.schema.targets.all().models,
|
||||||
|
{
|
||||||
|
pageSize: 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await store.query('target', {
|
||||||
|
query: { filters: { name: [{ equals: 'specific-target' }] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(results[0].name, 'specific-target');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it retries query when invalid list token error occurs', async function (assert) {
|
||||||
|
let targets = this.server.createList('target', 5, { scope });
|
||||||
|
|
||||||
|
const sqliteSpy = sinon.spy(sqliteHandler.sqlite);
|
||||||
|
|
||||||
|
// Mock the first query to fail with invalid list token error but return data as normal otherwise
|
||||||
|
sinon
|
||||||
|
.stub(applicationAdapter, 'query')
|
||||||
|
.callThrough()
|
||||||
|
.onFirstCall()
|
||||||
|
.throws(() => {
|
||||||
|
const error = new Error('Invalid list token');
|
||||||
|
error.errors = [
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
code: 'invalid list token',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return error;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.get(
|
||||||
|
'targets',
|
||||||
|
createPaginatedResponseHandler.call(this, targets, {
|
||||||
|
pageSize: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert tokens to simulate existing state
|
||||||
|
await sqliteHandler.sqlite.insertResource('token', [
|
||||||
|
['target-123', 'invalid key'],
|
||||||
|
['target-321', 'valid key'],
|
||||||
|
['session-123', 'session key'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results = await store.query('target', {});
|
||||||
|
|
||||||
|
assert.ok(sqliteHandlerSpy.retryQueryFailure.calledOnce);
|
||||||
|
|
||||||
|
assert.ok(sqliteSpy.deleteResource.calledWithExactly('target'));
|
||||||
|
assert.ok(
|
||||||
|
sqliteSpy.deleteResource.calledWithExactly('token', [
|
||||||
|
'target-123',
|
||||||
|
'target-321',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the query eventually succeeded
|
||||||
|
assert.strictEqual(results.length, 5);
|
||||||
|
|
||||||
|
// Verify query was called twice (initial fail + retry)
|
||||||
|
assert.ok(applicationAdapter.query.calledTwice);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,11 @@
|
|||||||
|
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'ember-qunit';
|
import { setupTest } from 'ember-qunit';
|
||||||
import { TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC } from 'api/models/credential-library';
|
import {
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
} from 'api/models/credential-library';
|
||||||
|
|
||||||
module('Unit | Model | credential library', function (hooks) {
|
module('Unit | Model | credential library', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -22,10 +26,18 @@ module('Unit | Model | credential library', function (hooks) {
|
|||||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||||
});
|
});
|
||||||
const modelB = store.createRecord('credential-library', {
|
const modelB = store.createRecord('credential-library', {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||||
|
});
|
||||||
|
const modelC = store.createRecord('credential-library', {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
});
|
||||||
|
const modelD = store.createRecord('credential-library', {
|
||||||
type: 'unknown',
|
type: 'unknown',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.false(modelA.isUnknown);
|
assert.false(modelA.isUnknown);
|
||||||
assert.true(modelB.isUnknown);
|
assert.false(modelB.isUnknown);
|
||||||
|
assert.false(modelC.isUnknown);
|
||||||
|
assert.true(modelD.isUnknown);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,13 @@
|
|||||||
|
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'ember-qunit';
|
import { setupTest } from 'ember-qunit';
|
||||||
|
import {
|
||||||
|
TYPE_CREDENTIAL_JSON,
|
||||||
|
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||||
|
TYPE_CREDENTIAL_USERNAME_PASSWORD
|
||||||
|
} from 'api/models/credential';
|
||||||
|
|
||||||
|
|
||||||
module('Unit | Model | credential', function (hooks) {
|
module('Unit | Model | credential', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -28,12 +35,12 @@ module('Unit | Model | credential', function (hooks) {
|
|||||||
|
|
||||||
test('it has isJSON property and returns the expected values', async function (assert) {
|
test('it has isJSON property and returns the expected values', async function (assert) {
|
||||||
const store = this.owner.lookup('service:store');
|
const store = this.owner.lookup('service:store');
|
||||||
const modelA = store.createRecord('credential', { type: 'json' });
|
const modelA = store.createRecord('credential', { type: TYPE_CREDENTIAL_JSON });
|
||||||
const modelB = store.createRecord('credential', {
|
const modelB = store.createRecord('credential', {
|
||||||
type: 'username_password',
|
type: TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||||
});
|
});
|
||||||
const modelC = store.createRecord('credential', {
|
const modelC = store.createRecord('credential', {
|
||||||
type: 'ssh_private_key',
|
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.true(modelA.isJSON);
|
assert.true(modelA.isJSON);
|
||||||
@@ -44,15 +51,17 @@ module('Unit | Model | credential', function (hooks) {
|
|||||||
test('it has isUnknown property and returns the expected values', async function (assert) {
|
test('it has isUnknown property and returns the expected values', async function (assert) {
|
||||||
const store = this.owner.lookup('service:store');
|
const store = this.owner.lookup('service:store');
|
||||||
const modelA = store.createRecord('credential', {
|
const modelA = store.createRecord('credential', {
|
||||||
type: 'username_password',
|
type: TYPE_CREDENTIAL_USERNAME_PASSWORD
|
||||||
});
|
});
|
||||||
const modelB = store.createRecord('credential', {
|
const modelB = store.createRecord('credential', {
|
||||||
type: 'ssh_private_key',
|
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY
|
||||||
});
|
});
|
||||||
const modelC = store.createRecord('credential', { type: 'unknown' });
|
const modelC = store.createRecord('credential', { type: 'unknown' });
|
||||||
|
const modelD = store.createRecord('credential', { type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN });
|
||||||
|
|
||||||
assert.false(modelA.isUnknown);
|
assert.false(modelA.isUnknown);
|
||||||
assert.false(modelB.isUnknown);
|
assert.false(modelB.isUnknown);
|
||||||
assert.true(modelC.isUnknown);
|
assert.true(modelC.isUnknown);
|
||||||
|
assert.false(modelD.isUnknown);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -6,7 +6,11 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'ember-qunit';
|
import { setupTest } from 'ember-qunit';
|
||||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
||||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
import {
|
||||||
|
TYPE_TARGET_TCP,
|
||||||
|
TYPE_TARGET_SSH,
|
||||||
|
TYPE_TARGET_RDP,
|
||||||
|
} from 'api/models/target';
|
||||||
|
|
||||||
module('Unit | Model | target', function (hooks) {
|
module('Unit | Model | target', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -386,6 +390,7 @@ module('Unit | Model | target', function (hooks) {
|
|||||||
});
|
});
|
||||||
assert.true(modelSSH.isSSH);
|
assert.true(modelSSH.isSSH);
|
||||||
assert.false(modelSSH.isTCP);
|
assert.false(modelSSH.isTCP);
|
||||||
|
assert.false(modelSSH.isRDP);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it has isTCP property and returns the expected values', function (assert) {
|
test('it has isTCP property and returns the expected values', function (assert) {
|
||||||
@@ -395,5 +400,16 @@ module('Unit | Model | target', function (hooks) {
|
|||||||
});
|
});
|
||||||
assert.true(modelTCP.isTCP);
|
assert.true(modelTCP.isTCP);
|
||||||
assert.false(modelTCP.isSSH);
|
assert.false(modelTCP.isSSH);
|
||||||
|
assert.false(modelTCP.isRDP);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it has isRDP property and returns the expected values', function (assert) {
|
||||||
|
const store = this.owner.lookup('service:store');
|
||||||
|
const modelRDP = store.createRecord('target', {
|
||||||
|
type: TYPE_TARGET_RDP,
|
||||||
|
});
|
||||||
|
assert.true(modelRDP.isRDP);
|
||||||
|
assert.false(modelRDP.isSSH);
|
||||||
|
assert.false(modelRDP.isTCP);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,7 @@ import { setupTest } from 'ember-qunit';
|
|||||||
import {
|
import {
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||||
|
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
} from 'api/models/credential-library';
|
} from 'api/models/credential-library';
|
||||||
|
|
||||||
module('Unit | Serializer | credential library', function (hooks) {
|
module('Unit | Serializer | credential library', function (hooks) {
|
||||||
@@ -24,9 +25,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
|||||||
http_method: 'GET',
|
http_method: 'GET',
|
||||||
version: 1,
|
version: 1,
|
||||||
credential_type: 'ssh_private_key',
|
credential_type: 'ssh_private_key',
|
||||||
credential_mapping_overrides: [
|
credential_mapping_overrides: { username_attribute: 'user' },
|
||||||
{ key: 'username_attribute', value: 'user' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const snapshot = record._createSnapshot();
|
const snapshot = record._createSnapshot();
|
||||||
const serializedRecord = serializer.serialize(snapshot);
|
const serializedRecord = serializer.serialize(snapshot);
|
||||||
@@ -63,9 +62,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
|||||||
http_method: 'GET',
|
http_method: 'GET',
|
||||||
version: 1,
|
version: 1,
|
||||||
credential_type: 'ssh_private_key',
|
credential_type: 'ssh_private_key',
|
||||||
credential_mapping_overrides: [
|
credential_mapping_overrides: { private_key_attribute: 'test' },
|
||||||
{ key: 'private_key_attribute', value: 'test' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -109,6 +106,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
|||||||
let serializedVaultGenericRecord = vaultGenericRecord.serialize();
|
let serializedVaultGenericRecord = vaultGenericRecord.serialize();
|
||||||
let serializedVaultSSHCertificateRecord =
|
let serializedVaultSSHCertificateRecord =
|
||||||
vaultSSHCertificateRecord.serialize();
|
vaultSSHCertificateRecord.serialize();
|
||||||
|
|
||||||
assert.deepEqual(serializedVaultGenericRecord, {
|
assert.deepEqual(serializedVaultGenericRecord, {
|
||||||
attributes: {
|
attributes: {
|
||||||
path: null,
|
path: null,
|
||||||
@@ -279,4 +277,61 @@ module('Unit | Serializer | credential library', function (hooks) {
|
|||||||
version: 1,
|
version: 1,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it serializes vault-ldap correctly on create', function (assert) {
|
||||||
|
const store = this.owner.lookup('service:store');
|
||||||
|
const serializer = store.serializerFor('credential-library');
|
||||||
|
const record = store.createRecord('credential-library', {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
name: 'Name',
|
||||||
|
description: 'Description',
|
||||||
|
path: '/vault/path',
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const snapshot = record._createSnapshot();
|
||||||
|
const serializedRecord = serializer.serialize(snapshot);
|
||||||
|
|
||||||
|
assert.deepEqual(serializedRecord, {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
credential_store_id: null,
|
||||||
|
name: 'Name',
|
||||||
|
description: 'Description',
|
||||||
|
version: 1,
|
||||||
|
attributes: {
|
||||||
|
path: '/vault/path',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it serializes vault-ldap correctly on update', function (assert) {
|
||||||
|
const store = this.owner.lookup('service:store');
|
||||||
|
const serializer = store.serializerFor('credential-library');
|
||||||
|
store.push({
|
||||||
|
data: {
|
||||||
|
id: '1',
|
||||||
|
type: 'credential-library',
|
||||||
|
attributes: {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
name: 'Test',
|
||||||
|
description: 'Description',
|
||||||
|
path: '/vault/path',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const record = store.peekRecord('credential-library', '1');
|
||||||
|
const snapshot = record._createSnapshot();
|
||||||
|
const serializedRecord = serializer.serialize(snapshot);
|
||||||
|
|
||||||
|
assert.deepEqual(serializedRecord, {
|
||||||
|
type: TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||||
|
credential_store_id: null,
|
||||||
|
name: 'Test',
|
||||||
|
description: 'Description',
|
||||||
|
version: 1,
|
||||||
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { module, test } from 'qunit';
|
|
||||||
import { setupTest } from 'dummy/tests/helpers';
|
|
||||||
import Service from '@ember/service';
|
|
||||||
|
|
||||||
class SessionService extends Service {}
|
|
||||||
|
|
||||||
module('Unit | Service | indexed-db', function (hooks) {
|
|
||||||
setupTest(hooks);
|
|
||||||
|
|
||||||
test('it exists', function (assert) {
|
|
||||||
this.owner.register('service:session', SessionService);
|
|
||||||
|
|
||||||
let service = this.owner.lookup('service:indexed-db');
|
|
||||||
assert.ok(service);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'dummy/tests/helpers';
|
||||||
|
import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||||
|
import { modelMapping } from 'api/services/sqlite';
|
||||||
|
import { underscore } from '@ember/string';
|
||||||
|
|
||||||
|
const supportedModels = Object.keys(modelMapping);
|
||||||
|
|
||||||
|
module('Unit | Service | sqlite', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
setupSqlite(hooks);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'Mapping matches table columns',
|
||||||
|
supportedModels,
|
||||||
|
async function (assert, resource) {
|
||||||
|
const service = this.owner.lookup('service:sqlite');
|
||||||
|
|
||||||
|
const tableColumns = await service.fetchResource({
|
||||||
|
sql: `SELECT * FROM pragma_table_info('${underscore(resource)}')`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnNames = tableColumns.map((column) => column.name);
|
||||||
|
|
||||||
|
const modelKeys = Object.keys(modelMapping[resource]);
|
||||||
|
|
||||||
|
// Last column should always be "data" which we don't map
|
||||||
|
assert.strictEqual(columnNames[columnNames.length - 1], 'data');
|
||||||
|
assert.deepEqual(columnNames.slice(0, -1), modelKeys);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'Mapping matches fts table columns',
|
||||||
|
supportedModels,
|
||||||
|
async function (assert, resource) {
|
||||||
|
const service = this.owner.lookup('service:sqlite');
|
||||||
|
|
||||||
|
const tableColumns = await service.fetchResource({
|
||||||
|
sql: `SELECT * FROM pragma_table_info('${underscore(resource)}_fts')`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnNames = tableColumns.map((column) => column.name);
|
||||||
|
const modelKeys = Object.keys(modelMapping[resource]);
|
||||||
|
|
||||||
|
assert.deepEqual(columnNames, modelKeys);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('it inserts and deletes large amounts of rows', async function (assert) {
|
||||||
|
const service = this.owner.lookup('service:sqlite');
|
||||||
|
|
||||||
|
const targets = [];
|
||||||
|
const numberOfTargets = 35_000;
|
||||||
|
|
||||||
|
for (let i = 0; i < numberOfTargets; i++) {
|
||||||
|
targets.push([
|
||||||
|
`ttcp_${i}`,
|
||||||
|
'tcp',
|
||||||
|
`name${i}`,
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'p_SWAXCPqUPr',
|
||||||
|
'2025-07-17T21:39:11.171Z',
|
||||||
|
'test data',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await service.insertResource('target', targets);
|
||||||
|
|
||||||
|
let count = await service.fetchResource({
|
||||||
|
sql: `SELECT count(*) as count FROM target`,
|
||||||
|
});
|
||||||
|
let total = count[0];
|
||||||
|
|
||||||
|
assert.strictEqual(total.count, numberOfTargets);
|
||||||
|
|
||||||
|
const ids = targets.map((target) => target[0]);
|
||||||
|
await service.deleteResource('target', ids);
|
||||||
|
|
||||||
|
count = await service.fetchResource({
|
||||||
|
sql: `SELECT count(*) as count FROM target`,
|
||||||
|
});
|
||||||
|
total = count[0];
|
||||||
|
|
||||||
|
assert.strictEqual(total.count, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,609 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { queryIndexedDb } from 'api/utils/indexed-db-query';
|
|
||||||
import { module, test } from 'qunit';
|
|
||||||
import { setupTest } from 'ember-qunit';
|
|
||||||
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
|
|
||||||
import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
|
||||||
import { pluralize } from 'ember-inflector';
|
|
||||||
import { camelize } from '@ember/string';
|
|
||||||
import { TYPE_TARGET_TCP, TYPE_TARGET_SSH } from 'api/models/target';
|
|
||||||
import { faker } from '@faker-js/faker';
|
|
||||||
|
|
||||||
const seedIndexDb = async (resource, store, indexedDb, server) => {
|
|
||||||
const resourceData =
|
|
||||||
server.schema[pluralize(camelize(resource))].all().models;
|
|
||||||
const serializedData = resourceData.map((resource) =>
|
|
||||||
JSON.parse(JSON.stringify(server.serializerOrRegistry.serialize(resource))),
|
|
||||||
);
|
|
||||||
const serializer = store.serializerFor(resource);
|
|
||||||
const schema = store.modelFor(resource);
|
|
||||||
const normalizedPayload = serializer.normalizeResponse(
|
|
||||||
store,
|
|
||||||
schema,
|
|
||||||
{ items: serializedData },
|
|
||||||
null,
|
|
||||||
'query',
|
|
||||||
);
|
|
||||||
|
|
||||||
await indexedDb.db[resource].bulkPut(
|
|
||||||
normalizedPayload.data.map((datum) =>
|
|
||||||
indexedDb.normalizeData({
|
|
||||||
data: datum,
|
|
||||||
cleanData: true,
|
|
||||||
schema,
|
|
||||||
serializer,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
module('Unit | Utility | indexed-db-query', function (hooks) {
|
|
||||||
setupTest(hooks);
|
|
||||||
setupMirage(hooks);
|
|
||||||
setupIndexedDb(hooks);
|
|
||||||
|
|
||||||
let indexedDb;
|
|
||||||
let store;
|
|
||||||
|
|
||||||
let scope1;
|
|
||||||
let scope2;
|
|
||||||
const resource = 'target';
|
|
||||||
|
|
||||||
hooks.beforeEach(function () {
|
|
||||||
indexedDb = this.owner.lookup('service:indexed-db');
|
|
||||||
store = this.owner.lookup('service:store');
|
|
||||||
scope1 = this.server.create('scope', {
|
|
||||||
id: 'p_123',
|
|
||||||
type: 'project',
|
|
||||||
});
|
|
||||||
scope2 = this.server.create('scope', {
|
|
||||||
id: 'p_456',
|
|
||||||
type: 'project',
|
|
||||||
});
|
|
||||||
this.server.createList(resource, 5, {
|
|
||||||
type: TYPE_TARGET_SSH,
|
|
||||||
scopeId: scope1.id,
|
|
||||||
});
|
|
||||||
this.server.createList(resource, 5, {
|
|
||||||
type: TYPE_TARGET_TCP,
|
|
||||||
scopeId: scope2.id,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: [{ equals: TYPE_TARGET_SSH }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "and" logicalOperator', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: TYPE_TARGET_SSH }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "or" logicalOperator', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'or',
|
|
||||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: [{ equals: TYPE_TARGET_SSH }],
|
|
||||||
scope_id: [{ equals: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields with "or" logicalOperator', async function (assert) {
|
|
||||||
this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id });
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'or',
|
|
||||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
|
||||||
},
|
|
||||||
scope_id: [{ equals: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
assert.strictEqual(result.length, 6);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields with "and" logicalOperator', async function (assert) {
|
|
||||||
this.server.create(resource, { type: TYPE_TARGET_TCP, scopeId: scope1.id });
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: TYPE_TARGET_SSH }, { notEquals: TYPE_TARGET_TCP }],
|
|
||||||
},
|
|
||||||
scope_id: [{ equals: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields and uses two "or" logicalOperators', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'or',
|
|
||||||
values: [{ equals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
|
||||||
},
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'or',
|
|
||||||
values: [{ equals: scope1.id }, { equals: scope2.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields and uses two "and" logicalOperators', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ notEquals: TYPE_TARGET_SSH }, { equals: TYPE_TARGET_TCP }],
|
|
||||||
},
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope2.id }, { notEquals: scope1.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it cannot filter with "contains" as initial operation', async function (assert) {
|
|
||||||
assert.expect(1);
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ contains: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
} catch (e) {
|
|
||||||
assert.strictEqual(
|
|
||||||
e.message,
|
|
||||||
'Contains is not supported as the first filter.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it cannot filter with "contains" as subsequent operation', async function (assert) {
|
|
||||||
assert.expect(2);
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope1.id }, { contains: scope2.id.slice(0, 2) }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
} catch (e) {
|
|
||||||
assert.strictEqual(
|
|
||||||
e.message,
|
|
||||||
'Contains is not supported as a filter option with "or" operator.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Modify test cases that use comparison opertators to compare fields that
|
|
||||||
// are of type number. Additionally, add test case for searching on boolean fields.
|
|
||||||
test('it filters by single field with "gt" as initial operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ gt: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "gt" as subsequent operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope2.id }, { gt: scope1.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "gte" as initial operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ gte: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "gte" as subsequent operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope2.id }, { gte: scope1.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "lt" as initial operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ lt: scope2.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "lt" as subsequent operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope1.id }, { lt: scope2.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "lte" as initial operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ lte: scope2.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "lte" as subsequent operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope1.id }, { lte: scope2.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "notEquals" as initial operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: [{ notEquals: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by single field with "notEquals" as subsequent operation', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
scope_id: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ equals: scope2.id }, { notEquals: scope1.id }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
|
|
||||||
query.filters.scope_id.logicalOperator = 'or';
|
|
||||||
|
|
||||||
result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters and returns zero results', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
type: [{ equals: 'unknown' }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it searches with a query that matches a field exactly', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = { search: scope1.id };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it searches with a query that matches a field partially', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = { search: scope2.id.slice(0, 2) };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it searches and returns no results with a query that matches no fields', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = { search: `${scope1.id}456789` };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by a single field and searches', async function (assert) {
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
search: scope1.id,
|
|
||||||
filters: {
|
|
||||||
type: [{ equals: TYPE_TARGET_SSH }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters by multiple fields and searches', async function (assert) {
|
|
||||||
const target = this.server.create(resource, {
|
|
||||||
name: 'Generated_ssh_target',
|
|
||||||
type: TYPE_TARGET_SSH,
|
|
||||||
scopeId: scope1.id,
|
|
||||||
});
|
|
||||||
await seedIndexDb(resource, store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
search: target.name,
|
|
||||||
filters: {
|
|
||||||
type: [{ equals: TYPE_TARGET_SSH }],
|
|
||||||
scope_id: [{ equals: scope1.id }],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, resource, query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters on boolean type fields with truthy value', async function (assert) {
|
|
||||||
const authMethod = this.server.create('auth-method', {
|
|
||||||
name: 'Generated_auth_method',
|
|
||||||
scopeId: scope1.id,
|
|
||||||
});
|
|
||||||
scope1.update({ primaryAuthMethodId: authMethod.id });
|
|
||||||
await seedIndexDb('auth-method', store, indexedDb, this.server);
|
|
||||||
const query = { filters: { is_primary: [{ equals: 'true' }] } };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, 'auth-method', query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters on boolean type fields with falsy value', async function (assert) {
|
|
||||||
const authMethod = this.server.create('auth-method', {
|
|
||||||
name: 'Generated_auth_method',
|
|
||||||
scopeId: scope1.id,
|
|
||||||
});
|
|
||||||
scope1.update({ primaryAuthMethodId: authMethod.id });
|
|
||||||
await seedIndexDb('auth-method', store, indexedDb, this.server);
|
|
||||||
const query = { filters: { is_primary: [{ equals: 'false' }] } };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(indexedDb.db, 'auth-method', query);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters on date type fields and returns results', async function (assert) {
|
|
||||||
const pastDate = new Date('2024-09-19T10:00:00.000Z');
|
|
||||||
const createdTime = new Date('2024-09-23T10:00:00.000Z');
|
|
||||||
this.server.create('session-recording', {
|
|
||||||
created_time: createdTime,
|
|
||||||
});
|
|
||||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
|
||||||
const query = { filters: { created_time: [{ gte: pastDate }] } };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(
|
|
||||||
indexedDb.db,
|
|
||||||
'session-recording',
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters on date type fields and returns results - from and to', async function (assert) {
|
|
||||||
const from = new Date('2024-09-19T10:00:00.000Z');
|
|
||||||
const to = new Date('2024-09-25T10:00:00.000Z');
|
|
||||||
const createdTime = new Date('2024-09-23T10:00:00.000Z');
|
|
||||||
const createdTime2 = new Date('2024-09-28T10:00:00.000Z');
|
|
||||||
this.server.create('session-recording', {
|
|
||||||
created_time: createdTime,
|
|
||||||
});
|
|
||||||
this.server.create('session-recording', {
|
|
||||||
created_time: createdTime2,
|
|
||||||
});
|
|
||||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
|
||||||
const query = {
|
|
||||||
filters: {
|
|
||||||
created_time: {
|
|
||||||
logicalOperator: 'and',
|
|
||||||
values: [{ gte: from }, { lte: to }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(
|
|
||||||
indexedDb.db,
|
|
||||||
'session-recording',
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 1);
|
|
||||||
assert.strictEqual(
|
|
||||||
result[0].attributes.created_time.toISOString(),
|
|
||||||
createdTime.toISOString(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it filters on date type fields and returns no results', async function (assert) {
|
|
||||||
const createdTime = faker.date.between({
|
|
||||||
from: '2024-09-19T00:00:00.000Z',
|
|
||||||
to: '2024-09-20T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
const pastDate = faker.date.between({
|
|
||||||
from: '2024-09-23T00:00:00.000Z',
|
|
||||||
to: '2024-09-24T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
this.server.create('session-recording', {
|
|
||||||
created_time: createdTime,
|
|
||||||
});
|
|
||||||
await seedIndexDb('session-recording', store, indexedDb, this.server);
|
|
||||||
const query = { filters: { created_time: [{ gte: pastDate }] } };
|
|
||||||
|
|
||||||
const result = await queryIndexedDb(
|
|
||||||
indexedDb.db,
|
|
||||||
'session-recording',
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(result.length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { assert, module, test } from 'qunit';
|
|
||||||
import { sortResults } from 'api/utils/sort-results';
|
|
||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import { setupTest } from 'ember-qunit';
|
|
||||||
|
|
||||||
function createJsonApiMockRecord(id, type, attributes) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
attributes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module('Unit | Utility | sortResults', function (hooks) {
|
|
||||||
setupTest(hooks);
|
|
||||||
|
|
||||||
module('Defaults', function () {
|
|
||||||
test("throws an error when an attribute is not id, created_time or defined as a schema attribute, even when it exists on the record's attribute object", function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'Result 01',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
name: 'Result 02',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
name: 'Result 03',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const querySort = { attribute: 'name' };
|
|
||||||
|
|
||||||
// Note: `name` is not defined as a schema attribute but exists on the record
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('date', { type: 'date' });
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => sortResults(shuffledResults, { querySort, schema }),
|
|
||||||
/Error: The attribute "name" does not map to the model definition of type "target". Supported sortable attributes are 'id', 'created_time', or 'date'/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws an error when results do not contain a valid sort direction', function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'Result 01',
|
|
||||||
});
|
|
||||||
|
|
||||||
const querySort = {
|
|
||||||
attribute: 'name',
|
|
||||||
direction: 'invalid sort direction',
|
|
||||||
};
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('name', { type: 'string' });
|
|
||||||
assert.throws(
|
|
||||||
() => sortResults([result01], { querySort, schema }),
|
|
||||||
/Error: Invalid sort direction/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when passing no querySort, sorts using default created_time, descending order and string sorting', async function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
created_time: '2021',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
created_time: '2022',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
created_time: '2023',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
created_time: '2024',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = {};
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('created_time', { type: 'string' });
|
|
||||||
const sortedResults = sortResults(shuffledResults, {
|
|
||||||
querySort,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { created_time } }) => created_time),
|
|
||||||
['2024', '2023', '2022', '2021'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('when sorting by id, without being on the results attributes it sorts as expected ', function () {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'Result 01',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
name: 'Result 02',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
name: 'Result 03',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
name: 'Result 04',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = { attribute: 'id' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('id', { type: 'string' });
|
|
||||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ id }) => id),
|
|
||||||
['id_01', 'id_02', 'id_03', 'id_04'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('string sorting', function () {
|
|
||||||
test('it sorts by ascending (default) `name` using string sorting', async function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'Result 01',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
name: 'Result 02',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
name: 'Result 03',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
name: 'Result 04',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = { attribute: 'name' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('name', { type: 'string' });
|
|
||||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { name } }) => name),
|
|
||||||
['Result 01', 'Result 02', 'Result 03', 'Result 04'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by ascending then descending `name` using string sorting', async function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'Result 01',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
name: 'Result 02',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
name: 'Result 03',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
name: 'Result 04',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySortAsc = { attribute: 'name', direction: 'asc' };
|
|
||||||
const querySortDesc = { attribute: 'name', direction: 'desc' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('name', { type: 'string' });
|
|
||||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortAsc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortDesc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsAsc.map(({ attributes: { name } }) => name),
|
|
||||||
['Result 01', 'Result 02', 'Result 03', 'Result 04'],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsDesc.map(({ attributes: { name } }) => name),
|
|
||||||
['Result 04', 'Result 03', 'Result 02', 'Result 01'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('date sorting', function () {
|
|
||||||
test('it sorts by ascending (default) `date` using date sorting', function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
date: '2021',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
date: '2022',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
date: '2023',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
date: '2024',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = { attribute: 'date' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('date', { type: 'date' });
|
|
||||||
|
|
||||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { date } }) => date),
|
|
||||||
['2021', '2022', '2023', '2024'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by ascending then descending `date` using date sorting', function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
date: '2021',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
date: '2022',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
date: '2023',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
date: '2024',
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySortAsc = { attribute: 'date', direction: 'asc' };
|
|
||||||
const querySortDesc = { attribute: 'date', direction: 'desc' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('date', { type: 'date' });
|
|
||||||
|
|
||||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortAsc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortDesc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsAsc.map(({ attributes: { date } }) => date),
|
|
||||||
['2021', '2022', '2023', '2024'],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsDesc.map(({ attributes: { date } }) => date),
|
|
||||||
['2024', '2023', '2022', '2021'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('number sorting', function () {
|
|
||||||
test('it sorts by ascending (default) `sessions`', function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 10,
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 200,
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 3333,
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 40444,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = { attribute: 'sessions' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('sessions', { type: 'number' });
|
|
||||||
|
|
||||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { sessions } }) => sessions),
|
|
||||||
[10, 200, 3333, 40444],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by ascending then descending `sessions`', function (assert) {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 10,
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 200,
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 3333,
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
sessions: 40444,
|
|
||||||
});
|
|
||||||
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySortAsc = { attribute: 'sessions', direction: 'asc' };
|
|
||||||
const querySortDesc = { attribute: 'sessions', direction: 'desc' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('sessions', { type: 'number' });
|
|
||||||
|
|
||||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortAsc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortDesc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsAsc.map(({ attributes: { sessions } }) => sessions),
|
|
||||||
[10, 200, 3333, 40444],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsDesc.map(({ attributes: { sessions } }) => sessions),
|
|
||||||
[40444, 3333, 200, 10],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('boolean sorting', function () {
|
|
||||||
test('it sorts by ascending (default) `active`', function (assert) {
|
|
||||||
const result01 = { attributes: { active: true } };
|
|
||||||
const result02 = { attributes: { active: false } };
|
|
||||||
const result03 = { attributes: { active: false } };
|
|
||||||
const result04 = { attributes: { active: true } };
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySort = { attribute: 'active' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('active', { type: 'boolean' });
|
|
||||||
|
|
||||||
const sortedResults = sortResults(shuffledResults, { querySort, schema });
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { active } }) => active),
|
|
||||||
[false, false, true, true],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('it sorts by ascending then descending `active`', function (assert) {
|
|
||||||
const result01 = { attributes: { active: true } };
|
|
||||||
const result02 = { attributes: { active: false } };
|
|
||||||
const result03 = { attributes: { active: false } };
|
|
||||||
const result04 = { attributes: { active: true } };
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
const querySortAsc = { attribute: 'active', direction: 'asc' };
|
|
||||||
const querySortDesc = { attribute: 'active', direction: 'desc' };
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
schema.attributes.set('active', { type: 'boolean' });
|
|
||||||
|
|
||||||
const sortedResultsAsc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortAsc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
const sortedResultsDesc = sortResults(shuffledResults, {
|
|
||||||
querySort: querySortDesc,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsAsc.map(({ attributes: { active } }) => active),
|
|
||||||
[false, false, true, true],
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResultsDesc.map(({ attributes: { active } }) => active),
|
|
||||||
[true, true, false, false],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
module('custom function sorting', function () {
|
|
||||||
const result01 = createJsonApiMockRecord('id_01', 'target', {
|
|
||||||
name: 'target1',
|
|
||||||
});
|
|
||||||
const result02 = createJsonApiMockRecord('id_02', 'target', {
|
|
||||||
name: 'target2',
|
|
||||||
});
|
|
||||||
const result03 = createJsonApiMockRecord('id_03', 'target', {
|
|
||||||
name: 'target3',
|
|
||||||
});
|
|
||||||
const result04 = createJsonApiMockRecord('id_04', 'target', {
|
|
||||||
name: 'target4',
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortFunction = (recordA, recordB) => {
|
|
||||||
const nameMap = {
|
|
||||||
target3: 'Alpha',
|
|
||||||
target4: 'Beta',
|
|
||||||
target1: 'Delta',
|
|
||||||
target2: 'Epsilon',
|
|
||||||
};
|
|
||||||
return String(nameMap[recordA.attributes.name]).localeCompare(
|
|
||||||
String(nameMap[recordB.attributes.name]),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const schema = { attributes: new Map() };
|
|
||||||
|
|
||||||
test.each(
|
|
||||||
'it sorts by `name` using custom sorting function',
|
|
||||||
{
|
|
||||||
'ascending (default)': {
|
|
||||||
querySort: { sortFunction },
|
|
||||||
expectedResults: ['target3', 'target4', 'target1', 'target2'],
|
|
||||||
},
|
|
||||||
descending: {
|
|
||||||
querySort: { sortFunction, direction: 'desc' },
|
|
||||||
expectedResults: ['target2', 'target1', 'target4', 'target3'],
|
|
||||||
},
|
|
||||||
ascending: {
|
|
||||||
querySort: { sortFunction, direction: 'asc' },
|
|
||||||
expectedResults: ['target3', 'target4', 'target1', 'target2'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
function (assert, input) {
|
|
||||||
const shuffledResults = faker.helpers.shuffle([
|
|
||||||
result01,
|
|
||||||
result02,
|
|
||||||
result03,
|
|
||||||
result04,
|
|
||||||
]);
|
|
||||||
schema.attributes.set('name', { type: 'string' });
|
|
||||||
const sortedResults = sortResults(shuffledResults, {
|
|
||||||
querySort: input.querySort,
|
|
||||||
schema,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
sortedResults.map(({ attributes: { name } }) => name),
|
|
||||||
input.expectedResults,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) HashiCorp, Inc.
|
||||||
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateSQLExpressions } from 'api/utils/sqlite-query';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | sqlite-query', function (hooks) {
|
||||||
|
const date = new Date(2025, 1, 1);
|
||||||
|
const isoDateString = date.toISOString();
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
String.prototype.removeExtraWhiteSpace = function () {
|
||||||
|
return this.replace(/^\s+/gm, '').trim();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
delete String.prototype.removeExtraWhiteSpace;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it grabs resources not in the model mapping', function (assert) {
|
||||||
|
const query = {
|
||||||
|
filters: { id: [{ equals: 'tokenKey' }] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions('token', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "token"
|
||||||
|
WHERE ("token".id = ?)`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(parameters, ['tokenKey']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it executes count queries correctly', function (assert) {
|
||||||
|
const select = {
|
||||||
|
select: [{ field: '*', isCount: true, alias: 'total' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions('target', {}, select);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT count(*) as total FROM "target"
|
||||||
|
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',
|
||||||
|
{
|
||||||
|
equals: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
type: [{ equals: 'ssh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause: 'WHERE ("target".type = ?)',
|
||||||
|
expectedParams: ['ssh'],
|
||||||
|
},
|
||||||
|
notEquals: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
type: [{ notEquals: 'ssh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause: 'WHERE ("target".type != ?)',
|
||||||
|
expectedParams: ['ssh'],
|
||||||
|
},
|
||||||
|
contains: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
type: [{ contains: 'ssh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause: 'WHERE ("target".type LIKE ?)',
|
||||||
|
expectedParams: ['%ssh%'],
|
||||||
|
},
|
||||||
|
greaterThan: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
created_time: [{ gt: date }],
|
||||||
|
numberField: [{ gt: 10 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause:
|
||||||
|
'WHERE ("target".created_time > ?) AND ("target".numberField > ?)',
|
||||||
|
expectedParams: [isoDateString, 10],
|
||||||
|
},
|
||||||
|
lessThan: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
created_time: [{ lt: date }],
|
||||||
|
numberField: [{ lt: 10 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause:
|
||||||
|
'WHERE ("target".created_time < ?) AND ("target".numberField < ?)',
|
||||||
|
expectedParams: [isoDateString, 10],
|
||||||
|
},
|
||||||
|
greaterThanOrEqual: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
created_time: [{ gte: date }],
|
||||||
|
numberField: [{ gte: 10 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause:
|
||||||
|
'WHERE ("target".created_time >= ?) AND ("target".numberField >= ?)',
|
||||||
|
expectedParams: [isoDateString, 10],
|
||||||
|
},
|
||||||
|
lessThanOrEqual: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
created_time: [{ lte: date }],
|
||||||
|
numberField: [{ lte: 10 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause:
|
||||||
|
'WHERE ("target".created_time <= ?) AND ("target".numberField <= ?)',
|
||||||
|
expectedParams: [isoDateString, 10],
|
||||||
|
},
|
||||||
|
logicalOperators: {
|
||||||
|
query: {
|
||||||
|
filters: {
|
||||||
|
id: {
|
||||||
|
logicalOperator: 'and',
|
||||||
|
values: [{ notEquals: 'id1' }, { notEquals: 'id2' }],
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
logicalOperator: 'or',
|
||||||
|
values: [{ equals: 'active' }, { equals: 'pending' }],
|
||||||
|
},
|
||||||
|
type: [{ equals: 'ssh' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedWhereClause:
|
||||||
|
'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 }) {
|
||||||
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "target"
|
||||||
|
${expectedWhereClause}
|
||||||
|
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(parameters, expectedParams);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'it generates sort direction correctly',
|
||||||
|
[
|
||||||
|
['asc', 'ASC'],
|
||||||
|
['desc', 'DESC'],
|
||||||
|
],
|
||||||
|
function (assert, [direction, expectedDirection]) {
|
||||||
|
const query = {
|
||||||
|
sort: { attributes: ['name'], direction },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "target"
|
||||||
|
ORDER BY "target".name COLLATE NOCASE ${expectedDirection}, "target".name ${expectedDirection}`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(parameters, []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'it generates sort order clause correctly',
|
||||||
|
{
|
||||||
|
'sort on multiple attributes and coalesced': {
|
||||||
|
sort: {
|
||||||
|
attributes: ['name', 'id'],
|
||||||
|
isCoalesced: true,
|
||||||
|
},
|
||||||
|
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 "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 "target".type WHEN 'ssh' THEN 'SSH' WHEN 'tcp' THEN 'Generic TCP' END DESC`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
function (assert, { sort, expectedOrderByClause }) {
|
||||||
|
const query = {
|
||||||
|
sort: { ...sort, direction: 'desc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions('target', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "target"
|
||||||
|
${expectedOrderByClause}`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(parameters, []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'it generates pagination clause',
|
||||||
|
[
|
||||||
|
{ page: 1, pageSize: 10, expectedParams: [10, 0] },
|
||||||
|
{ page: 3, pageSize: 10, expectedParams: [10, 20] },
|
||||||
|
{ page: 7, pageSize: 50, expectedParams: [50, 300] },
|
||||||
|
],
|
||||||
|
function (assert, { page, pageSize, expectedParams }) {
|
||||||
|
const { sql, parameters } = generateSQLExpressions(
|
||||||
|
'target',
|
||||||
|
{},
|
||||||
|
{ page, pageSize },
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "target"
|
||||||
|
ORDER BY "target".created_time DESC
|
||||||
|
LIMIT ? OFFSET ?`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(parameters, expectedParams);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each(
|
||||||
|
'it generates FTS5 search correctly',
|
||||||
|
{
|
||||||
|
'string search with filters': {
|
||||||
|
query: {
|
||||||
|
search: 'favorite',
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
search: 'favorite',
|
||||||
|
filters: {
|
||||||
|
type: [{ equals: 'ssh' }],
|
||||||
|
status: {
|
||||||
|
logicalOperator: 'or',
|
||||||
|
values: [{ equals: 'active' }, { equals: 'pending' }],
|
||||||
|
},
|
||||||
|
created_time: [{ gte: date }],
|
||||||
|
},
|
||||||
|
sort: { attributes: ['name'], direction: 'desc' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { sql, parameters } = generateSQLExpressions('target', query, {
|
||||||
|
page: 2,
|
||||||
|
pageSize: 15,
|
||||||
|
select: [{ field: 'data' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
sql,
|
||||||
|
`
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(parameters, [
|
||||||
|
'ssh',
|
||||||
|
'active',
|
||||||
|
'pending',
|
||||||
|
isoDateString,
|
||||||
|
'"favorite"*',
|
||||||
|
15,
|
||||||
|
15,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
{
|
||||||
|
emptyObject: {},
|
||||||
|
emptySearch: { search: '' },
|
||||||
|
emptyFilter: { filters: {} },
|
||||||
|
emptyFilterAttributes: { filters: { type: [] } },
|
||||||
|
invalidSortAttribute: {
|
||||||
|
sort: { attributes: ['test'], direction: 'asc' },
|
||||||
|
},
|
||||||
|
invalidCoalescedSortAttribute: {
|
||||||
|
sort: {
|
||||||
|
attributes: ['id', 'test'],
|
||||||
|
direction: 'asc',
|
||||||
|
isCoalesced: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onlyPage: { page: 10 },
|
||||||
|
onlyPageSize: { pageSize: 5 },
|
||||||
|
},
|
||||||
|
function (assert, query) {
|
||||||
|
const { sql: targetSql, parameters: targetParameters } =
|
||||||
|
generateSQLExpressions('target', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
targetSql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "target"
|
||||||
|
ORDER BY "target".created_time DESC`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(targetParameters, []);
|
||||||
|
|
||||||
|
const { sql: resourceSql, parameters: resourceParameters } =
|
||||||
|
generateSQLExpressions('resource', query);
|
||||||
|
assert.strictEqual(
|
||||||
|
resourceSql,
|
||||||
|
`
|
||||||
|
SELECT * FROM "resource"`.removeExtraWhiteSpace(),
|
||||||
|
);
|
||||||
|
assert.deepEqual(resourceParameters, []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
curly: ['error', 'multi-line', 'consistent'],
|
||||||
'ember/no-get': 'off',
|
'ember/no-get': 'off',
|
||||||
'ember/no-get-with-default': 'off',
|
'ember/no-get-with-default': 'off',
|
||||||
'ember/no-computed-properties-in-native-classes': 'off',
|
'ember/no-computed-properties-in-native-classes': 'off',
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
|||||||
* @param {string} username
|
* @param {string} username
|
||||||
* @return {object}
|
* @return {object}
|
||||||
*/
|
*/
|
||||||
normalizeData(data, username) {
|
normalizeData(data) {
|
||||||
// Pull fields up from `data.attributes` for easier access in JavaScript.
|
// Pull fields up from `data.attributes` for easier access in JavaScript.
|
||||||
// The `attributes` field exists on the Go side for its convenience but is
|
// The `attributes` field exists on the Go side for its convenience but is
|
||||||
// unnecessary here.
|
// unnecessary here.
|
||||||
@@ -109,7 +109,6 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
|||||||
// Add booleans indicated the scope type
|
// Add booleans indicated the scope type
|
||||||
data.isGlobal = data?.scope?.type === 'global';
|
data.isGlobal = data?.scope?.type === 'global';
|
||||||
data.isOrg = data?.scope?.type === 'org';
|
data.isOrg = data?.scope?.type === 'org';
|
||||||
if (username) data.username = username;
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,8 +72,6 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
|
|||||||
fetch(authEndpointURL, { method: 'post', body }),
|
fetch(authEndpointURL, { method: 'post', body }),
|
||||||
);
|
);
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
return response.status < 400
|
return response.status < 400 ? resolve(this.normalizeData(json)) : reject();
|
||||||
? resolve(this.normalizeData(json, login_name))
|
|
||||||
: reject();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"@ember/optional-features": "^2.1.0",
|
"@ember/optional-features": "^2.1.0",
|
||||||
"@ember/test-helpers": "^5.2.1",
|
"@ember/test-helpers": "^5.2.1",
|
||||||
"@embroider/test-setup": "^4.0.0",
|
"@embroider/test-setup": "^4.0.0",
|
||||||
"@glimmer/component": "^1.1.2",
|
"@glimmer/component": "^2.0.0",
|
||||||
"@glimmer/tracking": "^1.1.2",
|
"@glimmer/tracking": "^1.1.2",
|
||||||
"broccoli-asset-rev": "^3.0.0",
|
"broccoli-asset-rev": "^3.0.0",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
curly: ['error', 'multi-line', 'consistent'],
|
||||||
'ember/no-get': 'off',
|
'ember/no-get': 'off',
|
||||||
'ember/no-get-with-default': 'off',
|
'ember/no-get-with-default': 'off',
|
||||||
'ember/no-computed-properties-in-native-classes': 'off',
|
'ember/no-computed-properties-in-native-classes': 'off',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<DD.Header @hasDivider={{true}}>
|
<DD.Header @hasDivider={{true}}>
|
||||||
<Hds::Form::TextInput::Base
|
<Hds::Form::TextInput::Base
|
||||||
@type='search'
|
@type='search'
|
||||||
@value={{this.searchTerm}}
|
@value={{or this.searchTerm @searchTerm}}
|
||||||
placeholder={{t 'actions.narrow-results'}}
|
placeholder={{t 'actions.narrow-results'}}
|
||||||
aria-label={{t 'actions.narrow-results'}}
|
aria-label={{t 'actions.narrow-results'}}
|
||||||
{{on 'input' this.filterItems}}
|
{{on 'input' this.filterItems}}
|
||||||
@@ -28,11 +28,18 @@
|
|||||||
/>
|
/>
|
||||||
</DD.Header>
|
</DD.Header>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#unless this.itemOptions}}
|
|
||||||
|
{{#unless (or this.itemOptions @isLoading)}}
|
||||||
<DD.Description @text={{t 'titles.no-results'}} />
|
<DD.Description @text={{t 'titles.no-results'}} />
|
||||||
{{/unless}}
|
{{/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}}
|
{{yield DD this.selectItem this.itemOptions}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#each this.itemOptions as |itemOption|}}
|
{{#each this.itemOptions as |itemOption|}}
|
||||||
@@ -47,13 +54,15 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<DD.Footer @hasDivider={{true}}>
|
{{#unless @isLoading}}
|
||||||
<Hds::Button
|
<DD.Footer @hasDivider={{true}}>
|
||||||
@text='Apply'
|
<Hds::Button
|
||||||
@isFullWidth={{true}}
|
@text='Apply'
|
||||||
@size='small'
|
@isFullWidth={{true}}
|
||||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
@size='small'
|
||||||
data-test-dropdown-apply-button
|
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||||
/>
|
data-test-dropdown-apply-button
|
||||||
</DD.Footer>
|
/>
|
||||||
|
</DD.Footer>
|
||||||
|
{{/unless}}
|
||||||
</Hds::Dropdown>
|
</Hds::Dropdown>
|
||||||
@@ -21,7 +21,8 @@ export default class DropdownComponent extends Component {
|
|||||||
*/
|
*/
|
||||||
get itemOptions() {
|
get itemOptions() {
|
||||||
let items = this.args.itemOptions;
|
let items = this.args.itemOptions;
|
||||||
if (this.searchTerm) {
|
|
||||||
|
if (this.searchTerm && !this.args.updateSearchTerm) {
|
||||||
const searchTerm = this.searchTerm.toLowerCase();
|
const searchTerm = this.searchTerm.toLowerCase();
|
||||||
items = this.args.itemOptions.filter((item) => {
|
items = this.args.itemOptions.filter((item) => {
|
||||||
const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
|
const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
|
||||||
@@ -29,7 +30,7 @@ export default class DropdownComponent extends Component {
|
|||||||
return isNameMatch || isIdMatch;
|
return isNameMatch || isIdMatch;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return items.slice(0, 500);
|
return items.slice(0, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =methods
|
// =methods
|
||||||
@@ -39,10 +40,15 @@ export default class DropdownComponent extends Component {
|
|||||||
* @param {object} event
|
* @param {object} event
|
||||||
*/
|
*/
|
||||||
@action
|
@action
|
||||||
@debounce(150)
|
@debounce(250)
|
||||||
filterItems(event) {
|
async filterItems(event) {
|
||||||
const { value } = event.target;
|
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() {
|
get filters() {
|
||||||
const { allFilters, selectedFilters } = this.args.filters;
|
const { allFilters, selectedFilters } = this.args.filters;
|
||||||
return Object.entries(allFilters).flatMap(([key, value]) => {
|
return Object.entries(allFilters).flatMap(([key, values]) => {
|
||||||
assert(`Tags must be an array for key ${key}`, Array.isArray(value));
|
assert(`Tags must be an array for key ${key}`, Array.isArray(values));
|
||||||
const paramsSet = new Set(selectedFilters[key]);
|
const uniqueSelectedFilters = [...new Set(selectedFilters[key])];
|
||||||
const filters = value.filter((item) => paramsSet.has(item.id));
|
if (!uniqueSelectedFilters.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return filters.map((item) => ({
|
const valueMap = new Map(values.map((value) => [value.id, value.name]));
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
return uniqueSelectedFilters.filter(Boolean).map((item) => {
|
||||||
type: key,
|
const filter = {
|
||||||
}));
|
id: item,
|
||||||
|
type: key,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (valueMap.has(item)) {
|
||||||
|
filter.name = valueMap.get(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
{{#if @model.type}}
|
{{#if @model.type}}
|
||||||
{{component
|
<this.authenticateFormComponent
|
||||||
(concat 'form/authenticate/' @model.type)
|
@onSubmit={{@submit}}
|
||||||
onSubmit=@submit
|
@disabled={{@disabled}}
|
||||||
disabled=@disabled
|
/>
|
||||||
}}
|
|
||||||
{{/if}}
|
{{/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,8 @@ export function notifySuccess(notification) {
|
|||||||
const text = intlService.t(candidateKey);
|
const text = intlService.t(candidateKey);
|
||||||
const value = await method.apply(this, arguments);
|
const value = await method.apply(this, arguments);
|
||||||
notifyService.success(text, {
|
notifyService.success(text, {
|
||||||
notificationType: 'success',
|
color: 'success',
|
||||||
|
title: intlService.t('states.success'),
|
||||||
dismiss: (flash) => flash.destroyMessage(),
|
dismiss: (flash) => flash.destroyMessage(),
|
||||||
});
|
});
|
||||||
return value;
|
return value;
|
||||||
@@ -99,7 +100,8 @@ export function notifyError(
|
|||||||
const sticky = options.sticky === undefined ? true : options.sticky;
|
const sticky = options.sticky === undefined ? true : options.sticky;
|
||||||
|
|
||||||
notifyService.danger(text, {
|
notifyService.danger(text, {
|
||||||
notificationType: 'error',
|
color: 'critical',
|
||||||
|
title: intlService.t('states.error'),
|
||||||
sticky,
|
sticky,
|
||||||
dismiss: (flash) => flash.destroyMessage(),
|
dismiss: (flash) => flash.destroyMessage(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -129,8 +129,9 @@ class RouteResourceFilter extends EmberObject {
|
|||||||
const filterKey = `filter-${name}`;
|
const filterKey = `filter-${name}`;
|
||||||
|
|
||||||
// If the route has no query params specified yet, declare them.
|
// 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 = {};
|
routeClass.queryParams = {};
|
||||||
|
}
|
||||||
|
|
||||||
// If the route has no resource filters list specified yet, create one.
|
// If the route has no resource filters list specified yet, create one.
|
||||||
if (!routeClass.resourceFilterParams) routeClass.resourceFilterParams = [];
|
if (!routeClass.resourceFilterParams) routeClass.resourceFilterParams = [];
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@use 'rose/app/styles/rose/variables/sizing' as sizing;
|
||||||
|
@use 'sass:meta';
|
||||||
|
|
||||||
.filters-applied {
|
.filters-applied {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -72,3 +75,34 @@
|
|||||||
margin-bottom: 0.5rem;
|
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)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -3,4 +3,4 @@
|
|||||||
* SPDX-License-Identifier: BUSL-1.1
|
* SPDX-License-Identifier: BUSL-1.1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { default } from 'rose/modifiers/on-click-outside';
|
export { default } from 'core/components/form/authenticate/index';
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"ember-cli-htmlbars": "^6.3.0",
|
"ember-cli-htmlbars": "^6.3.0",
|
||||||
"ember-cli-sass": "^11.0.1",
|
"ember-cli-sass": "^11.0.1",
|
||||||
"ember-concurrency": "^4.0.3",
|
"ember-concurrency": "^4.0.3",
|
||||||
"ember-feature-flags": "^6.0.0",
|
"ember-feature-flags": "^7.0.0",
|
||||||
"ember-intl": "^7.1.8",
|
"ember-intl": "^7.1.8",
|
||||||
"ember-loading": "^2.0.0",
|
"ember-loading": "^2.0.0",
|
||||||
"ember-truth-helpers": "^4.0.3",
|
"ember-truth-helpers": "^4.0.3",
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
"@ember/test-helpers": "^5.2.1",
|
"@ember/test-helpers": "^5.2.1",
|
||||||
"@embroider/test-setup": "^4.0.0",
|
"@embroider/test-setup": "^4.0.0",
|
||||||
"@faker-js/faker": "^8.0.2",
|
"@faker-js/faker": "^8.0.2",
|
||||||
"@glimmer/component": "^1.1.2",
|
"@glimmer/component": "^2.0.0",
|
||||||
"@glimmer/tracking": "^1.1.2",
|
"@glimmer/tracking": "^1.1.2",
|
||||||
"broccoli-asset-rev": "^3.0.0",
|
"broccoli-asset-rev": "^3.0.0",
|
||||||
"concurrently": "^9.1.0",
|
"concurrently": "^9.1.0",
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
|||||||
assert.true(result);
|
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('checkedItems', []);
|
||||||
this.set(
|
this.set(
|
||||||
'itemOptions',
|
'itemOptions',
|
||||||
@@ -145,7 +145,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
|||||||
|
|
||||||
await click(TOGGLE_DROPDOWN_SELECTOR);
|
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
|
// Random number between 500 and 599 to search
|
||||||
await fillIn(SEARCH_INPUT_SELECTOR, '591');
|
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');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -55,6 +55,9 @@ do-this-later: Do this later
|
|||||||
toggle-color-theme: Toggle Color Theme
|
toggle-color-theme: Toggle Color Theme
|
||||||
toggle-feature-edition: Toggle Feature Edition
|
toggle-feature-edition: Toggle Feature Edition
|
||||||
toggle-licensed-features: Toggle Licensed Features
|
toggle-licensed-features: Toggle Licensed Features
|
||||||
|
manage-db: Manage SQLite
|
||||||
|
download-db: Download SQLite DB
|
||||||
|
clear-db: Clear SQLite DB
|
||||||
more: ', +{remainingItems} more'
|
more: ', +{remainingItems} more'
|
||||||
search: Search
|
search: Search
|
||||||
yes: Yes
|
yes: Yes
|
||||||
@@ -65,3 +68,4 @@ show-errors: Show Errors
|
|||||||
hide-errors: Hide Errors
|
hide-errors: Hide Errors
|
||||||
edit-worker-filter: Edit Worker Filter
|
edit-worker-filter: Edit Worker Filter
|
||||||
add-worker-filter: Add Worker Filter
|
add-worker-filter: Add Worker Filter
|
||||||
|
open: Open
|
||||||
@@ -49,6 +49,7 @@ titles:
|
|||||||
project-actions: Project Actions
|
project-actions: Project Actions
|
||||||
user-menu: User Menu
|
user-menu: User Menu
|
||||||
back-link: 'Back to {scope}'
|
back-link: 'Back to {scope}'
|
||||||
|
authenticated: Signed in as
|
||||||
descriptions:
|
descriptions:
|
||||||
empty-set: There are no items to display yet. You may be able to add items or try back later.
|
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
|
cluster-url-initialization: To get started, please enter your cluster URL
|
||||||
@@ -141,6 +142,21 @@ settings:
|
|||||||
alerts:
|
alerts:
|
||||||
cache-daemon: There may be a problem with the cache daemon
|
cache-daemon: There may be a problem with the cache daemon
|
||||||
client-agent: There may be a problem with the client agent
|
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:
|
worker-filter-generator:
|
||||||
title: Filter generator
|
title: Filter generator
|
||||||
description: Choose what you want to format into a filter.
|
description: Choose what you want to format into a filter.
|
||||||
|
|||||||
@@ -27,3 +27,7 @@ onboarding-failed:
|
|||||||
required-field: This is a required field.
|
required-field: This is a required field.
|
||||||
client-agent-failed:
|
client-agent-failed:
|
||||||
sessions: Unable to retrieve sessions from the client agent.
|
sessions: Unable to retrieve sessions from the client agent.
|
||||||
|
rdp-warning:
|
||||||
|
title: Continue in your RDP client
|
||||||
|
description: Your RDP client may show a pop-up to accept a certificate to connect. This is normal for RDP clients.
|
||||||
|
do-not-show-again: Do not show again
|
||||||
@@ -50,6 +50,7 @@ session_max_seconds:
|
|||||||
session_connection_limit:
|
session_connection_limit:
|
||||||
label: Maximum Connections
|
label: Maximum Connections
|
||||||
help: The maximum number of connections allowed per session. For unlimited, specify "-1".
|
help: The maximum number of connections allowed per session. For unlimited, specify "-1".
|
||||||
|
rdp-windows-notice: 'Note: The Windows Remote Desktop Connection client requires a connection limit of 2 or higher'
|
||||||
session_id:
|
session_id:
|
||||||
label: Session ID
|
label: Session ID
|
||||||
worker_release_version:
|
worker_release_version:
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ session:
|
|||||||
connected:
|
connected:
|
||||||
title: Connected
|
title: Connected
|
||||||
description: You can now access {targetDisplayName}
|
description: You can now access {targetDisplayName}
|
||||||
|
stop-sessions-and-signout:
|
||||||
|
title: Sign out of Boundary?
|
||||||
|
description: Signing out will stop all active and pending target sessions. Are you sure you want to sign out?
|
||||||
|
stop-sessions-before-quit:
|
||||||
|
title: Close sessions before quitting?
|
||||||
|
description: Quitting the app will stop all active and pending sessions. Are you sure you want to quit?
|
||||||
actions:
|
actions:
|
||||||
connect: Connect
|
connect: Connect
|
||||||
end: End session
|
end: End session
|
||||||
@@ -363,6 +369,7 @@ session:
|
|||||||
proxy-url:
|
proxy-url:
|
||||||
address-port: Address & port
|
address-port: Address & port
|
||||||
ssh: SSH
|
ssh: SSH
|
||||||
|
rdp: RDP
|
||||||
shell: Shell
|
shell: Shell
|
||||||
global:
|
global:
|
||||||
title: Global
|
title: Global
|
||||||
@@ -722,9 +729,11 @@ target:
|
|||||||
types:
|
types:
|
||||||
tcp: Generic TCP
|
tcp: Generic TCP
|
||||||
ssh: SSH
|
ssh: SSH
|
||||||
|
rdp: RDP
|
||||||
help:
|
help:
|
||||||
tcp: Generic TCP supports a broad range of connection types.
|
tcp: Generic TCP supports a broad range of connection types.
|
||||||
ssh: Protocol-aware SSH with support for credential injection.
|
ssh: Protocol-aware SSH with support for credential injection.
|
||||||
|
rdp: Remote Desktop Protocol for Windows servers.
|
||||||
filter:
|
filter:
|
||||||
active-sessions:
|
active-sessions:
|
||||||
yes: Has active sessions
|
yes: Has active sessions
|
||||||
@@ -742,6 +751,7 @@ target:
|
|||||||
default_client_port:
|
default_client_port:
|
||||||
label: Default Client Port
|
label: Default Client Port
|
||||||
help: The local proxy port on which to listen by default when a session is started on a client.
|
help: The local proxy port on which to listen by default when a session is started on a client.
|
||||||
|
rdp-windows-notice: 'Note: Windows OS prevents port 3389 from being used.'
|
||||||
sidebar:
|
sidebar:
|
||||||
label: Session Recording
|
label: Session Recording
|
||||||
help: Record SSH sessions on this target and save them to a storage bucket.
|
help: Record SSH sessions on this target and save them to a storage bucket.
|
||||||
@@ -804,6 +814,10 @@ target:
|
|||||||
add:
|
add:
|
||||||
title: Add Injected Application Credential Sources
|
title: Add Injected Application Credential Sources
|
||||||
description: Select injected Application credential sources to assign to this target.
|
description: Select injected Application credential sources to assign to this target.
|
||||||
|
alert:
|
||||||
|
title: Cannot connect to this target without injected application credentials
|
||||||
|
description: Injected application credentials are used by Boundary to sign in to remote resources without showing them to the end-user.
|
||||||
|
action: Add injected application credentials
|
||||||
workers:
|
workers:
|
||||||
description: You can customize how your workers route traffic to this target by setting up a filter that matches specific worker tags. If your target is in a private network, we recommend setting up an egress filter to specify a worker inside the network so that Boundary can access the host. Ingress and egress filters are allowed to select the same worker.
|
description: You can customize how your workers route traffic to this target by setting up a filter that matches specific worker tags. If your target is in a private network, we recommend setting up an egress filter to specify a worker inside the network so that Boundary can access the host. Ingress and egress filters are allowed to select the same worker.
|
||||||
messages:
|
messages:
|
||||||
@@ -898,10 +912,13 @@ credential-library:
|
|||||||
new: New Credential Library
|
new: New Credential Library
|
||||||
ssh_private_key: SSH Private Key
|
ssh_private_key: SSH Private Key
|
||||||
username_password: Username & Password
|
username_password: Username & Password
|
||||||
username_attribute: Username
|
username_password_domain: Username, Password & Domain
|
||||||
password_attribute: Password
|
password: Password
|
||||||
private_key_passphrase_attribute: Private Key Passphrase
|
username_attribute: username_attribute
|
||||||
private_key_attribute: Private Key
|
password_attribute: password_attribute
|
||||||
|
private_key_passphrase_attribute: private_key_passphrase_attribute
|
||||||
|
private_key_attribute: private_key_attribute
|
||||||
|
domain_attribute: domain_attribute
|
||||||
actions:
|
actions:
|
||||||
create: New Credential Library
|
create: New Credential Library
|
||||||
delete: Delete Credential Library
|
delete: Delete Credential Library
|
||||||
@@ -916,6 +933,7 @@ credential-library:
|
|||||||
credential_type:
|
credential_type:
|
||||||
label: Credential Type
|
label: Credential Type
|
||||||
help: Specify the type of credential this library will issue. This must be set to use credential injection. After this data is saved, it cannot be changed later.
|
help: Specify the type of credential this library will issue. This must be set to use credential injection. After this data is saved, it cannot be changed later.
|
||||||
|
description: The type of credential the library uses for credential injection.
|
||||||
credential_mapping_overrides:
|
credential_mapping_overrides:
|
||||||
label: Credential mapping overrides
|
label: Credential mapping overrides
|
||||||
help: Specify the credential fields to use instead of the defaults
|
help: Specify the credential fields to use instead of the defaults
|
||||||
@@ -949,9 +967,11 @@ credential-library:
|
|||||||
types:
|
types:
|
||||||
vault-generic: Generic Secrets
|
vault-generic: Generic Secrets
|
||||||
vault-ssh-certificate: SSH Certificates
|
vault-ssh-certificate: SSH Certificates
|
||||||
|
vault-ldap: LDAP
|
||||||
help:
|
help:
|
||||||
vault-generic: Authorize sessions using credentials brokered by Vault
|
vault-generic: Authorize sessions using credentials brokered by Vault
|
||||||
vault-ssh-certificate: Authorize sessions using the SSH Secrets Engine in Vault
|
vault-ssh-certificate: Authorize sessions using the SSH Secrets Engine in Vault
|
||||||
|
vault-ldap: Authorize sessions using the LDAP Secrets Engine in Vault
|
||||||
credential:
|
credential:
|
||||||
title: Credential
|
title: Credential
|
||||||
title_plural: Credentials
|
title_plural: Credentials
|
||||||
@@ -968,12 +988,16 @@ credential:
|
|||||||
types:
|
types:
|
||||||
username_password: Username & Password
|
username_password: Username & Password
|
||||||
ssh_private_key: Username & Key Pair
|
ssh_private_key: Username & Key Pair
|
||||||
|
username_password_domain: Username, Password & Domain
|
||||||
json: JSON
|
json: JSON
|
||||||
|
password: Password
|
||||||
unknown: Unknown
|
unknown: Unknown
|
||||||
help:
|
help:
|
||||||
username_password: Connect using the provided username and password.
|
username_password: Connect using the provided username and password.
|
||||||
ssh_private_key: Connect using a username, public key, and private key.
|
ssh_private_key: Connect using a username, public key, and private key.
|
||||||
json: Connect using a JSON blob containing the credentials.
|
json: Connect using a JSON blob containing the credentials.
|
||||||
|
username_password_domain: Includes a domain field for Active Directory.
|
||||||
|
password: Connect using only a provided password.
|
||||||
form:
|
form:
|
||||||
username:
|
username:
|
||||||
label: Username
|
label: Username
|
||||||
@@ -981,6 +1005,9 @@ credential:
|
|||||||
password:
|
password:
|
||||||
label: Password
|
label: Password
|
||||||
help: The password to use when signing in with this credential store.
|
help: The password to use when signing in with this credential store.
|
||||||
|
domain:
|
||||||
|
label: Domain
|
||||||
|
help: The Active Directory domain or FQDN (e.g., contoso.local)
|
||||||
private_key:
|
private_key:
|
||||||
label: SSH Private Key
|
label: SSH Private Key
|
||||||
help: The private key to use when connecting with this credential store.
|
help: The private key to use when connecting with this credential store.
|
||||||
@@ -1200,7 +1227,7 @@ alias:
|
|||||||
help: Specify any target across your deployment to associate this alias with
|
help: Specify any target across your deployment to associate this alias with
|
||||||
value:
|
value:
|
||||||
label: Alias Value
|
label: Alias Value
|
||||||
help: The alias you would like to use for this resource
|
help: The alias you would like to use for this resource. If using Windows, using a "." in the hostname is recommended.
|
||||||
authorize_session_arguments:
|
authorize_session_arguments:
|
||||||
label: Host ID
|
label: Host ID
|
||||||
help: Always use this host when connecting using this alias
|
help: Always use this host when connecting using this alias
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ module.exports = {
|
|||||||
browser: true,
|
browser: true,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
curly: ['error', 'multi-line', 'consistent'],
|
||||||
'ember/no-jquery': 'error',
|
'ember/no-jquery': 'error',
|
||||||
'ember/no-new-mixins': 0,
|
'ember/no-new-mixins': 0,
|
||||||
'ember/no-get': 'off',
|
'ember/no-get': 'off',
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
export default function mapTypeToColor(flashMsgType) {
|
|
||||||
// Maps the flash message type parameter to the correct hds color.
|
|
||||||
switch (flashMsgType) {
|
|
||||||
case 'error':
|
|
||||||
return 'critical';
|
|
||||||
case 'success':
|
|
||||||
return 'success';
|
|
||||||
case 'warning':
|
|
||||||
return 'warning';
|
|
||||||
default:
|
|
||||||
return 'neutral';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { modifier } from 'ember-modifier';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to detect clicks occurring _inside_ the modified element.
|
|
||||||
* This is useful especially for dropdowns to toggle content visibility
|
|
||||||
* when the user clicks an item.
|
|
||||||
*/
|
|
||||||
export default modifier(
|
|
||||||
(element, [callback]) => {
|
|
||||||
function handleClick(event) {
|
|
||||||
if (element.contains(event.target)) {
|
|
||||||
callback(element, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
element.addEventListener('click', handleClick, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
element.removeEventListener('click', handleClick, false);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ eager: false },
|
|
||||||
);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Copyright (c) HashiCorp, Inc.
|
|
||||||
* SPDX-License-Identifier: BUSL-1.1
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { modifier } from 'ember-modifier';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to detect clicks occurring _outside_ of the modified element.
|
|
||||||
* This is useful especially for dropdowns to toggle content visibility
|
|
||||||
* when the user clicks away.
|
|
||||||
*/
|
|
||||||
export default modifier(
|
|
||||||
(element, [callback]) => {
|
|
||||||
function handleClick(event) {
|
|
||||||
if (!element.contains(event.target)) {
|
|
||||||
callback(element, event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', handleClick, false);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('click', handleClick, false);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ eager: false },
|
|
||||||
);
|
|
||||||
@@ -45,13 +45,13 @@ form {
|
|||||||
.hds-table__th-sort button {
|
.hds-table__th-sort button {
|
||||||
color: var(--token-color-foreground-strong);
|
color: var(--token-color-foreground-strong);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: This is a temporary hack to fix an issue causing weird whitespace
|
// 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
|
// 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
|
// as they already forced it with !important and we can't override it otherwise
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: unset !important;
|
position: unset !important;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hds-dropdown {
|
.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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.ember-application:not(.rose-theme-light) {
|
.ember-application:not(.rose-theme-light) {
|
||||||
@include dark-mode;
|
@include dark-mode;
|
||||||
@include dark-mode-modal-override;
|
@include dark-mode-modal-override;
|
||||||
|
@include dark-mode-ember-dropdown-override;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ember-application.rose-theme-dark {
|
.ember-application.rose-theme-dark {
|
||||||
@include dark-mode;
|
@include dark-mode;
|
||||||
@include dark-mode-modal-override;
|
@include dark-mode-modal-override;
|
||||||
|
@include dark-mode-ember-dropdown-override;
|
||||||
}
|
}
|
||||||
@@ -14,10 +14,21 @@ html {
|
|||||||
body {
|
body {
|
||||||
// Used by Rose::Tabs and '/' in breadcrumbs
|
// Used by Rose::Tabs and '/' in breadcrumbs
|
||||||
// and link list panel
|
// and link list panel
|
||||||
font-family: typography.$family-primary;
|
font-family: var(--token-typography-font-stack-text);
|
||||||
|
|
||||||
// these 3 are used across the app
|
// this is used across the app
|
||||||
line-height: typography.line-height(m);
|
line-height: typography.line-height(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is use to set the color and background color
|
||||||
|
// of the ember application depending
|
||||||
|
// on where the ember application is mounted
|
||||||
|
// When running the tests, the ember application
|
||||||
|
// is mounted on a div element with the class 'ember-application'
|
||||||
|
// When running the app, the ember application
|
||||||
|
// is mounted on the body element
|
||||||
|
.ember-application {
|
||||||
|
// these are used across the app
|
||||||
color: var(--stark);
|
color: var(--stark);
|
||||||
background-color: var(--subtle);
|
background-color: var(--subtle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: ui-monospace, Menlo, monospace;
|
font-family: var(--token-typography-font-stack-code);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rose-autosize {
|
.rose-autosize {
|
||||||
|
|||||||
@@ -28,9 +28,10 @@
|
|||||||
|
|
||||||
.rose-form-checkbox-label {
|
.rose-form-checkbox-label {
|
||||||
// label styles
|
// label styles
|
||||||
@include type.type(s, semibold);
|
@include type.type(s);
|
||||||
|
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
|
font-weight: var(--token-typography-font-weight-semibold);
|
||||||
|
|
||||||
&,
|
&,
|
||||||
.rose-form-checkbox-label-description {
|
.rose-form-checkbox-label-description {
|
||||||
@@ -53,7 +54,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
content: ' ';
|
content: ' ';
|
||||||
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
|
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
|
||||||
border-radius: sizing.rems(xxxs);
|
border-radius: var(--token-form-checkbox-border-radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
margin-bottom: sizing.rems(m);
|
margin-bottom: sizing.rems(m);
|
||||||
|
|
||||||
.rose-form-fieldset-title {
|
.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 {
|
.rose-form-fieldset-body {
|
||||||
|
|||||||
Loaded 100 of 757 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user