Compare commits

...
155 Commits
Author SHA1 Message Date
hashicc cd3fc5cea7 fix: 🐛 apply .sr-only fix to all instances 2025-12-11 10:08:17 -08:00
Dharini 719bab7358 chore: 🤖 Desktop client changelog and cli version update (#3087) 2025-11-21 13:29:01 -08:00
Zhihe Li 95f72e1248 chore: 🤖 Update playwright dependency (#3085)
* chore: 🤖 Update playwright dependency

* chore: 🤖 Update openapi generator
2025-11-20 12:22:16 -05:00
hashicc f9a9f677ca chore: 🤖 use statically defined credential store components (#3071)
* chore: 🤖 use statically defined cred store components

* Add missing copyright headers
2025-11-20 10:19:46 -05:00
Cameron Perera dd0c5df25c chore: 🤖 update tmp package to 0.2.5 (#3026)
* chore: 🤖 update tmp package to 0.2.5

* chore: 🤖 add tmp@0.2.5 override
2025-11-19 17:49:45 -06:00
Priya Patel 4fc3244049 refactor: 💡 update logic to get preferred rdp client (#3066)
* refactor: 💡 updated logic to store preferred rdp client

* refactor: 💡 simplified preferred client logic
2025-11-19 14:22:07 -08:00
Priya Patel 5faf830d46 refactor: 💡 updated magic strings with const (#3064) 2025-11-19 14:22:07 -08:00
Priya Patel d2cf82b85c e2e Rdp client tests (#3055)
* test: 💍 e2e test for rdp client launch

* test: 💍 added rdp test helper

* refactor: 💡 misc

* refactor: 💡 addressed comments

* refactor: 💡 removed similar test
2025-11-19 14:22:07 -08:00
Priya Patel fb0020c395 feat: 🎸 Add RDP client launch on session details page (#3037)
* feat: 🎸 preferred clients section on settings page

* refactor: 💡 moved rdp calls to app route

* test: 💍 fixed failings tests

* refactor: 💡 addressed comments

* fix: 🐛 fixed failing tests

* refactor: 💡 add mock rdp service calls to tests

* feat: 🎸 adds open button to session details page

* fix: 🐛 translation string

* refactor: 💡 address feedback

* refactor: 💡 address comment

* fix: 🐛 test failure
2025-11-19 14:22:07 -08:00
Priya Patel e55ea0b629 feat: 🎸 preferred clients section on settings page (#3034)
* feat: 🎸 preferred clients section on settings page

* chore: 🤖 update mock ipc with new handlers

* refactor: 💡 moved windows rdp protocol to const

* refactor: 💡 moved rdp calls to app route

* test: 💍 fixed failings tests

* refactor: 💡 addressed comments

* fix: 🐛 fixed failing tests

* refactor: 💡 add mock rdp service calls to tests

* refactor: 💡 addressed comments

* test: 💍 add additional tests

* refactor: 💡 fixed test failure
2025-11-19 14:22:06 -08:00
Dharini 9234dbae70 Icu 17881 UI desktop add rdp launch button in target detail view (#3038) 2025-11-19 14:22:06 -08:00
Dharini 27136eaa71 feat: Add launch functionality in targets list view (#3035) 2025-11-19 14:22:06 -08:00
Dharini 6554af1164 chore: 🤖 add ipc handlers for rdp (#3031) 2025-11-19 14:22:06 -08:00
Priya Patel 437d0e686d Create new RDP service (#3029)
* feat: 🎸 added a new RDP service

* fix: 🐛 rdp test

* refactor: 💡 update error handling for fetching rdp clients

* refactor: 💡 addressed comments

* fix: 🐛 revert private field

* refactor: 💡 addressed comments

* refactor: 💡 updated rdp clients to array of strings
2025-11-19 14:22:06 -08:00
Michael Li 25b801826d chore(e2e): Fix slack message (#3079) 2025-11-19 16:52:14 -05:00
Michael Li 123818dc70 chore(e2e): Update workflow triggers for e2e tests (#3077)
* chore(e2e): Update workflow triggers for e2e tests

* CR
2025-11-18 18:27:30 -05:00
Michael Li dafcd046d7 fix(e2e): Improve locator to avoid finding multiple elements (#3076) 2025-11-18 17:00:45 -05:00
Lisbet Alvarez accf9faf60 refactor: 💡 update credential-library form for password type (#3060)
* refactor: 💡 update credential-library form for password type

* style: 💄 fix display issue

* test: 💍 add test cases
2025-11-18 11:20:49 -08:00
Lisbet Alvarez 6bb237e78c Add password credential form (#3054)
* feat: 🎸 add password credential form

* test: 💍 add a11y ignores

* test: 💍 fix e2e tests

* test: 💍 add password cred type to targets brokering tests

* chore: 🤖 remove comment
2025-11-18 11:20:49 -08:00
Lisbet Alvarez bbb5808875 chore: 🤖 clean-up json-credentials feature flag (#3049) 2025-11-18 11:20:49 -08:00
Lisbet Alvarez 905a8f1951 Refactor cred type radio field (#3046)
* refactor: 💡 credential type radio field to be select field

* refactor: 💡 radio component to be select component

* test: 💍 fix e2e test page

* test: 💍 add correct selector to e2e test

* test: 💍 fix
2025-11-18 11:20:49 -08:00
Lisbet Alvarez f39961bbf5 Add password type to models and mirage (#3039)
* feat: 🎸 add new password type to cred models + mirage

* test: 💍 fix

* refactor: 💡 update target mirage trait

* chore: 🤖 add missing attr type
2025-11-18 11:20:49 -08:00
Michael Li 2bf09011fb fix(e2e): Fix LDAP test when org name contains the word "New" (#3074)
* fix(e2e): Wait for auth methods page to load

* fix(e2e): Make search for New button exact
2025-11-18 08:35:46 -05:00
Zhihe Li 013aa2e570 feat: 🎸 Refactored sessions page filters to load separately (#3053) 2025-11-17 17:06:47 -05:00
Michael Li 953e35bcd6 chore(e2e): Add slack message on e2e test failure (#3070)
* chore(e2e): Add slack message on test failure

* chore(e2e): Remove deprecated variable

* CR: only run in boundary-ui
2025-11-17 15:34:13 -05:00
hashicc e138b06fd3 chore: 🤖 remove leftover .yarn file (#3068) 2025-11-17 13:43:56 -05:00
hashicc 2ef6b0549d chore: 🤖 hoist esbuild to consolidate ember-source dependencies (#3052)
* chore: 🤖 hoist esbuild to fix dependency resolutions

* chore: 🤖 add back esbuild as an explicit dep for api addon

This addresses an eslint error but also is more explicit
with the api addon using esbuild directly
2025-11-13 13:32:08 -05:00
hashicc 1c49303432 chore: 🤖 update electron to avoid potential macos tahoe issue (#3024)
Electron has an issue being reported with latest macos tahoe:
https://github.com/electron/electron/issues/48311

This has been fixed in:
38.2.0
37.6.0
36.9.2

We are currently on 38.0.0 so this commit bumps electron to 38.2.0
2025-11-13 08:38:57 -05:00
hashicc f31b1e975d chore: 🤖 bump hds dependency (#3059) 2025-11-12 16:43:38 -05:00
Lisbet Alvarez 48d0087141 Unload expired sessions from store (#3033)
* fix: 🐛 unload expired sessions from ember data store

* fix: 🐛 add unloadAll & include terminated to admin ui

* test: 💍 update tests

* refactor: 💡 pr feedback for admin ui

* refactor: 💡 remove unneeded query from desktop ui

* refactor: 💡 check pushToStore var

* Revert "refactor: 💡 remove unneeded query from desktop ui"

* fix: 🐛 fix proxy details being removed after unload

* refactor: 💡 remove log
2025-11-11 11:16:12 -08:00
Zhihe Li 58cc9e56fc fix: 🐛 Fix filters sometimes not being displayed (#3050)
One of the changes that was made was not retrieving all resources for the dropdown filters. 
This had an unintended consequence when displaying the selected filters as it's possible for the selected filter to not be in the list of allFilters.

There's two main scenarios this happens (the prerequisite is having more filter options than we display which is 250):

* When filtering down the dropdown filters which means it might not be in the allFilters options. This was fixed by keeping track of all the searched options to be able to display.
* A user uses a query string or bookmarked a URL and goes to it directly. If the option isn't in the initial load, we won't have the actual option. In this case, I changed it to at least just display the ID instead of not showing up at all. Otherwise I'd have to load these selected options separately from the initial load which doesn't seem worth for this edge case.
2025-11-11 11:57:04 -05:00
Dylan HyunandZhihe Li 189a453e8b style: 💄 align typography and border styles to HDS tokens (#3051)
* Feat: Convert font-family styles to HDS tokens

* Feat: Convert font-size styles to HDS tokens

* Feat: Convert font-weight styles to HDS tokens

* Feat: Convert border-radius styles to HDS tokens

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-11-07 12:32:54 -05:00
Lisbet Alvarez 6fca90747c chore: 🤖 Add ignores for new violations (#3047) 2025-11-06 13:23:33 -08:00
Zhihe Li 92f5a65c00 feat: 🎸 Add ability to search other resources as well (#3021)
This commit adds the ability to search across multiple tables with joins which unlocks a better search experience in the case of tables that source their data from multiple tables.
2025-11-05 17:57:45 -05:00
Priya Patel d8744e81bc refactor: 💡 removed vault_ldap_feature flag (#3045) 2025-11-05 14:03:09 -06:00
dependabot[bot]andLisbet Alvarez 136baf8260 chore(deps): bump pnpm/action-setup from 4.1.0 to 4.2.0 (#3044)
Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/a7487c7e89a18df4991f7f222e4898a00d66ddda...41ff72655975bd51cab0327fa583b6e92b6d3061)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-11-04 13:35:58 -08:00
dependabot[bot]andLisbet Alvarez ef47c163d0 chore(deps): bump actions/upload-artifact from 4.6.2 to 5.0.0 (#3043)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-11-04 13:23:43 -08:00
dependabot[bot]andLisbet Alvarez 39e5e3406b chore(deps): bump stefanzweifel/git-auto-commit-action (#3042)
Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 6.0.1 to 7.0.0.
- [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases)
- [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/778341af668090896ca464160c2def5d1d1a3eb0...28e16e81777b558cc906c8750092100bbb34c5e3)

---
updated-dependencies:
- dependency-name: stefanzweifel/git-auto-commit-action
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-11-04 13:06:57 -08:00
dependabot[bot]andLisbet Alvarez 91d8d61afb chore(deps): bump hashicorp/action-setup-enos from 1.37 to 1.38 (#3041)
Bumps [hashicorp/action-setup-enos](https://github.com/hashicorp/action-setup-enos) from 1.37 to 1.38.
- [Commits](https://github.com/hashicorp/action-setup-enos/compare/v1.37...39a8f388434bea5eeba6649b99aa9949a55a1150)

---
updated-dependencies:
- dependency-name: hashicorp/action-setup-enos
  dependency-version: '1.38'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-11-04 12:41:38 -08:00
dependabot[bot]andLisbet Alvarez b1d9153efe chore(deps): bump actions/setup-node from 4.4.0 to 6.0.0 (#3040)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.4.0...2028fbc5c25fe9cf00d9f06a71cc4710d4507903)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-11-04 12:14:10 -08:00
Zhihe Li fda1f56ad1 feat: 🎸 Add support for joins with filters (#3010)
This commit adds support for being able to join to other tables when filtering.
Support was also added for being able to find null values.
2025-11-03 17:28:11 -05:00
Zhihe Li fa18bfff01 feat: 🎸 Refactor session recording filters (#2981)
This commit has a few notable changes:

1. A simple attempt at establishing a pattern for using SELECT statements. This will be useful in the future for joins/subquery support.
2. Expanded usage of searching to be able to pass instead of just a string, an object that lets a consumer choose which columns to search on.
3. Decouple our session recording filters from being loaded from the route model hook which is a departure from our convention. This has the benefit of actually letting the page first load and having the filters load async so the page doesn't wait for the filters to finish. This is also much faster to query for each individual filter option from SQLite rather than returning all data to filter in application code.
2025-10-31 16:58:05 -04:00
Lisbet Alvarez 72f7c8a92d fix: 🐛 OIDC authentications not properly showing username (#2930)
* fix: 🐛 properly show username for oidc auth method in user menu

* test: 💍 fix admin ui tests

* test: 💍 fix desktop ui tests

* refactor: 💡 admin to not store username in authenticated object

* refactor: 💡 use getter in app controller

* test: 💍 POC for how we could update our tests to use auth account

* refactor: 💡 remove unused service

* test: 💍 fix all tests in ui/admin

* test: 💍 revert one change

* refactor: 💡 update ui/desktop w/ feedback + fix tests

* refactor: 💡 add in error handling

* refactor: 💡 cleanup unused service

* test: 💍 fix e2e test selector

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17231
2025-10-30 12:49:30 -07:00
Zhihe Li 838bc37243 fix: 🐛 Fix sanitizing of tokens when logging (#3036) 2025-10-29 17:05:22 -04:00
hashicc 866972c6c6 chore: 🤖 create patched ember-loading for modern e-c (#3015) 2025-10-28 10:04:32 -04:00
dillanb-hashi b19d76f249 chore: 🤖 Added github action to run e2e ui tests (#3025)
* chore: 🤖 Added github action to run e2e ui tests
2025-10-21 09:18:05 -07:00
Zhihe Li 35ccaeba56 style: 💄 Update if curly brace statements (#3023) 2025-10-13 10:53:21 -04:00
hashicc bf134072e5 refactor: 💡 migrate away from component helper for auth-method (#3011)
* refactor: 💡 migrate away from component helper for auth-method
component

* Add missing copyright headers
2025-10-08 12:53:19 -04:00
hashicc 0bd152e0b5 Migrate away from component helper in credential library form component (#3013)
* refactor: 💡 migrate away from component helper for cred library form

* Add missing copyright headers
2025-10-07 13:40:30 -04:00
dependabot[bot] 928ca5409c chore(deps): bump hashicorp/action-setup-enos from 1.37 to 1.38 (#3016)
Bumps [hashicorp/action-setup-enos](https://github.com/hashicorp/action-setup-enos) from 1.37 to 1.38.
- [Commits](https://github.com/hashicorp/action-setup-enos/compare/b9fa53484a1e8fdcc7b02a118bcf01d65b9414c9...39a8f388434bea5eeba6649b99aa9949a55a1150)

---
updated-dependencies:
- dependency-name: hashicorp/action-setup-enos
  dependency-version: '1.38'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-07 10:16:55 -04:00
dependabot[bot] 0f846eab2c chore(deps): bump actions/setup-node from 4.4.0 to 5.0.0 (#3017)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.4.0 to 5.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/49933ea5288caeca8642d1e84afbd3f7d6820020...a0853c24544627f65ddf259abe73b1d18a591444)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-06 21:25:12 -04:00
dependabot[bot]andLisbet Alvarez 14ed74dd3d chore(deps): bump actions/labeler from 5.0.0 to 6.0.1 (#3018)
Bumps [actions/labeler](https://github.com/actions/labeler) from 5.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/8558fd74291d67161a8a78ce36a881fa63b766a9...634933edcd8ababfe52f92936142cc22ac488b1b)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Lisbet Alvarez <107949262+lisbet-alvarez@users.noreply.github.com>
2025-10-06 13:34:51 -07:00
Lisbet Alvarez 701d19bd18 chore: 🤖 resolve on-headers dependabot alert (#3020) 2025-10-06 13:17:50 -07:00
hashicc 240a544d82 refactor: 💡 remove dynamic component helper from form/authenticate (#3012)
* refactor: 💡 remove dynamic component helper from form/authenticate

* Add missing copyright headers
2025-10-06 15:09:42 -04:00
Priya Patel b4f3741979 fix: 🐛 Update vault-ldap cred library (#3000)
* fix: 🐛 remove UPD cred type from vault-ldap

* fix: 🐛 failing tests

* fix: 🐛 remove log
2025-10-02 12:08:25 -05:00
dependabot[bot]andZhihe Li 396bf0a43f chore(deps): bump browser-actions/setup-chrome from 1.7.3 to 2.1.0 (#2982)
Bumps [browser-actions/setup-chrome](https://github.com/browser-actions/setup-chrome) from 1.7.3 to 2.1.0.
- [Release notes](https://github.com/browser-actions/setup-chrome/releases)
- [Changelog](https://github.com/browser-actions/setup-chrome/blob/master/CHANGELOG.md)
- [Commits](https://github.com/browser-actions/setup-chrome/compare/v1.7.3...b94431e051d1c52dcbe9a7092a4f10f827795416)

---
updated-dependencies:
- dependency-name: browser-actions/setup-chrome
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-10-01 11:01:50 -04:00
dependabot[bot] 3225ccda10 chore(deps): bump actions/checkout from 4.2.2 to 5.0.0 (#2983)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/11bd71901bbe5b1630ceea73d27597364c9af683...08c6903cd8c0fde910a37f88322edcfb5dd907a8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-01 10:49:55 -04:00
Michael Li ad2c1ba2ad chore(e2e): Update desktop e2e test workflow to use new environment variable (#3014)
* chore(e2e): Update to use boundary license env

* chore(e2e): Allow runs if workflow_dispatch
2025-10-01 09:06:36 -04:00
dillanb-hashi ea591df0bb test: 💍 Refactor target functions and fix failing test (#2999)
* test: 💍 Fixed broken test 

* test: 💍 Refactoring to consolidate target function names

* test: 💍 fixed failing tests with capitalization issue
2025-09-30 09:35:26 -07:00
Zhihe Li 72639bd6a3 fix: 🐛 Pass in session time out when creating session (#3008)
* fix: 🐛 Pass in session time out when creating session

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17701
2025-09-22 16:31:44 -04:00
Zhihe Li 31f0044c0f fix: 🐛 Fix schema for sqlite inserts (#3006)
* fix: 🐛 Fix schema for sqlite inserts

* fix: 🐛 Also fix delete trigger not working with REPLACE INTO
2025-09-18 10:16:01 -04:00
Lisbet Alvarez d0924f7e6d fix: 🐛 max depth error for active sessions filter in targets (#3004)
* fix: 🐛 max depth error for active sessions filter in targets

* refactor: 💡 revert route and convert operators in parser

* refactor: 💡 add expr limit

* refactor: 💡 add parameters to IN/NOT IN conditions

* test: 💍 fix tests

* docs: ✏️ add comment
2025-09-17 15:32:39 -07:00
Zhihe Li d2616acf36 fix: 🐛 Pass the addr to CLI when canceling sessions (#3007) 2025-09-17 18:15:39 -04:00
Zhihe Li dfd116bd56 chore: 🤖 Update dependency for security alert (#3005) 2025-09-17 16:59:35 -04:00
Michael Li 830acef847 test(e2e): Make role creation sequential (#3003)
When running this test against an AWS cluster, this operation was resulting in 504s. This was potentially hitting some AWS rate limits when making these requests concurrently.
2025-09-16 17:50:11 -04:00
Zhihe Li ee7c916494 fix: 🐛 Fix sessions not properly getting cleaned up (#3002)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17645
2025-09-15 18:11:43 -04:00
hashicc 46d477ea5b Refresh target active sessions for admin and desktop (#3001)
* fix: 🐛 refresh sessions for targets list on admin and desktop

Refresh the sessions for the targets list pages on admin and desktop so
that the active sessions column is up to date

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646

* test: 💍 add test for targets list refreshing sessions on admin

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646

* refactor: 💡 allow ipcStub to return current mirage data

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646

* test: 💍 ensure that desktop targets sessions are refreshed

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646

* test: 💍 update existing tests affected by sessions refresh ipc

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646

* refactor: 💡 remove force_refresh from sessions sync

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17646
2025-09-15 12:53:17 -04:00
hashicc 177b4af695 refactor: 💡 rdp default client port and max connections labels (#2998) 2025-09-12 14:41:46 -07:00
Zhihe Li d2b8805605 fix: 🐛 Fix usage of top level await in worker which fails build (#2997) 2025-09-10 16:44:24 -04:00
Zhihe Li 39dedc68ec fix: 🐛 Add better check for determining if we can use OPFS (#2996) 2025-09-10 11:35:02 -04:00
hashicc bd67756cda fix: 🐛 fix bug with the apiClientTestAfter noop fixture hanging and update e2e label username, password locators with required text (#2994)
* fix: 🐛 fix bug with the e2e test fixture hanging

* test: 💍 update cred store label locators with exact match

* test: 💍 update createTargetWithAddressAndAlias with target type
2025-09-10 10:14:55 -04:00
Dharini dd37fa4270 fix: 🐛 var name (#2970) 2025-09-09 15:27:35 -07:00
hashicc 3c5c048152 docs: ✏️ update alias value helper text (#2995) 2025-09-09 16:49:11 -04:00
hashicc dd6e4ef2d0 chore: 🤖 update desktop client changelog & bundled cli version (#2992)
* chore: 🤖 update cli version bundled with desktop client

* docs: ✏️ update changelog
2025-09-08 11:47:27 -04:00
Zhihe Li bce79f3488 fix: 🐛 Add a better db check (#2993) 2025-09-08 10:42:39 -04:00
Zhihe Li 74793a2399 chore: 🤖 Update form-data dependency (#2991) 2025-09-05 14:00:03 -04:00
Zhihe Li 6b120b053a chore: 🤖 Update electron dependency (#2989) 2025-09-04 16:49:50 -04:00
Dharini 1d0d62337e fix: 🐛 a11y test failures (#2988) 2025-09-04 10:30:44 -07:00
Dharini df8bfc42bb chore: 🤖 vault ldap feature flag 2025-09-03 17:02:25 -07:00
Dharini ff087738fc fix: 🐛 parser for handling username with domain 2025-09-03 17:02:05 -07:00
Dharini 27567c88c9 fix: 🐛 a11y tests 2025-09-03 17:01:43 -07:00
Priya Patel 9077a747d6 refactor: 💡 change default target type to tcp 2025-09-03 17:01:27 -07:00
Dharini b359d9f525 fix: 🐛 remove optional tags 2025-09-03 17:01:04 -07:00
Dharini ad7bd2e52d fix: 🐛 add required tags 2025-09-03 17:00:43 -07:00
Priya Patel 36aed9588b refactor: 💡 remove dropdown for rdp type 2025-09-03 17:00:22 -07:00
Dharini f8c81a29c4 chore: 🤖 vault-ldap target injection 2025-09-03 16:59:48 -07:00
Priya Patel c91771e152 feat: 🎸 vault ldap CRUDL support 2025-09-03 16:59:20 -07:00
Priya Patel 3b7873302b feat: 🎸 vault ldap cred 2025-09-03 16:08:00 -07:00
Dharini d79bd54f0b fix: 🐛 remove redudant cert warning 2025-09-03 16:06:40 -07:00
Priya Patel 51dc9a0e49 chore: 🤖 update brokered cred tests 2025-09-03 16:06:07 -07:00
Dharini b49e55d400 test: 💍 refactor e2e 2025-09-03 16:05:42 -07:00
Priya Patel 33cb7e0a50 test: 💍 acceptance tests for rdp target brokered creds 2025-09-03 16:05:21 -07:00
Dharini 9edc90306e test: 💍 e2e tests 2025-09-03 16:04:50 -07:00
Dharini 0a75e05b28 feat: 🎸 display toast notifications for rdp sessions 2025-09-03 16:04:17 -07:00
Priya Patel 4a179ac3e3 chore: 🤖 rdp desktop changes 2025-09-03 16:03:48 -07:00
Dharini 42e32f9568 chore: 🤖 filter rdp credentials 2025-09-03 16:02:59 -07:00
Priya Patel 0223755b2a feat: 🎸 rdp target form- read, list and delete 2025-09-03 16:02:00 -07:00
Priya Patel 30a5f2a701 feat: 🎸 rdp form - create and update 2025-09-03 16:01:25 -07:00
Dharini Jeeva d2d351c9f7 chore: 🤖 add rdp target type 2025-09-03 16:00:12 -07:00
Dharini Jeeva e1696e9a4d feat: 🎸 add support for upd credential 2025-09-03 15:59:29 -07:00
Zhihe Li 25f015b179 chore: 🤖 Remove timer console statements (#2985) 2025-09-03 11:41:43 -04:00
Zhihe Li 9e263379a7 refactor: 💡 Remove usage of indexed DB (#2979)
* refactor: 💡 Remove usage of indexed DB
2025-09-03 11:41:43 -04:00
Lisbet Alvarez 4c7ea161cd feat: 🎸 Add sqlite support to sessions (#2978)
* feat: 🎸 Add sqlite support to sessions

* refactor: 💡 only peek sqlite db when batching queries

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17411
2025-09-03 11:41:42 -04:00
Zhihe Li dc026c44a1 feat: 🎸 Handle corrupted sqlite DB (#2976)
* feat: 🎸 Handle corrupted sqlite DB
2025-09-03 11:41:42 -04:00
Zhihe Li cb8b21c10c feat: 🎸 Add sqlite support for session recordings (#2974)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17419
2025-09-03 11:41:42 -04:00
Lisbet Alvarez 47c1beadcd feat: 🎸 Add sqlite support to host-catalogs (#2973)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17417
2025-09-03 11:41:42 -04:00
Lisbet Alvarez 8f7eb2abea feat: 🎸 add fts tables to groups (#2971)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17413
2025-09-03 11:41:42 -04:00
Zhihe Li c2b0f321de feat: 🎸 Add sqlite support for auth methods (#2969)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17418
2025-09-03 11:41:42 -04:00
Lisbet Alvarez ba7b8b8824 feat: 🎸 add sqlite support for scopes (#2968)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17415
2025-09-03 11:41:42 -04:00
Zhihe Li 9e612cf83d feat: 🎸 Add sqlite support for credential stores (#2967)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17416
2025-09-03 11:41:42 -04:00
Lisbet Alvarez 2154251c30 feat: 🎸 Add sqlite support for users (#2966)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17412
2025-09-03 11:41:42 -04:00
Zhihe Li 2d38266609 feat: 🎸 Add sqlite support for roles (#2965)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17414
2025-09-03 11:41:41 -04:00
Lisbet AlvarezandZhihe Li d414df4ca4 feat: 🎸 Add custom sort support to sql parser (#2959)
* feat: 🎸 Add custom sort support to sql parser

* test: 💍 fix column numner

* refactor: 💡 apply pr feedback

* fix: 🐛 use every method for checking attrs

* refactor: 💡 PR feedback + ensure default sort

* refactor: 💡 Consolidate attributes field when sorting

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17441

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-09-03 11:41:41 -04:00
Zhihe Li eab93cf6c5 Icu 17516 use in memory sqlite in non secure contexts (#2958)
* feat: 🎸 Add checks to see if sqlite is in secure environment

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17516
2025-09-03 11:41:41 -04:00
Zhihe Li 3304def44a feat: 🎸 Add sqlite support for aliases (#2957)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17420
2025-09-03 11:41:41 -04:00
Zhihe Li a4bf02b21b refactor: 💡 Change variable name for peeking db (#2954) 2025-09-03 11:41:41 -04:00
Zhihe Li e0982da744 feat: 🎸 Add sqlite handler and parser (#2933)
* feat: 🎸 Add sqlite handler and parser

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17410
2025-09-03 11:41:41 -04:00
Zhihe Li 8abed1a809 feat: 🎸 Add a shared service between web workers (#2932)
* feat: 🎸 Add a shared service between web workers

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17409
2025-09-03 11:41:40 -04:00
Zhihe Li a9a6518b23 feat: 🎸 Allow building and bundling of web workers in ember (#2923)
* feat: 🎸 Allow building and bundling of web workers in ember

* refactor: 💡 Cleanup code and add comments
2025-09-03 11:41:40 -04:00
Michael Li 1bafbe1ad2 test(e2e): Address some Admin UI test flakiness (#2934)
* fix(e2e): Address flakiness during screen navigation

* fix(e2e): Fix function call

* fix(e2e): Address flakiness in connection

* fix(e2e): Make locator explicit to avoid conflict when an ID has `ok`

* fix(e2e): Fix URL now that project page defaults to showing targets
2025-09-03 11:28:33 -04:00
Lisbet Alvarez a26074a3b8 fix: 🐛 host header renders multiple times (#2980)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17584
2025-09-02 16:28:53 -07:00
Zhihe Li 90fd2f1447 fix: 🐛 Fix bug that prevents setting cluster URL (#2975)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17579
2025-08-28 12:27:12 -04:00
Priya Patel e59690e703 chore: 🤖 update ember-feature-flags and tracked-built-ins (#2903)
* chore: 🤖 update ember-feature-flags and tracked-built-ins

* chore: 🤖 update glimmer/component@2.0.0 for ember-feature-flag

* chore: 🤖 test ember-feature-flag update

* chore: 🤖 test ember-feature-flag@6.0.0

* refactor: 💡 undo test commit changes
2025-08-20 13:37:58 -05:00
cameronperera bf06e9f654 test: 💍 update known a11y violations 2025-08-19 10:51:44 -05:00
cameronperera 84359d8e79 docs: ✏️ fix flag name and a11y references 2025-08-19 10:51:44 -05:00
cameronperera 7801429530 refactor: 💡 remove body reference from typography style 2025-08-19 10:51:44 -05:00
hashicc dc9376a460 (HACK WEEK 2025) a11y-audit-ignore codemod (#2946)
* chore: 🤖 add ember-a11y-ignore tool

* chore: 🤖 fix handling existing setRunOptions imports

* chore: 🤖 move codemod to support folder

* chore: 🤖 fix edge case for handling property key names

* chore: 🤖 handle case where multiple of same violation in test

This fixes a bug where multiple cases of the same violation would end up
with multiple entries

* chore: 🤖 ignore axe rules that are currently failing in admin

* chore: 🤖 ignore axe rules that are currently failing in desktop

* docs: ✏️ add a comment explaining the codemod

* chore: 🤖 include test.each cases for a11y ignores

* fix: 🐛 handle test.each test cases for a11y ignore codemod
2025-08-19 10:51:44 -05:00
hashicc 1f4076d8d4 (HACK WEEK 2025) Upload a11y reports in ci (#2949)
* ci: 🎡 consolidate ember a11y reports and upload as gh artifact

* ci: 🎡 Bump test timeout for a11y tests

* chore: 🤖 run a11y tests in parallel

* chore: 🤖 only run a11y audit against acceptance tests

* chore: 🤖 restore ember test in place of ember exam

testing whether or not ember test hangs with three tests timing out like
ember exam does

* ci: 🎡 output a11y reports filtered down in ci

* ci: 🎡 attempt to fix shell exit code for a11y test failures

* ci: 🎡 add step names for outputting a11y test report summary
2025-08-19 10:51:44 -05:00
Cameron Perera f202d0f533 (HACK WEEK 2025) docs: ✏️ add a11y testing info to READMEs (#2945)
* docs: ✏️ add a11y testing info to READMEs

* chore: 🤖 remove light theme setup for acceptance tests in admin

* docs: ✏️ add link to test-helper.js in README

* chore: 🤖 update PR template for a11y-tests

* test: 💍 fix color theme switching tests
2025-08-19 10:51:44 -05:00
hashicc 4344dcbb36 Fix a11y-tests label (#2947) 2025-08-19 10:51:44 -05:00
Cameron Perera 87d6bbae2a (HACK WEEK 2025) chore: 🤖 add new gh workflow for a11y tests (#2942)
* chore: 🤖 add new gh workflow for a11y tests

* chore: 🤖 remove a11y dark tests job

* chore: 🤖 increase a11y tests timeout
2025-08-19 10:51:44 -05:00
Cameron Perera 7a42bf456e (HACK WEEK 2025) Dark mode a11y audit rules (#2939)
* chore: 🤖 change axe runOptions based on color theme

* fix: 🐛 fix background-color and color styles application
2025-08-19 10:51:44 -05:00
hashicc a51f1eee8e (HACK WEEK 2025) a11y desktop test helper (#2938)
* chore: 🤖 run ember-a11y-testing conditionally in desktop

* chore: 🤖 control launched testem chrome theme via COLOR_THEME

* chore: 🤖 add package.json scripts for desktop a11y tests

* chore: 🤖 gitignore a11y report

* chore: 🤖 update desktop package.json test command

Explicitly call test and finalize separately, previously "concurrently"
would pick up the finalize task

* chore: 🤖 remove unneeded console.logs
2025-08-19 10:51:44 -05:00
Carlos Alcaide CorvoandLisbet Alvarez 5b72a916e4 (HACK WEEK 2025) A11y admin test helper (#2937)
* chore: 🤖 add a11y audit test helper config

* chore: 🤖 remove report files

* refactor: 💡 remove console.log

* Apply PR feedback

* Delete the flag --server when running a11y tests

---------

Co-authored-by: Lisbet Alvarez <lisbet.alvarez@hashicorp.com>
2025-08-19 10:51:44 -05:00
Cameron Pereraandcalcaide 42013a919f (HACK WEEK 2025) refactor: 💡 remove explicit a11yAudit usage (#2936)
* refactor: 💡 remove explicit usage of a11yAudit

* Delete a11yAudit method from readme

---------

Co-authored-by: calcaide <ccorvo@hashicorp.com>
2025-08-19 10:51:44 -05:00
Cameron Perera 3918f82e14 chore: 🤖 add DARK_MODE env variable to testem config (#2935)
for admin and desktop
2025-08-19 10:51:44 -05:00
Carlos Alcaide Corvo 6e79a2a133 Delete development environment variables for the appUpdater (#2961) 2025-08-15 14:12:08 -07:00
dependabot[bot]andZhihe Li 088deaed3a chore(deps): bump tmp from 0.2.3 to 0.2.5 in /ui/desktop/electron-app (#2962)
Bumps [tmp](https://github.com/raszi/node-tmp) from 0.2.3 to 0.2.5.
- [Changelog](https://github.com/raszi/node-tmp/blob/master/CHANGELOG.md)
- [Commits](https://github.com/raszi/node-tmp/compare/v0.2.3...v0.2.5)

---
updated-dependencies:
- dependency-name: tmp
  dependency-version: 0.2.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-08-14 14:50:15 -04:00
Zhihe Li 7504e7710c ci: 🎡 Use repo variables to determine which runner to use (#2963) 2025-08-14 14:28:58 -04:00
hashicc bb7352ca54 fix: 🐛 fix the dependency on apiClientTestAfter with a noop (#2964) 2025-08-14 10:40:52 -04:00
hashiccandZhihe Li b42ecbff36 Add api client for playwright e2e tests (#2901)
* chore: 🤖 add @openapitools/openapi-generator-cli

* chore: 🤖 Add @nestjs/core and @openapitools/openapi-generator-cli to ignoredBuiltDependencies

These packages use postinstall for a opencollective notice

* chore: 🤖 add package.json scripts for open-api client gen

* chore: 🤖 add openapi generated files

* chore: 🤖 add boundary api client fixture

* chore: 🤖 incorporate api client fixture with base fixture

* chore: 🤖 migrate existing test to use boundary api fixture

* refactor: 💡 remove org cleanup from test

* refactor: 💡 update sorting e2e test to use api client

* refactor: 💡 clean up api client resource clean up logging

* docs: ✏️ update jsdoc links for domain clients

* chore: 🤖 avoid adding copywright headers to generated api

* Add missing copyright headers

* chore: 🤖 remove fetch argument from api client class

The hope was to use request.fetch but playwright's fetch is a
non-standard fetch which causes the generated api client to fail when
using .clone() on the response

* chore: 🤖 add optional OPENAPI_SWAGGER_URL_OR_FILE for openapi-client
generation

The OPENAPI_SWAGGER_URL_OR_FILE env var can be used to specify the
-i flag the generate:api-client script with either:
* a relative path
* an absolute path
* a url like: https://raw.githubusercontent.com/hashicorp/boundary/refs/heads/main/internal/gen/controller.swagger.json

* refactor: 💡 clean up and refactor boundary-api-client fixture

* chore: 🤖 add extra doc links and delete methods

* test: 💍 add test to ensure that api-client fixture works

* test: 💍 update existing e2e test examples with api-client

* docs: ✏️ add comment for api-client e2e test

* docs: ✏️ add additional links to service docs for api clients

* refactor: 💡 cleanup api-client and corresponding test

* docs: ✏️ add api client fixture docs to e2e README

* fix: 🐛 fix regex

* remove unnecessary capture group
* add "-" to regex to allow for url segments like "storage-buckets" in
  "/v1/storage-buckets"

* docs: ✏️ comment potential edge case in e2e api client cleanup

* docs: ✏️ fix comment typo

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

* docs: ✏️ remove auth flag for generating e2e client

* docs: ✏️ add mention that java is required for generate:api

* chore: 🤖 implement pr feedback

* chore: 🤖 Clean up api client and add more extensive test

* chore: 🤖 fix casing in api-client test

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-08-12 18:08:35 -04:00
Carlos Alcaide Corvo 5fd9f0216a Fix security vulnerability within desktop appUpdater (#2953)
* use app.isPackaged instead of electron-is-dev
2025-08-12 11:19:43 -07:00
dependabot[bot]andCarlos Alcaide Corvo 942ea99459 chore(deps): bump browser-actions/setup-chrome from 1.7.3 to 2.1.0 (#2952)
Bumps [browser-actions/setup-chrome](https://github.com/browser-actions/setup-chrome) from 1.7.3 to 2.1.0.
- [Release notes](https://github.com/browser-actions/setup-chrome/releases)
- [Changelog](https://github.com/browser-actions/setup-chrome/blob/master/CHANGELOG.md)
- [Commits](https://github.com/browser-actions/setup-chrome/compare/c785b87e244131f27c9f19c1a33e2ead956ab7ce...b94431e051d1c52dcbe9a7092a4f10f827795416)

---
updated-dependencies:
- dependency-name: browser-actions/setup-chrome
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Carlos Alcaide Corvo <ccorvo@hashicorp.com>
2025-08-08 09:28:25 -07:00
LaurenandZhihe Li 990e2d6b9d [Desktop][Bug fix]: Stop target sessions before unauthenticating user session (#2891)
* add stopAll invocation to ipc handler

* trigger modal on both signout btns

* refactor invalidateSession fn

* fix application controller tests

* fix failing tests settings-card/user

* add test to Acceptance | authentication

* migrate electron dialog.showMessageBoxSync to Hds

* rename channel and tracked prop, consolidate cancel fns

* combine modals, add willDestroy, refactor

* test signout and close sessions modal

* address feedback

* refactor window.electron in authentication-test

* address feedback

* refactor: 💡 Fix failing tests

---------

Co-authored-by: Zhihe Li <zhihe.li@hashicorp.com>
2025-08-04 16:44:27 -04:00
Zhihe Li f776ba0617 Fix auto commit of copyright headers (#2948)
* fix: 🐛 Fix copyright header updates failing in CI
2025-07-28 13:50:35 -04:00
hashicc fb57965457 chore: 🤖 update @glimmer/component to v2 (#2871)
* chore: 🤖 update @glimmer/component to v2

* chore: 🤖 bump ember-a11y-refocus to fix focus errors in tests

Previous runs had three failing tests related to focusing from
ember-a11y-refocus:
* global failure: TypeError: Cannot read properties of null (reading 'focus')

Tests failing with this error were fixed as part of this PR:
https://github.com/ember-a11y/ember-a11y-refocus/pull/347

After bumping to a later version of ember-a11y-refocus, the
existing failing tests now pass
2025-07-18 13:12:34 -04:00
Tony b99fce7d55 refactor: 💡 e2e admin tests - update locators (#2931)
* refactor: 💡 e2e tests - update locators

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17174

* refactor: 💡 addressing comments

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17174

* refactor: 💡 update test for session-recording-aws-ent

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17174
2025-07-17 15:48:20 -04:00
Zhihe Li 0a2d743ea2 fix: 🐛 Fix removing worker tag (#2928) 2025-07-16 13:27:13 -04:00
Lisbet Alvarez fbd613084d fix: 🐛 prevent worker registration icon from shrinking (#2929)
* fix: 🐛 prevent worker registration icon from shrinking

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17392
2025-07-15 17:13:07 -07:00
Cameron Perera 4cd56f8ae5 Icu 17393 manage resource button permissions bug (#2927)
* fix: 🐛 hide manage button if no action items

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for credential-stores

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for groups

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for host-sets

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for roles

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage action for targets

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for workers

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for users

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393

* fix: 🐛 update manage actions for auth-methods

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17393
2025-07-15 14:03:27 -05:00
Carlos Alcaide Corvo c0d41c0a62 Delete on-click-inside and on-click-outside rose modifiers (#2926) 2025-07-15 09:11:32 -07:00
Carlos Alcaide Corvo 389c635030 Modify string sorting to benefit upperCase (#2924)
* Modify the string sorting to benefit uppercase over lowercase at comparison
2025-07-11 10:08:39 -07:00
Lisbet Alvarez d93a0c170c test: 💍 refactor targets manage alias test selectors (#2921)
✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16437
2025-07-10 16:20:37 -07:00
Lisbet Alvarez aa18f599ce test: 💍 refactor target worker test selectors (#2910)
* test: 💍 refactor target worker test selectors

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16438

* test: 💍 update selector

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-16438
2025-07-10 15:34:24 -07:00
Tony aa7eb2e844 test: 💍 Add admin UI test for change passsword flow (#2920)
* test: 💍 Add admin UI test for change passsword flow

This PR adds a test to the Admin UI e2e test suite to validate the
workflow of changing user password from settings menu

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 create resources via REST API

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 consistent code structure, comments

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 code structure consistency

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 removed version parameter from boundary-http funcs

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 rm unneeded params from https makeAuthMethodPrimary

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173

* refactor: 💡 rm ui login step

✅ Closes: https://hashicorp.atlassian.net/browse/ICU-17173
2025-07-10 11:26:52 -04:00
757 changed files with 56363 additions and 8313 deletions

No files matched your search

+3 -2
View File
@@ -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/**"
]
}
+5
View File
@@ -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
+56
View File
@@ -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
+16 -16
View File
@@ -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/
+1 -1
View File
@@ -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 -2
View File
@@ -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
+1 -1
View File
@@ -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 }}"
+13 -13
View File
@@ -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'
+1 -1
View File
@@ -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: |
+207
View File
@@ -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 }}"
+26 -13
View File
@@ -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 }}"
+1 -1
View File
@@ -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.
+2
View File
@@ -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));
}),
};
});
});
};
@@ -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;
+2 -1
View File
@@ -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);
}
}
+2 -1
View File
@@ -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);
}
}
+274
View File
@@ -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);
}
}
+30 -3
View File
@@ -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;
}
}
+5
View File
@@ -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 {
+15 -1
View File
@@ -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;
}
}
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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
+2 -1
View File
@@ -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;
}
+44
View File
@@ -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,
});
}
}
-170
View File
@@ -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),
};
}
}
+216
View File
@@ -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();
}
}
+3 -3
View File
@@ -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);
}
}
-258
View File
@@ -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('&', '')),
};
};
-84
View File
@@ -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;
});
};
+408
View File
@@ -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})` : '');
+240
View File
@@ -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();
}
})();
+480
View File
@@ -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,
});
};
}
};
+63
View File
@@ -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);
},
};
+9 -3
View File
@@ -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,
});
+32 -7
View File
@@ -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 {};
}
},
});
+27
View File
@@ -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,
});
}
},
}),
});
+114 -26
View File
@@ -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({});
+14 -8
View File
@@ -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
+2 -2
View File
@@ -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');
+16
View File
@@ -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
View File
@@ -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);
});
});
+17 -1
View File
@@ -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',
},
});
});
});
+19
View File
@@ -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, []);
},
);
});
+1
View File
@@ -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',
+1 -2
View File
@@ -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;
}
+1 -3
View File
@@ -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();
}
}
+1 -1
View File
@@ -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",
+1
View File
@@ -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',
+21 -12
View File
@@ -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>
+11 -5
View File
@@ -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;
}
}
+4 -2
View File
@@ -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 = [];
+34
View File
@@ -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)};
}
}
}
}
@@ -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';
+2 -2
View File
@@ -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
+16
View File
@@ -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
+1
View File
@@ -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:
+32 -5
View File
@@ -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
+1
View File
@@ -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 },
);
+6 -6
View File
@@ -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;
}
+13 -2
View File
@@ -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