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/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"ui/desktop/electron-app/out/**",
|
||||
"ui/desktop/electron-app/ember-dist/**",
|
||||
"**/pnpm-lock.yaml",
|
||||
"**/pnpm-workspace.yaml",
|
||||
"ui/desktop/electron-app/out/**",
|
||||
"ui/desktop/electron-app/ember-dist/**",
|
||||
"e2e-tests/api-client/**"
|
||||
]
|
||||
}
|
||||
@@ -12,12 +12,17 @@
|
||||
## Checklist
|
||||
<!-- 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 JSON response output for API changes
|
||||
- [ ] 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
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] 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
|
||||
|
||||
|
||||
@@ -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
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -26,12 +26,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -39,12 +39,12 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:oss
|
||||
- name: Upload artifact admin-ui
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui
|
||||
path: ./ui/admin/dist/
|
||||
- name: Upload artifact OSS admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-oss
|
||||
path: ./ui/admin/dist/
|
||||
@@ -53,12 +53,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:enterprise
|
||||
- name: Upload artifact enterprise admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-ent
|
||||
path: ./ui/admin/dist/
|
||||
@@ -75,12 +75,12 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- name: Build
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
- run: pnpm install --offline
|
||||
- run: pnpm run build:ui:admin:hcp
|
||||
- name: Upload artifact HCP admin UI
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: admin-ui-hcp
|
||||
path: ./ui/admin/dist/
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
|
||||
@@ -6,10 +6,14 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
# Ref and repository aren't normally needed, this is a workaround for a bug in auto commit
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- uses: hashicorp/setup-copywrite@32638da2d4e81d56a0764aa1547882fc4d209636 # v1.1.3
|
||||
- name: Add missing Copyright Headers
|
||||
run: copywrite headers
|
||||
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1
|
||||
- uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0
|
||||
with:
|
||||
commit_message: Add missing copyright headers
|
||||
@@ -6,6 +6,6 @@ jobs:
|
||||
triage:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -6,11 +6,11 @@ jobs:
|
||||
name: Install dependencies
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
needs: [dependencies]
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
@@ -58,14 +58,14 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: browser-actions/setup-chrome@c785b87e244131f27c9f19c1a33e2ead956ab7ce # v1.7.3
|
||||
- uses: browser-actions/setup-chrome@b94431e051d1c52dcbe9a7092a4f10f827795416 # v2.1.0
|
||||
with:
|
||||
install-dependencies: true
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
- run: |
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Test E2E Admin UI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/admin/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/admin/**'
|
||||
- '.github/workflows/test-e2e-admin-ui.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
boundary-enterprise-branch:
|
||||
description: 'Branch of boundary-enterprise to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
boundary-branch:
|
||||
description: 'Branch of boundary to test against (default to main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- boundary_edition: community
|
||||
github_repo: hashicorp/boundary
|
||||
folder_name: boundary
|
||||
boundary_edition_short: oss
|
||||
enos_scenario: e2e_ui_docker
|
||||
test_command: admin:ce:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-branch || 'main' }}
|
||||
- boundary_edition: enterprise
|
||||
github_repo: hashicorp/boundary-enterprise
|
||||
folder_name: boundary-enterprise
|
||||
boundary_edition_short: ent
|
||||
enos_scenario: e2e_ui_docker_ent
|
||||
test_command: admin:ent:docker
|
||||
boundary_branch: ${{ github.event.inputs.boundary-enterprise-branch || 'main' }}
|
||||
fail-fast: false
|
||||
env:
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
ui/desktop/electron-app/pnpm-lock.yaml
|
||||
|
||||
- name: Set up Terraform
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Vault CLI
|
||||
run: |
|
||||
wget https://releases.hashicorp.com/vault/1.12.2/vault_1.12.2_linux_amd64.zip -O /tmp/vault.zip
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: GPG setup
|
||||
run: |
|
||||
# Create a GPG key
|
||||
export KEY_PW=boundary
|
||||
gpg --generate-key --batch <<eoGpgConf
|
||||
%echo Started!
|
||||
Key-Type: RSA
|
||||
Key-Length: default
|
||||
Subkey-Type: RSA
|
||||
Name-Real: boundary
|
||||
Name-Comment: default
|
||||
Name-Email: default
|
||||
Expire-Date: 0
|
||||
Passphrase: $KEY_PW
|
||||
%commit
|
||||
%echo Done.
|
||||
eoGpgConf
|
||||
|
||||
# Enable gpg-preset-passphrase so that key passwords can be saved
|
||||
echo "allow-preset-passphrase" >> ~/.gnupg/gpg-agent.conf
|
||||
gpg-connect-agent reloadagent /bye &>/dev/null
|
||||
|
||||
# Get information about the created keys
|
||||
export lines=$(gpg --list-secret-keys --with-colons --with-keygrip)
|
||||
export KEY_ID=""
|
||||
while read -r line
|
||||
do
|
||||
# Save the first key id to be used later
|
||||
if [[ $line =~ "fpr"* ]]; then
|
||||
if [[ $KEY_ID == "" ]]; then
|
||||
KEY_ID="$(echo "$line" | sed -r 's/fpr|://g')"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Cache the passphrases for the keys so passwords do not need to be entered
|
||||
if [[ $line =~ "grp"* ]]; then
|
||||
export KEYGRIP_ID="$(echo "$line" | sed -r 's/grp|://g')"
|
||||
/usr/lib/gnupg/gpg-preset-passphrase --preset -P $KEY_PW $KEYGRIP_ID
|
||||
fi
|
||||
done <<< $lines
|
||||
|
||||
# Trust the key
|
||||
touch /tmp/test.txt
|
||||
gpg -a --encrypt -r $KEY_ID --trust-model always --batch --yes /tmp/test.txt
|
||||
echo "trusted-key $KEY_ID" >> ~/.gnupg/gpg.conf
|
||||
|
||||
# Initialize the password store
|
||||
pass init $KEY_ID &>/dev/null
|
||||
|
||||
- name: Checkout ${{ matrix.folder_name }} repo
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: ${{ matrix.github_repo }}
|
||||
path: support/src/${{ matrix.folder_name }}
|
||||
token: ${{ secrets.ELEVATED_GITHUB_TOKEN }}
|
||||
ref: ${{ matrix.boundary_branch }}
|
||||
|
||||
- name: GH fix for localhost resolution
|
||||
run: |
|
||||
cat /etc/hosts && echo "-----------"
|
||||
sudo sed -i 's/::1 *localhost ip6-localhost ip6-loopback/::1 ip6 -localhost ip6-loopback/g' /etc/hosts
|
||||
cat /etc/hosts
|
||||
ssh -V
|
||||
|
||||
echo "127.0.0.1 localhost boundary" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost worker" | sudo tee -a /etc/hosts
|
||||
echo "127.0.0.1 localhost vault" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Set git config
|
||||
run: |
|
||||
git config --global url."https://oauth2:${{ secrets.ELEVATED_GITHUB_TOKEN }}@github.com".insteadOf "https://github.com"
|
||||
|
||||
- name: Install node dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
cd e2e-tests
|
||||
pnpm playwright install --with-deps
|
||||
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
echo ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} > ./support/src/${{matrix.folder_name}}/internal/ui/VERSION
|
||||
export ENOS_VAR_ui_build_override="oss" && \
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Move boundary binary
|
||||
run: |
|
||||
mv ./support/src/${{matrix.folder_name}}/bin/boundary /usr/local/bin/
|
||||
boundary version
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source <(bash ./support/src/${{matrix.folder_name}}/enos/scripts/test_e2e_env.sh)
|
||||
cd e2e-tests
|
||||
pnpm run ${{ matrix.test_command }} --reporter=html
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report-${{ matrix.boundary_edition }}
|
||||
path: e2e-tests/playwright-report
|
||||
|
||||
- name: Clean up test infra
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
export ENOS_VAR_boundary_edition=${{ matrix.boundary_edition_short }} && \
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/${{matrix.folder_name}}/enos ${{matrix.enos_scenario}} builder:local
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: admin ui e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -3,30 +3,35 @@ name: Test E2E Desktop Client
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled, opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'ui/desktop/**'
|
||||
- 'e2e-tests/*'
|
||||
- 'e2e-tests/api-client/**'
|
||||
- 'e2e-tests/helpers/**'
|
||||
- 'e2e-tests/desktop/**'
|
||||
- '.github/workflows/test-e2e-desktop.yaml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: ${{contains(github.event.pull_request.labels.*.name, 'desktop')}}
|
||||
name: Run Desktop E2E Tests
|
||||
runs-on: ubuntu-22.04
|
||||
name: test
|
||||
runs-on: ${{ fromJSON(vars.RUNNER_UBUNTU_22) }}
|
||||
env:
|
||||
ENOS_VAR_boundary_edition: "ent"
|
||||
ENOS_VAR_e2e_debug_no_run: true
|
||||
ENOS_VAR_boundary_license_path: "./license.hclic"
|
||||
SETUP_CLI: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "pnpm"
|
||||
@@ -38,7 +43,7 @@ jobs:
|
||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd
|
||||
|
||||
- name: Set up Enos
|
||||
uses: hashicorp/action-setup-enos@b9fa53484a1e8fdcc7b02a118bcf01d65b9414c9
|
||||
uses: hashicorp/action-setup-enos@39a8f388434bea5eeba6649b99aa9949a55a1150
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -48,7 +53,7 @@ jobs:
|
||||
unzip /tmp/vault.zip -d /usr/local/bin
|
||||
|
||||
- name: Checkout boundary-enterprise
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: hashicorp/boundary-enterprise
|
||||
# ref: ${{ github.event.inputs.boundary-enterprise-branch }}
|
||||
@@ -72,16 +77,14 @@ jobs:
|
||||
docker pull hashicorp/boundary-enterprise:latest
|
||||
docker save hashicorp/boundary-enterprise:latest | gzip > /tmp/boundary-enterprise.zip
|
||||
|
||||
# TODO: Set up license in a better way? Use directly rather than writing to file and pull from secrets manager
|
||||
- name: Set up test infra
|
||||
id: infra
|
||||
run: |
|
||||
echo "${{ secrets.BOUNDARY_ENT_LICENSE }}" > ./support/src/boundary-enterprise/enos/license.hclic
|
||||
ssh-keygen -N '' -t ed25519 -f ~/.ssh/github_enos
|
||||
mkdir -p ./enos/terraform-plugin-cache
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
export ENOS_VAR_boundary_docker_image_name=hashicorp/boundary-enterprise:latest && \
|
||||
export ENOS_VAR_boundary_license="${{ secrets.BOUNDARY_ENT_LICENSE }}" && \
|
||||
export ENOS_VAR_boundary_docker_image_file=/tmp/boundary-enterprise.zip && \
|
||||
enos scenario launch --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
@@ -102,7 +105,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e-tests/playwright-report
|
||||
@@ -113,3 +116,13 @@ jobs:
|
||||
export ENOS_VAR_enos_user=$GITHUB_ACTOR && \
|
||||
export ENOS_VAR_aws_ssh_private_key_path=~/.ssh/github_enos && \
|
||||
enos scenario destroy --timeout 60m0s --chdir ./support/src/boundary-enterprise/enos e2e_ui_docker_ent builder:crt
|
||||
|
||||
- name: Send Slack message on failure
|
||||
uses: slackapi/slack-github-action@91efab103c0de0a537f72a35f6b8cda0ee76bf0a # v2.1.1
|
||||
if: ${{ failure() && github.repository == 'hashicorp/boundary-ui' }}
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: ${{ secrets.SLACK_CHANNEL_ID }}
|
||||
text: ":x: desktop client e2e tests failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Branch:* ${{ github.repository }}:${{ github.head_ref }}"
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
trigger-gha-release:
|
||||
runs-on: ${{ fromJSON(vars.RUNNER) }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Release description
|
||||
run: |
|
||||
echo "Triggering release with a TAG ${GITHUB_REF#refs/tags/}"
|
||||
|
||||
Binary file not shown.
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
@@ -45,6 +46,7 @@ module.exports = {
|
||||
'./blueprints/*/index.js',
|
||||
'./config/**/*.js',
|
||||
'./tests/dummy/config/**/*.js',
|
||||
'./broccoli-plugins/**/*.js',
|
||||
],
|
||||
parserOptions: {
|
||||
sourceType: 'script',
|
||||
|
||||
@@ -56,32 +56,33 @@ export default function setupStubs(hooks) {
|
||||
stubTypes = types;
|
||||
|
||||
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
|
||||
.withArgs('searchCacheDaemon')
|
||||
.onCall(i)
|
||||
.returns({
|
||||
[underscore(resourceNames[singularize(resourceName)])]: models.map(
|
||||
(model) => {
|
||||
// Use internal serializer to serialize the model correctly
|
||||
// according to our mirage serializers
|
||||
const modelData =
|
||||
this.server.serializerOrRegistry.serialize(model);
|
||||
.callsFake(() => {
|
||||
let models;
|
||||
let resourceName;
|
||||
if (typeOf(type) === 'object') {
|
||||
models = type.func();
|
||||
resourceName = type.resource;
|
||||
} else {
|
||||
models = this.server.schema[type].all().models;
|
||||
resourceName = type;
|
||||
}
|
||||
|
||||
// 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));
|
||||
},
|
||||
),
|
||||
return {
|
||||
[underscore(resourceNames[singularize(resourceName)])]:
|
||||
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
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* This test helper can help set up and cleanup the database in your tests.
|
||||
*
|
||||
* ```js
|
||||
* import { setupIndexedDb } from 'api/test-support/helpers/indexed-db';
|
||||
* import { setupSqlite } from 'api/test-support/helpers/sqlite';
|
||||
*
|
||||
* module('Acceptance | my test', function(hooks) {
|
||||
* setupApplicationTest(hooks);
|
||||
* setupIndexedDb(hooks);
|
||||
* setupSqlite(hooks);
|
||||
*
|
||||
* // add your actual tests here
|
||||
* });
|
||||
@@ -19,17 +21,17 @@
|
||||
*
|
||||
* @param hooks
|
||||
*/
|
||||
export function setupIndexedDb(hooks) {
|
||||
export function setupSqlite(hooks) {
|
||||
hooks.beforeEach(async function () {
|
||||
const indexedDb = this.owner.lookup('service:indexed-db');
|
||||
await indexedDb.setup(`test-indexed-db-${Date.now()}`);
|
||||
const sqliteDb = this.owner.lookup('service:sqlite');
|
||||
await sqliteDb.setup(`test-sqlite-${uuidv4()}`);
|
||||
});
|
||||
|
||||
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 typeIsValid =
|
||||
type === 'user' || type === 'group' || type === 'managed-group';
|
||||
if (!typeIsValid)
|
||||
if (!typeIsValid) {
|
||||
throw new InvalidRolePrincipalTypeError(
|
||||
`Expected a role principal of type 'user', 'group', or 'managed-group'. Got '${type}'.`,
|
||||
);
|
||||
}
|
||||
return this.can.can(`read ${type}`, this.model);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,8 @@ export default class TargetAbility extends ModelAbility {
|
||||
*/
|
||||
get canAddInjectedApplicationCredentialSources() {
|
||||
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;
|
||||
|
||||
@attr('object-as-array', {
|
||||
@attr('object', {
|
||||
for: 'vault-generic',
|
||||
description: 'It indicates the credential mapping overrides.',
|
||||
emptyObjectIfMissing: true,
|
||||
})
|
||||
credential_mapping_overrides;
|
||||
|
||||
|
||||
@@ -51,17 +51,17 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
})
|
||||
version;
|
||||
|
||||
// =attributes (username_password, ssh_private_key)
|
||||
// =attributes (username_password, ssh_private_key, username_password_domain)
|
||||
@attr('string', {
|
||||
for: ['username_password', 'ssh_private_key'],
|
||||
for: ['username_password', 'ssh_private_key', 'username_password_domain'],
|
||||
isNestedAttribute: true,
|
||||
description: 'The username for credential.',
|
||||
})
|
||||
username;
|
||||
|
||||
// =attributes (username_password)
|
||||
// =attributes (username_password, username_password_domain, password)
|
||||
@attr('string', {
|
||||
for: 'username_password',
|
||||
for: ['username_password', 'username_password_domain', 'password'],
|
||||
isNestedAttribute: true,
|
||||
isSecret: true,
|
||||
description: 'The password for credential.',
|
||||
@@ -92,4 +92,12 @@ export default class GeneratedCredentialModel extends BaseModel {
|
||||
description: 'The secret associated with credential.',
|
||||
})
|
||||
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 {
|
||||
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.
|
||||
*/
|
||||
export const options = {
|
||||
http_method: ['GET', 'POST'],
|
||||
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: {
|
||||
username_password: ['username_attribute', 'password_attribute'],
|
||||
ssh_private_key: [
|
||||
@@ -19,12 +29,19 @@ export const options = {
|
||||
'private_key_passphrase_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_SSH_CERTIFICATE =
|
||||
'vault-ssh-certificate';
|
||||
export const TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP = 'vault-ldap';
|
||||
|
||||
/**
|
||||
* Supported Credential Library types.
|
||||
@@ -32,6 +49,7 @@ export const TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE =
|
||||
export const TYPES_CREDENTIAL_LIBRARY = Object.freeze([
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
]);
|
||||
|
||||
export default class CredentialLibraryModel extends GeneratedCredentialLibraryModel {
|
||||
@@ -52,7 +70,8 @@ export default class CredentialLibraryModel extends GeneratedCredentialLibraryMo
|
||||
get isVault() {
|
||||
return (
|
||||
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() {
|
||||
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_SSH_PRIVATE_KEY = 'ssh_private_key';
|
||||
export const TYPE_CREDENTIAL_JSON = 'json';
|
||||
export const TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN =
|
||||
'username_password_domain';
|
||||
export const TYPE_CREDENTIAL_PASSWORD = 'password';
|
||||
|
||||
export const TYPES_CREDENTIAL = Object.freeze([
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN,
|
||||
TYPE_CREDENTIAL_JSON,
|
||||
TYPE_CREDENTIAL_PASSWORD,
|
||||
]);
|
||||
|
||||
export default class CredentialModel extends GeneratedCredentialModel {
|
||||
|
||||
@@ -9,7 +9,13 @@ import { service } from '@ember/service';
|
||||
|
||||
export const TYPE_TARGET_TCP = 'tcp';
|
||||
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 {
|
||||
// =services
|
||||
@@ -347,4 +353,12 @@ export default class TargetModel extends GeneratedTargetModel {
|
||||
get isSSH() {
|
||||
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 = {};
|
||||
}
|
||||
// New record case
|
||||
if (password && snapshot?.record?.isNew)
|
||||
if (password && snapshot?.record?.isNew) {
|
||||
serialized.attributes.password = password;
|
||||
}
|
||||
// Set password custom method
|
||||
if (snapshot?.adapterOptions?.method === 'set-password') {
|
||||
serialized = this.serializeForSetPassword(snapshot, password);
|
||||
|
||||
@@ -44,21 +44,7 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
if (!serialized.attributes?.http_method?.match(/post/i)) {
|
||||
serialized.attributes.http_request_body = null;
|
||||
}
|
||||
const { credential_type, credential_mapping_overrides } = serialized;
|
||||
|
||||
// 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;
|
||||
}, {});
|
||||
}
|
||||
this.handleCredentialMappingOverrides(serialized, isNew);
|
||||
return serialized;
|
||||
}
|
||||
}
|
||||
@@ -74,4 +60,29 @@ export default class CredentialLibrarySerializer extends ApplicationSerializer {
|
||||
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 (
|
||||
!serialized.attributes?.client_certificate_key &&
|
||||
serialized.attributes?.client_certificate
|
||||
)
|
||||
) {
|
||||
delete serialized.attributes.client_certificate_key;
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
@@ -76,9 +76,10 @@ export default class StorageBucketSerializer extends ApplicationSerializer {
|
||||
// This deletes any fields that don't belong to the record's credential type
|
||||
if (options.isNestedAttribute && json.attributes) {
|
||||
// The key must be included in the fieldsByType list above
|
||||
if (!fieldsByCredentialType[credentialType].includes(key))
|
||||
if (!fieldsByCredentialType[credentialType].includes(key)) {
|
||||
// API requires these fields to be null
|
||||
json.attributes[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
//json.secrets is only present, if there are any updates to the secret fields
|
||||
|
||||
@@ -33,16 +33,18 @@ export default class TargetSerializer extends ApplicationSerializer {
|
||||
const injectedApplicationCredentialSourceIDs =
|
||||
snapshot?.adapterOptions?.injectedApplicationCredentialSourceIDs;
|
||||
|
||||
if (brokeredCredentialSourceIDs)
|
||||
if (brokeredCredentialSourceIDs) {
|
||||
serialized = this.serializeWithBrokeredCredentialSources(
|
||||
snapshot,
|
||||
brokeredCredentialSourceIDs,
|
||||
);
|
||||
if (injectedApplicationCredentialSourceIDs)
|
||||
}
|
||||
if (injectedApplicationCredentialSourceIDs) {
|
||||
serialized = this.serializeWithInjectedApplicationCredentialSources(
|
||||
snapshot,
|
||||
injectedApplicationCredentialSourceIDs,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNew && serialized?.with_aliases) {
|
||||
// API expects scope id along with every alias value
|
||||
|
||||
@@ -18,8 +18,9 @@ export default class UserSerializer extends ApplicationSerializer {
|
||||
serialize(snapshot) {
|
||||
let serialized = super.serialize(...arguments);
|
||||
const accountIDs = snapshot?.adapterOptions?.accountIDs;
|
||||
if (accountIDs)
|
||||
if (accountIDs) {
|
||||
serialized = this.serializewithAccounts(snapshot, accountIDs);
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Service, { service } from '@ember/service';
|
||||
import { modelMapping } from 'api/services/sqlite';
|
||||
import { assert } from '@ember/debug';
|
||||
import { generateSQLExpressions } from '../utils/sqlite-query';
|
||||
|
||||
/**
|
||||
* Database service for querying Boundary resources from storage.
|
||||
* Provides a high-level interface for executing queries against the local database.
|
||||
*/
|
||||
export default class DbService extends Service {
|
||||
@service sqlite;
|
||||
|
||||
/**
|
||||
* Query resources from the database by type.
|
||||
*
|
||||
* @param {string} type - The resource type (e.g., 'scope', 'user', 'target').
|
||||
* Must be a supported model type defined in modelMapping.
|
||||
* @param {Object} query - Query configuration object
|
||||
* @returns {Promise<Object>} Promise resolving to query results from sqlite service
|
||||
* @throws {Error} Assertion error if resource type is not supported
|
||||
*/
|
||||
query(type, query) {
|
||||
const supportedModels = Object.keys(modelMapping);
|
||||
assert('Resource type is not supported.', supportedModels.includes(type));
|
||||
|
||||
let { page, pageSize, select, query: queryObj } = query;
|
||||
|
||||
const { sql, parameters } = generateSQLExpressions(type, queryObj, {
|
||||
page,
|
||||
pageSize,
|
||||
select,
|
||||
});
|
||||
|
||||
return this.sqlite.fetchResource({
|
||||
sql,
|
||||
parameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 RequestManager from '@ember-data/request';
|
||||
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 {
|
||||
requestManager = new RequestManager();
|
||||
@@ -18,9 +18,9 @@ export default class extends Store {
|
||||
constructor(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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
export { default } from 'api/services/indexed-db';
|
||||
export { default } from 'api/services/db';
|
||||
@@ -3,4 +3,4 @@
|
||||
* 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';
|
||||
|
||||
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 = {
|
||||
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;
|
||||
// Default parent scope is global
|
||||
if (!scope_id) scope_id = 'global';
|
||||
// We do not return global scope to mimic our API.
|
||||
if (recursive && scope_id === 'global') {
|
||||
resultSet = scopes.all();
|
||||
resultSet = scopes.where((scope) => {
|
||||
return scope.scope ? scope.type !== 'global' : false;
|
||||
});
|
||||
} else {
|
||||
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));
|
||||
@@ -537,10 +542,11 @@ function routes() {
|
||||
session.update({
|
||||
status: pickRandomStatusString(),
|
||||
});
|
||||
if (session.id.split('-').pop() % 2)
|
||||
if (session.id.split('-').pop() % 2) {
|
||||
session.update({
|
||||
userId: 'authenticateduser',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
if (recursive && scope_id === 'global') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import generateId from '../helpers/id';
|
||||
import {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
TYPES_CREDENTIAL_LIBRARY,
|
||||
options,
|
||||
} from 'api/models/credential-library';
|
||||
@@ -45,6 +46,10 @@ export default factory.extend({
|
||||
key_bits: faker.number.int(999),
|
||||
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;
|
||||
switch (type) {
|
||||
case 'vault':
|
||||
server.createList('credential-library', 2, {
|
||||
server.createList('credential-library', 3, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
break;
|
||||
case 'static':
|
||||
default:
|
||||
server.createList('credential', 3, {
|
||||
server.createList('credential', 5, {
|
||||
scope,
|
||||
credentialStore,
|
||||
});
|
||||
|
||||
@@ -7,19 +7,31 @@ import factory from '../generated/factories/credential';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import permissions from '../helpers/permissions';
|
||||
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({
|
||||
type: (i) => types[i % types.length],
|
||||
id() {
|
||||
switch (this.type) {
|
||||
case 'ssh_private_key':
|
||||
case TYPE_CREDENTIAL_SSH_PRIVATE_KEY:
|
||||
return generatedId('credspk_');
|
||||
case 'username_password':
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD:
|
||||
return generatedId('credup_');
|
||||
case 'json':
|
||||
case TYPE_CREDENTIAL_JSON:
|
||||
return generatedId('credjson_');
|
||||
case TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN:
|
||||
return generatedId('credupd_');
|
||||
case TYPE_CREDENTIAL_PASSWORD:
|
||||
return generatedId('credp_');
|
||||
}
|
||||
},
|
||||
authorized_actions: () =>
|
||||
@@ -30,8 +42,21 @@ export default factory.extend({
|
||||
'delete',
|
||||
],
|
||||
attributes() {
|
||||
return {
|
||||
username: faker.internet.userName(),
|
||||
};
|
||||
switch (this.type) {
|
||||
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 generateId from '../helpers/id';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TYPE_AUTH_METHOD_PASSWORD } from 'api/models/auth-method';
|
||||
|
||||
export default factory.extend({
|
||||
type: 'global',
|
||||
@@ -78,6 +79,15 @@ export default factory.extend({
|
||||
return name;
|
||||
},
|
||||
|
||||
scope() {
|
||||
if (this.type === 'global') {
|
||||
return {
|
||||
id: 'global',
|
||||
type: 'global',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
withChildren: trait({
|
||||
afterCreate(record, server) {
|
||||
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 permissions from '../helpers/permissions';
|
||||
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 randomFilter = () =>
|
||||
@@ -16,6 +27,35 @@ const randomFilter = () =>
|
||||
const hostSetChance = 0.3;
|
||||
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({
|
||||
authorized_actions: () =>
|
||||
permissions.authorizedActionsFor('target') || [
|
||||
@@ -45,29 +85,87 @@ export default factory.extend({
|
||||
*/
|
||||
withAssociations: trait({
|
||||
afterCreate(target, server) {
|
||||
let randomlySelectedHostSets,
|
||||
randomlySelectedCredentialLibraries,
|
||||
randomlySelectedCredentials;
|
||||
randomlySelectedHostSets = server.schema.hostSets
|
||||
const { scope } = target;
|
||||
|
||||
const randomlySelectedHostSets = server.schema.hostSets
|
||||
// BLERG: fun fact, for no reason at all, the element passed
|
||||
// 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
|
||||
// representation of the model instance. It's very confusing since
|
||||
// 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));
|
||||
|
||||
randomlySelectedCredentialLibraries = server.schema.credentialLibraries
|
||||
.where(
|
||||
const randomlySelectedBrokeredCredentialSources = [
|
||||
...selectItems(
|
||||
server.schema.credentialLibraries,
|
||||
(credentialLibrary) => credentialLibrary.scopeId === target.scope.id,
|
||||
)
|
||||
.models.filter(() => randomBoolean())
|
||||
.map((cred) => cred.id);
|
||||
randomlySelectedCredentials = server.schema.credentials
|
||||
.where((credential) => credential.scopeId === target.scope.id)
|
||||
.models.filter(() => randomBoolean())
|
||||
.map((cred) => cred.id);
|
||||
),
|
||||
...selectItems(
|
||||
server.schema.credentials,
|
||||
(credential) => credential.scopeId === target.scope.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 randomlySelectedStorageBucket =
|
||||
storageBuckets.length === 0
|
||||
@@ -81,6 +179,7 @@ export default factory.extend({
|
||||
});
|
||||
}
|
||||
|
||||
// Add Aliases
|
||||
const aliases = server.schema.aliases.all().models;
|
||||
const randomlySelectedAliases =
|
||||
aliases.length === 0 ? undefined : faker.helpers.arrayElement(aliases);
|
||||
@@ -89,17 +188,6 @@ export default factory.extend({
|
||||
aliases: randomlySelectedAliases.aliases,
|
||||
});
|
||||
}
|
||||
target.update({
|
||||
hostSets: randomlySelectedHostSets,
|
||||
brokeredCredentialSourceIds: [
|
||||
...randomlySelectedCredentialLibraries,
|
||||
...randomlySelectedCredentials,
|
||||
],
|
||||
injectedApplicationCredentialSourceIds: [
|
||||
...randomlySelectedCredentialLibraries,
|
||||
...randomlySelectedCredentials,
|
||||
],
|
||||
});
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
@@ -3,4 +3,6 @@
|
||||
* 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 = {
|
||||
password: {
|
||||
login: (payload, scopeAttrs) => {
|
||||
login: (payload, auth_method_id, account_id, scopeAttrs) => {
|
||||
if (payload.attributes.login_name === 'error') {
|
||||
return new Response(400);
|
||||
} else {
|
||||
@@ -25,9 +25,9 @@ const commandHandlers = {
|
||||
scope: scopeAttrs,
|
||||
id: 'token123',
|
||||
token: 'thetokenstring',
|
||||
account_id: 'authenticatedaccount',
|
||||
account_id: account_id || 'authenticatedaccount',
|
||||
user_id: 'authenticateduser',
|
||||
auth_method_id: 'authmethod123',
|
||||
auth_method_id: auth_method_id || 'authmethod123',
|
||||
created_time: '',
|
||||
updated_time: '',
|
||||
last_used_time: '',
|
||||
@@ -61,7 +61,7 @@ const commandHandlers = {
|
||||
},
|
||||
},
|
||||
),
|
||||
token: (_, scopeAttrs) => {
|
||||
token: (_, auth_method_id, account_id, scopeAttrs) => {
|
||||
oidcAttemptCounter++;
|
||||
if (oidcAttemptCounter < oidcRequiredAttempts) {
|
||||
return new Response(202);
|
||||
@@ -74,9 +74,9 @@ const commandHandlers = {
|
||||
scope: scopeAttrs,
|
||||
id: 'token123',
|
||||
token: 'thetokenstring',
|
||||
account_id: '1',
|
||||
account_id: account_id || '1',
|
||||
user_id: 'authenticateduser',
|
||||
auth_method_id: 'authmethod123',
|
||||
auth_method_id: auth_method_id || 'authmethod123',
|
||||
created_time: '',
|
||||
updated_time: '',
|
||||
last_used_time: '',
|
||||
@@ -90,7 +90,7 @@ const commandHandlers = {
|
||||
};
|
||||
|
||||
// Handles all custom methods on /auth-methods/:id route
|
||||
export function authHandler({ scopes, authMethods }, request) {
|
||||
export function authHandler({ scopes, authMethods, accounts }, request) {
|
||||
const [, id, method] = request.params.id_method.match(
|
||||
/(?<id>.[^:]*):(?<method>(.*))/,
|
||||
);
|
||||
@@ -100,8 +100,14 @@ export function authHandler({ scopes, authMethods }, request) {
|
||||
const payload = JSON.parse(request.requestBody);
|
||||
const { command } = payload;
|
||||
const scope = scopes.find(authMethod.scopeId);
|
||||
const account = accounts.where({ authMethodId: authMethod.id })?.models[0];
|
||||
const scopeAttrs = this.serialize(scopes.find(scope.id));
|
||||
return commandHandlers[authMethod.type][command](payload, scopeAttrs);
|
||||
return commandHandlers[authMethod.type][command](
|
||||
payload,
|
||||
authMethod.id,
|
||||
account?.id,
|
||||
scopeAttrs,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: this handler doesn't really belong here, but we already route
|
||||
|
||||
@@ -122,8 +122,8 @@ export default function (server) {
|
||||
{ scope, type: 'plugin' },
|
||||
'withChildren',
|
||||
);
|
||||
server.createList('credential-store', 3, { scope }, 'withAssociations');
|
||||
server.createList('target', 2, { scope }, 'withAssociations');
|
||||
server.createList('credential-store', 5, { scope }, 'withAssociations');
|
||||
server.createList('target', 4, { scope }, 'withAssociations');
|
||||
server.create('target', { scope, address: '0.0.0.0' });
|
||||
// Sessions have target data. Create it after targets.
|
||||
server.createList('session', 4, { scope }, 'withAssociations');
|
||||
|
||||
@@ -246,6 +246,22 @@ export default function initializeMockIPC(server, config) {
|
||||
}
|
||||
pauseClientAgent() {}
|
||||
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": {
|
||||
"@babel/core": "^7.26.10",
|
||||
"@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-can": "^4.2.0",
|
||||
"ember-cli-babel": "^8.2.0",
|
||||
"ember-cli-htmlbars": "^6.3.0",
|
||||
"ember-cli-mirage": "^3.0.3",
|
||||
"ember-data": "~5.3.12",
|
||||
"esbuild": "^0.25.4",
|
||||
"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": {
|
||||
"@babel/eslint-parser": "^7.25.1",
|
||||
@@ -45,7 +52,7 @@
|
||||
"@ember/test-waiters": "^4.1.0",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.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 { 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) {
|
||||
setupTest(hooks);
|
||||
@@ -22,10 +26,18 @@ module('Unit | Model | credential library', function (hooks) {
|
||||
type: TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
});
|
||||
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',
|
||||
});
|
||||
|
||||
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 { 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) {
|
||||
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) {
|
||||
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', {
|
||||
type: 'username_password',
|
||||
type: TYPE_CREDENTIAL_USERNAME_PASSWORD,
|
||||
});
|
||||
const modelC = store.createRecord('credential', {
|
||||
type: 'ssh_private_key',
|
||||
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY,
|
||||
});
|
||||
|
||||
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) {
|
||||
const store = this.owner.lookup('service:store');
|
||||
const modelA = store.createRecord('credential', {
|
||||
type: 'username_password',
|
||||
type: TYPE_CREDENTIAL_USERNAME_PASSWORD
|
||||
});
|
||||
const modelB = store.createRecord('credential', {
|
||||
type: 'ssh_private_key',
|
||||
type: TYPE_CREDENTIAL_SSH_PRIVATE_KEY
|
||||
});
|
||||
const modelC = store.createRecord('credential', { type: 'unknown' });
|
||||
const modelD = store.createRecord('credential', { type: TYPE_CREDENTIAL_USERNAME_PASSWORD_DOMAIN });
|
||||
|
||||
assert.false(modelA.isUnknown);
|
||||
assert.false(modelB.isUnknown);
|
||||
assert.true(modelC.isUnknown);
|
||||
assert.false(modelD.isUnknown);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'ember-qunit';
|
||||
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) {
|
||||
setupTest(hooks);
|
||||
@@ -386,6 +390,7 @@ module('Unit | Model | target', function (hooks) {
|
||||
});
|
||||
assert.true(modelSSH.isSSH);
|
||||
assert.false(modelSSH.isTCP);
|
||||
assert.false(modelSSH.isRDP);
|
||||
});
|
||||
|
||||
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.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 {
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_GENERIC,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_SSH_CERTIFICATE,
|
||||
TYPE_CREDENTIAL_LIBRARY_VAULT_LDAP,
|
||||
} from 'api/models/credential-library';
|
||||
|
||||
module('Unit | Serializer | credential library', function (hooks) {
|
||||
@@ -24,9 +25,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
http_method: 'GET',
|
||||
version: 1,
|
||||
credential_type: 'ssh_private_key',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'username_attribute', value: 'user' },
|
||||
],
|
||||
credential_mapping_overrides: { username_attribute: 'user' },
|
||||
});
|
||||
const snapshot = record._createSnapshot();
|
||||
const serializedRecord = serializer.serialize(snapshot);
|
||||
@@ -63,9 +62,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
http_method: 'GET',
|
||||
version: 1,
|
||||
credential_type: 'ssh_private_key',
|
||||
credential_mapping_overrides: [
|
||||
{ key: 'private_key_attribute', value: 'test' },
|
||||
],
|
||||
credential_mapping_overrides: { private_key_attribute: 'test' },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -109,6 +106,7 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
let serializedVaultGenericRecord = vaultGenericRecord.serialize();
|
||||
let serializedVaultSSHCertificateRecord =
|
||||
vaultSSHCertificateRecord.serialize();
|
||||
|
||||
assert.deepEqual(serializedVaultGenericRecord, {
|
||||
attributes: {
|
||||
path: null,
|
||||
@@ -279,4 +277,61 @@ module('Unit | Serializer | credential library', function (hooks) {
|
||||
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,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
|
||||
@@ -101,7 +101,7 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
* @param {string} username
|
||||
* @return {object}
|
||||
*/
|
||||
normalizeData(data, username) {
|
||||
normalizeData(data) {
|
||||
// Pull fields up from `data.attributes` for easier access in JavaScript.
|
||||
// The `attributes` field exists on the Go side for its convenience but is
|
||||
// unnecessary here.
|
||||
@@ -109,7 +109,6 @@ export default class BaseAuthenticator extends SimpleAuthBaseAuthenticator {
|
||||
// Add booleans indicated the scope type
|
||||
data.isGlobal = data?.scope?.type === 'global';
|
||||
data.isOrg = data?.scope?.type === 'org';
|
||||
if (username) data.username = username;
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ export default class PasswordAuthenticator extends BaseAuthenticator {
|
||||
fetch(authEndpointURL, { method: 'post', body }),
|
||||
);
|
||||
const json = await response.json();
|
||||
return response.status < 400
|
||||
? resolve(this.normalizeData(json, login_name))
|
||||
: reject();
|
||||
return response.status < 400 ? resolve(this.normalizeData(json)) : reject();
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"@ember/optional-features": "^2.1.0",
|
||||
"@ember/test-helpers": "^5.2.1",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-get': 'off',
|
||||
'ember/no-get-with-default': 'off',
|
||||
'ember/no-computed-properties-in-native-classes': 'off',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<DD.Header @hasDivider={{true}}>
|
||||
<Hds::Form::TextInput::Base
|
||||
@type='search'
|
||||
@value={{this.searchTerm}}
|
||||
@value={{or this.searchTerm @searchTerm}}
|
||||
placeholder={{t 'actions.narrow-results'}}
|
||||
aria-label={{t 'actions.narrow-results'}}
|
||||
{{on 'input' this.filterItems}}
|
||||
@@ -28,11 +28,18 @@
|
||||
/>
|
||||
</DD.Header>
|
||||
{{/if}}
|
||||
{{#unless this.itemOptions}}
|
||||
|
||||
{{#unless (or this.itemOptions @isLoading)}}
|
||||
<DD.Description @text={{t 'titles.no-results'}} />
|
||||
{{/unless}}
|
||||
|
||||
{{#if (has-block)}}
|
||||
{{#if @isLoading}}
|
||||
<DD.Generic>
|
||||
<Hds::Layout::Flex @align='center' @justify='center'>
|
||||
<Hds::Icon @name='loading' @size='24' class='margin-y-xs' />
|
||||
</Hds::Layout::Flex>
|
||||
</DD.Generic>
|
||||
{{else if (has-block)}}
|
||||
{{yield DD this.selectItem this.itemOptions}}
|
||||
{{else}}
|
||||
{{#each this.itemOptions as |itemOption|}}
|
||||
@@ -47,13 +54,15 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{#unless @isLoading}}
|
||||
<DD.Footer @hasDivider={{true}}>
|
||||
<Hds::Button
|
||||
@text='Apply'
|
||||
@isFullWidth={{true}}
|
||||
@size='small'
|
||||
{{on 'click' (fn this.applyFilter DD.close)}}
|
||||
data-test-dropdown-apply-button
|
||||
/>
|
||||
</DD.Footer>
|
||||
{{/unless}}
|
||||
</Hds::Dropdown>
|
||||
@@ -21,7 +21,8 @@ export default class DropdownComponent extends Component {
|
||||
*/
|
||||
get itemOptions() {
|
||||
let items = this.args.itemOptions;
|
||||
if (this.searchTerm) {
|
||||
|
||||
if (this.searchTerm && !this.args.updateSearchTerm) {
|
||||
const searchTerm = this.searchTerm.toLowerCase();
|
||||
items = this.args.itemOptions.filter((item) => {
|
||||
const isNameMatch = item.name?.toLowerCase().includes(searchTerm);
|
||||
@@ -29,7 +30,7 @@ export default class DropdownComponent extends Component {
|
||||
return isNameMatch || isIdMatch;
|
||||
});
|
||||
}
|
||||
return items.slice(0, 500);
|
||||
return items.slice(0, 250);
|
||||
}
|
||||
|
||||
// =methods
|
||||
@@ -39,10 +40,15 @@ export default class DropdownComponent extends Component {
|
||||
* @param {object} event
|
||||
*/
|
||||
@action
|
||||
@debounce(150)
|
||||
filterItems(event) {
|
||||
@debounce(250)
|
||||
async filterItems(event) {
|
||||
const { value } = event.target;
|
||||
this.searchTerm = value;
|
||||
|
||||
if (this.args.updateSearchTerm) {
|
||||
this.args.updateSearchTerm(value);
|
||||
} else {
|
||||
this.searchTerm = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,16 +17,27 @@ export default class FilterTagsIndexComponent extends Component {
|
||||
|
||||
get filters() {
|
||||
const { allFilters, selectedFilters } = this.args.filters;
|
||||
return Object.entries(allFilters).flatMap(([key, value]) => {
|
||||
assert(`Tags must be an array for key ${key}`, Array.isArray(value));
|
||||
const paramsSet = new Set(selectedFilters[key]);
|
||||
const filters = value.filter((item) => paramsSet.has(item.id));
|
||||
return Object.entries(allFilters).flatMap(([key, values]) => {
|
||||
assert(`Tags must be an array for key ${key}`, Array.isArray(values));
|
||||
const uniqueSelectedFilters = [...new Set(selectedFilters[key])];
|
||||
if (!uniqueSelectedFilters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: key,
|
||||
}));
|
||||
const valueMap = new Map(values.map((value) => [value.id, value.name]));
|
||||
|
||||
return uniqueSelectedFilters.filter(Boolean).map((item) => {
|
||||
const filter = {
|
||||
id: item,
|
||||
type: key,
|
||||
};
|
||||
|
||||
if (valueMap.has(item)) {
|
||||
filter.name = valueMap.get(item);
|
||||
}
|
||||
|
||||
return filter;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
}}
|
||||
|
||||
{{#if @model.type}}
|
||||
{{component
|
||||
(concat 'form/authenticate/' @model.type)
|
||||
onSubmit=@submit
|
||||
disabled=@disabled
|
||||
}}
|
||||
<this.authenticateFormComponent
|
||||
@onSubmit={{@submit}}
|
||||
@disabled={{@disabled}}
|
||||
/>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { assert } from '@ember/debug';
|
||||
import oidc from './oidc';
|
||||
import ldap from './ldap';
|
||||
import password from './password';
|
||||
|
||||
const modelTypeToComponent = {
|
||||
ldap,
|
||||
password,
|
||||
oidc,
|
||||
};
|
||||
|
||||
export default class FormAuthenticateIndex extends Component {
|
||||
get authenticateFormComponent() {
|
||||
const component = modelTypeToComponent[this.args.model.type];
|
||||
assert(
|
||||
`Mapped component must exist for auth method: ${this.args.model.type}`,
|
||||
component,
|
||||
);
|
||||
return component;
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@ export function notifySuccess(notification) {
|
||||
const text = intlService.t(candidateKey);
|
||||
const value = await method.apply(this, arguments);
|
||||
notifyService.success(text, {
|
||||
notificationType: 'success',
|
||||
color: 'success',
|
||||
title: intlService.t('states.success'),
|
||||
dismiss: (flash) => flash.destroyMessage(),
|
||||
});
|
||||
return value;
|
||||
@@ -99,7 +100,8 @@ export function notifyError(
|
||||
const sticky = options.sticky === undefined ? true : options.sticky;
|
||||
|
||||
notifyService.danger(text, {
|
||||
notificationType: 'error',
|
||||
color: 'critical',
|
||||
title: intlService.t('states.error'),
|
||||
sticky,
|
||||
dismiss: (flash) => flash.destroyMessage(),
|
||||
});
|
||||
|
||||
@@ -129,8 +129,9 @@ class RouteResourceFilter extends EmberObject {
|
||||
const filterKey = `filter-${name}`;
|
||||
|
||||
// If the route has no query params specified yet, declare them.
|
||||
if (!Object.keys(routeClass.queryParams).length)
|
||||
if (!Object.keys(routeClass.queryParams).length) {
|
||||
routeClass.queryParams = {};
|
||||
}
|
||||
|
||||
// If the route has no resource filters list specified yet, create one.
|
||||
if (!routeClass.resourceFilterParams) routeClass.resourceFilterParams = [];
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
@use 'rose/app/styles/rose/variables/sizing' as sizing;
|
||||
@use 'sass:meta';
|
||||
|
||||
.filters-applied {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -72,3 +75,34 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
$margin-directions: (
|
||||
'': 'margin',
|
||||
'-top': 'margin-top',
|
||||
'-right': 'margin-right',
|
||||
'-bottom': 'margin-bottom',
|
||||
'-left': 'margin-left',
|
||||
'-x': (
|
||||
'margin-left',
|
||||
'margin-right',
|
||||
),
|
||||
'-y': (
|
||||
'margin-top',
|
||||
'margin-bottom',
|
||||
),
|
||||
);
|
||||
|
||||
// Generate margin utility classes with sizes and directions
|
||||
@each $size-name, $size-coefficient in sizing.$size-coefficients {
|
||||
@each $direction-suffix, $properties in $margin-directions {
|
||||
.margin#{$direction-suffix}-#{$size-name} {
|
||||
@if meta.type-of($properties) == 'list' {
|
||||
@each $property in $properties {
|
||||
#{$property}: #{sizing.rems($size-name)};
|
||||
}
|
||||
} @else {
|
||||
#{$properties}: #{sizing.rems($size-name)};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,4 +3,4 @@
|
||||
* 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-sass": "^11.0.1",
|
||||
"ember-concurrency": "^4.0.3",
|
||||
"ember-feature-flags": "^6.0.0",
|
||||
"ember-feature-flags": "^7.0.0",
|
||||
"ember-intl": "^7.1.8",
|
||||
"ember-loading": "^2.0.0",
|
||||
"ember-truth-helpers": "^4.0.3",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@ember/test-helpers": "^5.2.1",
|
||||
"@embroider/test-setup": "^4.0.0",
|
||||
"@faker-js/faker": "^8.0.2",
|
||||
"@glimmer/component": "^1.1.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@glimmer/tracking": "^1.1.2",
|
||||
"broccoli-asset-rev": "^3.0.0",
|
||||
"concurrently": "^9.1.0",
|
||||
|
||||
@@ -127,7 +127,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
assert.true(result);
|
||||
});
|
||||
|
||||
test('it only renders a max of 500 items but allows searching on all items', async function (assert) {
|
||||
test('it only renders a max of 250 items but allows searching on all items', async function (assert) {
|
||||
this.set('checkedItems', []);
|
||||
this.set(
|
||||
'itemOptions',
|
||||
@@ -145,7 +145,7 @@ module('Integration | Component | dropdown/index', function (hooks) {
|
||||
|
||||
await click(TOGGLE_DROPDOWN_SELECTOR);
|
||||
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 500 });
|
||||
assert.dom(ITEM_SELECTOR).exists({ count: 250 });
|
||||
|
||||
// Random number between 500 and 599 to search
|
||||
await fillIn(SEARCH_INPUT_SELECTOR, '591');
|
||||
|
||||
@@ -22,4 +22,14 @@ module('Integration | Component | filter-tags/index', function (hooks) {
|
||||
|
||||
assert.dom('.hds-tag__text').hasText('Project 1');
|
||||
});
|
||||
|
||||
test('it renders id if allFilter does not have corresponding value', async function (assert) {
|
||||
this.set('filter', {
|
||||
allFilters: { scopes: [{ id: '2', name: 'Project 2' }] },
|
||||
selectedFilters: { scopes: ['1'] },
|
||||
});
|
||||
await render(hbs`<FilterTags @filters={{this.filter}} />`);
|
||||
|
||||
assert.dom('.hds-tag__text').hasText('1');
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,9 @@ do-this-later: Do this later
|
||||
toggle-color-theme: Toggle Color Theme
|
||||
toggle-feature-edition: Toggle Feature Edition
|
||||
toggle-licensed-features: Toggle Licensed Features
|
||||
manage-db: Manage SQLite
|
||||
download-db: Download SQLite DB
|
||||
clear-db: Clear SQLite DB
|
||||
more: ', +{remainingItems} more'
|
||||
search: Search
|
||||
yes: Yes
|
||||
@@ -65,3 +68,4 @@ show-errors: Show Errors
|
||||
hide-errors: Hide Errors
|
||||
edit-worker-filter: Edit Worker Filter
|
||||
add-worker-filter: Add Worker Filter
|
||||
open: Open
|
||||
@@ -49,6 +49,7 @@ titles:
|
||||
project-actions: Project Actions
|
||||
user-menu: User Menu
|
||||
back-link: 'Back to {scope}'
|
||||
authenticated: Signed in as
|
||||
descriptions:
|
||||
empty-set: There are no items to display yet. You may be able to add items or try back later.
|
||||
cluster-url-initialization: To get started, please enter your cluster URL
|
||||
@@ -141,6 +142,21 @@ settings:
|
||||
alerts:
|
||||
cache-daemon: There may be a problem with the cache daemon
|
||||
client-agent: There may be a problem with the client agent
|
||||
preferred-clients:
|
||||
title: Preferred Clients
|
||||
description: Select the applications for Boundary to use when opening these target types.
|
||||
table:
|
||||
header:
|
||||
protocol: Protocol
|
||||
client: Client
|
||||
data:
|
||||
protocols:
|
||||
windows-rdp: Windows RDP
|
||||
clients:
|
||||
none: None
|
||||
mstsc: Remote Desktop Connection (mstsc)
|
||||
windows-app: Windows App
|
||||
none-detected: 'None detected. We recommend <a href={rdpClientLink} target="_blank" rel="noopener noreferrer">{rdpClientName}</a>.'
|
||||
worker-filter-generator:
|
||||
title: Filter generator
|
||||
description: Choose what you want to format into a filter.
|
||||
|
||||
@@ -27,3 +27,7 @@ onboarding-failed:
|
||||
required-field: This is a required field.
|
||||
client-agent-failed:
|
||||
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:
|
||||
label: Maximum Connections
|
||||
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:
|
||||
label: Session ID
|
||||
worker_release_version:
|
||||
|
||||
@@ -343,6 +343,12 @@ session:
|
||||
connected:
|
||||
title: Connected
|
||||
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:
|
||||
connect: Connect
|
||||
end: End session
|
||||
@@ -363,6 +369,7 @@ session:
|
||||
proxy-url:
|
||||
address-port: Address & port
|
||||
ssh: SSH
|
||||
rdp: RDP
|
||||
shell: Shell
|
||||
global:
|
||||
title: Global
|
||||
@@ -722,9 +729,11 @@ target:
|
||||
types:
|
||||
tcp: Generic TCP
|
||||
ssh: SSH
|
||||
rdp: RDP
|
||||
help:
|
||||
tcp: Generic TCP supports a broad range of connection types.
|
||||
ssh: Protocol-aware SSH with support for credential injection.
|
||||
rdp: Remote Desktop Protocol for Windows servers.
|
||||
filter:
|
||||
active-sessions:
|
||||
yes: Has active sessions
|
||||
@@ -742,6 +751,7 @@ target:
|
||||
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.
|
||||
rdp-windows-notice: 'Note: Windows OS prevents port 3389 from being used.'
|
||||
sidebar:
|
||||
label: Session Recording
|
||||
help: Record SSH sessions on this target and save them to a storage bucket.
|
||||
@@ -804,6 +814,10 @@ target:
|
||||
add:
|
||||
title: Add Injected Application Credential Sources
|
||||
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:
|
||||
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:
|
||||
@@ -898,10 +912,13 @@ credential-library:
|
||||
new: New Credential Library
|
||||
ssh_private_key: SSH Private Key
|
||||
username_password: Username & Password
|
||||
username_attribute: Username
|
||||
password_attribute: Password
|
||||
private_key_passphrase_attribute: Private Key Passphrase
|
||||
private_key_attribute: Private Key
|
||||
username_password_domain: Username, Password & Domain
|
||||
password: Password
|
||||
username_attribute: username_attribute
|
||||
password_attribute: password_attribute
|
||||
private_key_passphrase_attribute: private_key_passphrase_attribute
|
||||
private_key_attribute: private_key_attribute
|
||||
domain_attribute: domain_attribute
|
||||
actions:
|
||||
create: New Credential Library
|
||||
delete: Delete Credential Library
|
||||
@@ -916,6 +933,7 @@ credential-library:
|
||||
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.
|
||||
description: The type of credential the library uses for credential injection.
|
||||
credential_mapping_overrides:
|
||||
label: Credential mapping overrides
|
||||
help: Specify the credential fields to use instead of the defaults
|
||||
@@ -949,9 +967,11 @@ credential-library:
|
||||
types:
|
||||
vault-generic: Generic Secrets
|
||||
vault-ssh-certificate: SSH Certificates
|
||||
vault-ldap: LDAP
|
||||
help:
|
||||
vault-generic: Authorize sessions using credentials brokered by 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:
|
||||
title: Credential
|
||||
title_plural: Credentials
|
||||
@@ -968,12 +988,16 @@ credential:
|
||||
types:
|
||||
username_password: Username & Password
|
||||
ssh_private_key: Username & Key Pair
|
||||
username_password_domain: Username, Password & Domain
|
||||
json: JSON
|
||||
password: Password
|
||||
unknown: Unknown
|
||||
help:
|
||||
username_password: Connect using the provided username and password.
|
||||
ssh_private_key: Connect using a username, public key, and private key.
|
||||
json: Connect using a JSON blob containing the credentials.
|
||||
username_password_domain: Includes a domain field for Active Directory.
|
||||
password: Connect using only a provided password.
|
||||
form:
|
||||
username:
|
||||
label: Username
|
||||
@@ -981,6 +1005,9 @@ credential:
|
||||
password:
|
||||
label: Password
|
||||
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:
|
||||
label: SSH Private Key
|
||||
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
|
||||
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:
|
||||
label: Host ID
|
||||
help: Always use this host when connecting using this alias
|
||||
|
||||
@@ -26,6 +26,7 @@ module.exports = {
|
||||
browser: true,
|
||||
},
|
||||
rules: {
|
||||
curly: ['error', 'multi-line', 'consistent'],
|
||||
'ember/no-jquery': 'error',
|
||||
'ember/no-new-mixins': 0,
|
||||
'ember/no-get': 'off',
|
||||
|
||||
@@ -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 {
|
||||
color: var(--token-color-foreground-strong);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This is a temporary hack to fix an issue causing weird whitespace
|
||||
// and scrollbars from an accessible class in HDS. We have to use !important
|
||||
// as they already forced it with !important and we can't override it otherwise
|
||||
.sr-only {
|
||||
position: unset !important;
|
||||
}
|
||||
// TODO: This is a temporary hack to fix an issue causing weird whitespace
|
||||
// and scrollbars from an accessible class in HDS. We have to use !important
|
||||
// as they already forced it with !important and we can't override it otherwise
|
||||
.sr-only {
|
||||
position: unset !important;
|
||||
}
|
||||
|
||||
.hds-dropdown {
|
||||
|
||||
@@ -121,14 +121,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@mixin dark-mode-ember-dropdown-override {
|
||||
.ember-basic-dropdown-content {
|
||||
background-color: var(--token-color-palette-neutral-0);
|
||||
|
||||
/* stylelint-disable-next-line selector-class-pattern */
|
||||
&.ember-basic-dropdown-content--in-place {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ember-application:not(.rose-theme-light) {
|
||||
@include dark-mode;
|
||||
@include dark-mode-modal-override;
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
}
|
||||
}
|
||||
|
||||
.ember-application.rose-theme-dark {
|
||||
@include dark-mode;
|
||||
@include dark-mode-modal-override;
|
||||
@include dark-mode-ember-dropdown-override;
|
||||
}
|
||||
@@ -14,10 +14,21 @@ html {
|
||||
body {
|
||||
// Used by Rose::Tabs and '/' in breadcrumbs
|
||||
// and link list panel
|
||||
font-family: typography.$family-primary;
|
||||
font-family: var(--token-typography-font-stack-text);
|
||||
|
||||
// these 3 are used across the app
|
||||
// this is used across the app
|
||||
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);
|
||||
background-color: var(--subtle);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-family: var(--token-typography-font-stack-code);
|
||||
}
|
||||
|
||||
.rose-autosize {
|
||||
|
||||
@@ -28,9 +28,10 @@
|
||||
|
||||
.rose-form-checkbox-label {
|
||||
// label styles
|
||||
@include type.type(s, semibold);
|
||||
@include type.type(s);
|
||||
|
||||
color: currentcolor;
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
|
||||
&,
|
||||
.rose-form-checkbox-label-description {
|
||||
@@ -53,7 +54,7 @@
|
||||
pointer-events: none;
|
||||
content: ' ';
|
||||
border: sizing.rems(xxxxs) solid var(--ui-gray-subtler-2);
|
||||
border-radius: sizing.rems(xxxs);
|
||||
border-radius: var(--token-form-checkbox-border-radius);
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
margin-bottom: sizing.rems(m);
|
||||
|
||||
.rose-form-fieldset-title {
|
||||
@include type.type(s, semibold);
|
||||
@include type.type(s);
|
||||
|
||||
font-weight: var(--token-typography-font-weight-semibold);
|
||||
}
|
||||
|
||||
.rose-form-fieldset-body {
|
||||
|
||||
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