Compare commits

...
59 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
367 changed files with 5825 additions and 3765 deletions

No files matched your search

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