diff --git a/.github/workflows/pr-ui-tests-coverage-report.yml b/.github/workflows/pr-ui-tests-coverage-report.yml
new file mode 100644
index 0000000000..71a740f2a0
--- /dev/null
+++ b/.github/workflows/pr-ui-tests-coverage-report.yml
@@ -0,0 +1,188 @@
+# Copyright 2025 Specter Ops, Inc.
+#
+# Licensed under the Apache License, Version 2.0
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+name: Pull Request UI Test Coverage
+
+env:
+ node_version: "22"
+
+on:
+ pull_request:
+ branches:
+ - main
+ - "stage/**"
+ types:
+ - opened
+ - synchronize
+ - edited
+
+jobs:
+ build:
+ name: Build and Test
+ runs-on: ubuntu-latest
+ if: github.event.pull_request.head.repo.full_name == github.repository
+
+ steps:
+ - name: Check PR code
+ uses: actions/checkout@v4
+
+ - name: Install Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install Yarn
+ run: |
+ npm install --global yarn
+
+ - name: Install Deps
+ run: |
+ cd cmd/ui && yarn
+
+ - name: Run eslint
+ run: |
+ yarn lint
+
+ - name: Format js-client-library with Prettier
+ run: |
+ cd packages/javascript/js-client-library && yarn format && cd ../bh-shared-ui/ && yarn format && cd ../../../cmd/ui/ && yarn format
+
+ - name: Run Build
+ run: |
+ cd cmd/ui && yarn build
+
+ test:
+ # This matrix tests both the main and pull request branches
+ name: Test ${{ matrix.artifact }}
+ needs: build
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ include:
+ - branch: main
+ artifact: main
+ - branch: ${{ github.head_ref }}
+ artifact: pull-request
+
+ steps:
+ - name: Checkout source code for this repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ matrix.branch }}
+ repository: ${{ github.repository }}
+
+ - name: Install Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ check-latest: true
+
+ - name: Install Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install Yarn
+ run: |
+ npm install --global yarn
+
+ - name: Install Deps
+ run: |
+ cd cmd/ui && yarn
+
+ # Run tests with coverage with summary in json format
+ - name: Run tests with coverage
+ working-directory: packages/javascript/bh-shared-ui
+ run: TZ='America/Los_Angeles' npx vitest run --coverage.enabled --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
+
+ - name: Copy thresholds config (if exists)
+ working-directory: packages/javascript/bh-shared-ui
+ run: |
+ if [ -f vitest.thresholds.js ]; then
+ cp vitest.thresholds.js coverage/
+ fi
+
+ - name: Upload coverage artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-${{ matrix.artifact }}
+ working-directory: packages/javascript/bh-shared-ui
+ path: coverage/
+
+ report-coverage:
+ name: Report UI Coverage Comparison
+ needs: test
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: write
+ statuses: write
+ steps:
+ - name: Checkout source code for this repository
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ matrix.branch }}
+ repository: ${{ github.repository }}
+
+ - name: Install Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+ check-latest: true
+
+ - name: Install Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Install Yarn
+ run: |
+ npm install --global yarn
+
+ - name: Install Deps
+ run: |
+ cd cmd/ui && yarn
+
+ # Step to delete the previous coverage report folder
+ - name: Delete previous coverage reports
+ run: rm -rf coverage
+
+ # Run tests with coverage with summary in json format
+ - name: Run tests with coverage
+ working-directory: packages/javascript/bh-shared-ui
+ run: TZ='America/Los_Angeles' npx vitest run --coverage.enabled --coverage.reportOnFailure --coverage.reporter=json --coverage.reporter=json-summary --coverage.reporter=lcov
+
+ - name: Copy thresholds config (if exists)
+ working-directory: packages/javascript/bh-shared-ui
+ run: |
+ if [ -f vitest.thresholds.js ]; then
+ cp vitest.thresholds.js coverage/
+ fi
+
+ - name: Report coverage
+ if: always()
+ uses: davelosert/vitest-coverage-report-action@v2
+ with:
+ file-coverage-mode: "all"
+ working-directory: packages/javascript/bh-shared-ui/coverage
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ json-summary-path: coverage-summary.json
+ json-summary-compare-path: coverage-summary.json
+ json-final-path: coverage-final.json
+ comment-on: pr
+ vite-config-path: vitest.thresholds.js
+ threshold-icons: "{0: '🔴', 50: '🟠', 60: '🟢'}"
diff --git a/packages/javascript/bh-shared-ui/package.json b/packages/javascript/bh-shared-ui/package.json
index a01df35c85..f3b11344b5 100644
--- a/packages/javascript/bh-shared-ui/package.json
+++ b/packages/javascript/bh-shared-ui/package.json
@@ -12,6 +12,7 @@
"build": "yarn check-types && rollup -c rollup.config.js",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"test": "TZ=America/Los_Angeles vitest",
+ "coverage": "TZ=America/Los_Angeles vitest run --coverage",
"check-types": "tsc --noEmit --pretty",
"format": "prettier --write \"src/**/*.@(js|jsx|ts|tsx|md|html|css|scss|json)\"",
"check-format": "prettier --list-different \"src/**/*.@(js|jsx|ts|tsx|md|html|css|scss|json)\""
diff --git a/packages/javascript/bh-shared-ui/src/views/Users/UsersWithEnvironmentAccessControls.test.tsx b/packages/javascript/bh-shared-ui/src/views/Users/UsersWithEnvironmentAccessControls.test.tsx
new file mode 100644
index 0000000000..c6ee05684f
--- /dev/null
+++ b/packages/javascript/bh-shared-ui/src/views/Users/UsersWithEnvironmentAccessControls.test.tsx
@@ -0,0 +1,143 @@
+// Copyright 2024 Specter Ops, Inc.
+//
+// Licensed under the Apache License, Version 2.0
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+
+import userEvent from '@testing-library/user-event';
+import { rest } from 'msw';
+import { setupServer } from 'msw/node';
+import UsersWithEnvironmentAccessControls from '.';
+import { bloodHoundUsersHandlers, testAuthenticatedUser, testBloodHoundUsers, testSSOProviders } from '../../mocks';
+import { render, screen, within } from '../../test-utils';
+
+const server = setupServer(...bloodHoundUsersHandlers);
+
+beforeAll(() => server.listen());
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());
+
+describe('UsersWithEnvironmentAccessControls', () => {
+ test('The password reset dialog is opened when switching a user from SAML based authentication to username/password based authentication', async () => {
+ render();
+
+ expect(screen.getByText('Manage Users')).toBeInTheDocument();
+
+ // wait for the table data to load
+ await screen.findByText(testBloodHoundUsers[0].principal_name);
+
+ // this table row contains the data for "Marshall Law" aka testBloodHoundUsers[1]
+ const testUserRow = screen.getAllByRole('row')[2];
+
+ expect(within(testUserRow).getByText(testBloodHoundUsers[1].principal_name)).toBeInTheDocument();
+ expect(within(testUserRow).getByText(testBloodHoundUsers[1].email_address)).toBeInTheDocument();
+ expect(
+ within(testUserRow).getByText(`${testBloodHoundUsers[1].first_name} ${testBloodHoundUsers[1].last_name}`)
+ ).toBeInTheDocument();
+ expect(within(testUserRow).getByText('2024-01-01 04:00 PST (GMT-0800)')).toBeInTheDocument();
+ expect(within(testUserRow).getByText('User')).toBeInTheDocument();
+ expect(within(testUserRow).getByText('Active')).toBeInTheDocument();
+ expect(within(testUserRow).getByText(`SSO: ${testSSOProviders[0].name}`)).toBeInTheDocument();
+ expect(within(testUserRow).getByRole('button')).toBeInTheDocument();
+
+ // open the update user dialog for Marshall
+ await userEvent.click(within(testUserRow).getByRole('button', { name: 'Show user actions' }));
+ await screen.findByRole('menuitem', { name: /update user/i, hidden: false });
+ await userEvent.click(screen.getByRole('menuitem', { name: /update user/i, hidden: false }));
+ expect(await screen.findByTestId('update-user-dialog')).toBeVisible();
+
+ // update Marshall to use username/password based authentication and save the changes
+ await userEvent.click(screen.getByLabelText('Authentication Method'));
+ await userEvent.click(screen.getByRole('option', { name: 'Username / Password' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ // the update user dialog should close and the password reset dialog should open
+ expect(screen.queryByTestId('update-user-dialog')).toBeNull();
+ expect(await screen.findByTestId('password-dialog')).toBeVisible();
+
+ // the force password reset option should be checked
+ expect(screen.getByLabelText('Force Password Reset?')).toBeChecked();
+ });
+
+ it('disables the create user button and does not populate a table if the user lacks the permission', async () => {
+ render();
+
+ expect(screen.getByTestId('manage-users_button-create-user')).toBeDisabled();
+
+ const nameElement = screen.queryByText(testBloodHoundUsers[0].principal_name);
+ expect(nameElement).toBeNull();
+
+ const rows = screen.getAllByRole('row');
+ // Only the header row renders even though there is a mock endpoint that serves data
+ expect(rows).toHaveLength(1);
+ });
+
+ it('does not show the "Disable MFA" context menu option for users without MFA enabled', async () => {
+ render();
+
+ const noMFARow = await screen.findByRole('row', { name: /test_admin/i });
+
+ await userEvent.click(within(noMFARow).getByRole('button', { name: 'Show user actions' }));
+ await screen.findByRole('menuitem', { name: /update user/i, hidden: false });
+ expect(screen.queryByRole('menuitem', { name: /disable mfa/i, hidden: false })).not.toBeInTheDocument();
+ });
+
+ it('shows the "Disable MFA" context menu option for users with MFA enabled', async () => {
+ render();
+
+ const withMFARow = await screen.findByRole('row', { name: /mfa_user/i });
+
+ await userEvent.click(within(withMFARow).getByRole('button', { name: 'Show user actions' }));
+ expect(screen.queryByRole('menuitem', { name: /disable mfa/i, hidden: false })).toBeInTheDocument();
+ });
+
+ it('requires a password to disable MFA for a user when logged in without SSO', async () => {
+ render();
+
+ const withMFARow = await screen.findByRole('row', { name: /mfa_user/i });
+
+ await userEvent.click(within(withMFARow).getByRole('button', { name: 'Show user actions' }));
+ await userEvent.click(screen.getByRole('menuitem', { name: /disable mfa/i }));
+
+ const dialog = screen.queryByRole('dialog', { name: /disable multi-factor authentication/i });
+ const input = screen.queryByLabelText(/password/i);
+
+ expect(dialog).toBeInTheDocument();
+ expect(input).toBeInTheDocument();
+ });
+
+ it('hides the password field and removes the requirement when logged in with SSO', async () => {
+ // Override logged in admin with a SSO provider value
+ server.use(
+ rest.get('/api/v2/self', (req, res, ctx) => {
+ return res(
+ ctx.json({
+ data: { ...testAuthenticatedUser, sso_provider_id: 1 },
+ })
+ );
+ })
+ );
+ render();
+
+ const withMFARow = await screen.findByRole('row', { name: /mfa_user/i });
+
+ await userEvent.click(within(withMFARow).getByRole('button', { name: 'Show user actions' }));
+ await userEvent.click(screen.getByRole('menuitem', { name: /disable mfa/i }));
+
+ const dialog = screen.queryByRole('dialog', { name: /disable multi-factor authentication/i });
+ const input = screen.queryByLabelText(/password/i);
+
+ expect(dialog).toBeInTheDocument();
+ expect(input).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/javascript/bh-shared-ui/vitest.config.js b/packages/javascript/bh-shared-ui/vitest.config.js
index 80fe35cc75..80230efa76 100644
--- a/packages/javascript/bh-shared-ui/vitest.config.js
+++ b/packages/javascript/bh-shared-ui/vitest.config.js
@@ -31,10 +31,14 @@ export default defineConfig({
coverage: {
provider: 'v8',
reportsDirectory: './coverage',
- reporter: ['text-summary', 'json-summary'],
+ enabled: true, // Make sure coverage is enabled
+ reportOnFailure: true, // report coverage even if fails
+ reporter: ['text', 'json', 'json-summary', 'html'],
+ exclude: ['**/types/**', '**/constants/**', 'dist', '**/components/HelpTexts/**'],
},
reporters: [
'default',
+ 'github-actions',
[
'allure-vitest/reporter',
{