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', {