Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
71ed1c0
wip: test if unit test report is generates in pull request
catsiller Dec 19, 2025
64c14d0
wip: update working directory for failed yml check
catsiller Dec 19, 2025
1bd5434
wip: update test coverage threshold amounts
catsiller Dec 19, 2025
e2cf5d5
wip: update yml file with working directory
catsiller Dec 19, 2025
780773a
wip: update yml file with more steps on report-coverage
catsiller Dec 19, 2025
990670e
Merge branch 'main' of github.com:SpecterOps/BloodHound into generate…
catsiller Dec 22, 2025
4733a0f
chore: update yml file with github pr run id
catsiller Dec 22, 2025
5e6b8f8
chore: update yml file for failing report coverage
catsiller Dec 22, 2025
f430ba5
chire: testing to see how different threshold shows on pr
catsiller Dec 22, 2025
e1d8c6b
chore: update yml to generate coverage report on failure
catsiller Dec 22, 2025
8516b7d
chore: update yml and vitest to report on failure
catsiller Dec 22, 2025
d941f07
chore: update yml commands
catsiller Dec 22, 2025
46c9df1
chore: test threshold file
catsiller Dec 22, 2025
48c0709
chore: update yml with status on pull request
catsiller Dec 22, 2025
6951109
chore: update pr types
catsiller Dec 22, 2025
9da0d74
chore: update yml and vitest config
catsiller Dec 22, 2025
0f95931
chore: see if coverage regenerates on pr
catsiller Dec 22, 2025
163d341
chore: update yml file
catsiller Dec 22, 2025
64c734e
chore: update yml with file-coverage-mode
catsiller Dec 22, 2025
ab8d3ac
chore: update yml with only report
catsiller Dec 22, 2025
98a6e39
chore: update yml
catsiller Dec 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions .github/workflows/pr-ui-tests-coverage-report.yml
Original file line number Diff line number Diff line change
@@ -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: '🟢'}"
1 change: 1 addition & 0 deletions packages/javascript/bh-shared-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)\""
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<UsersWithEnvironmentAccessControls />);

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(<UsersWithEnvironmentAccessControls />);

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(<UsersWithEnvironmentAccessControls />);

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(<UsersWithEnvironmentAccessControls />);

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(<UsersWithEnvironmentAccessControls />);

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(<UsersWithEnvironmentAccessControls />);

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();
});
});
6 changes: 5 additions & 1 deletion packages/javascript/bh-shared-ui/vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
{
Expand Down
Loading