From a5398e8409bf68ee6edcf7ad41c1e6f69884ab38 Mon Sep 17 00:00:00 2001 From: "privilegedescalation-engineer[bot]" <269729446+privilegedescalation-engineer[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 12:53:07 +0000 Subject: [PATCH] feat: add ExemptionManager tests, coverage threshold, and ArtifactHub metadata polish (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: rework E2E infrastructure to use default namespace Board directive: E2E tests must run in the `default` namespace. Nothing should persist beyond a test run; no dedicated namespace needed. Changes: - e2e-ci-runner-rbac.yaml: retarget Role/RoleBinding to `default`, remove ClusterRole/ClusterRoleBinding (no longer needed since we don't need cluster-scoped namespace read permission) - e2e.yaml: set E2E_NAMESPACE=default - deploy-e2e-headlamp.sh: default namespace to `default`, remove namespace existence check (default always exists) - teardown-e2e-headlamp.sh: default namespace to `default`, remove namespace existence check guard - headlamp-e2e-values.yaml: update usage comment - e2e/README.md: remove namespace creation prerequisite Closes #78 #79 Co-Authored-By: Paperclip * ci: add RBAC preflight check to deploy-e2e-headlamp.sh Fails fast with a clear error and remediation hint if the runner SA lacks configmap delete permission, instead of dying mid-deploy. Co-Authored-By: Claude Sonnet 4.6 * feat: add ExemptionManager tests, coverage threshold, and ArtifactHub metadata - Add 22 unit tests for ExemptionManager.tsx covering: - Failing checks extraction (pod-level, container-level, ignore-severity, dedup) - Dialog open/close, check toggle, exempt-all toggle - Apply button enabled/disabled state - ApiProxy.request called with correct path (apps/batch/core) and annotation structure - Success and error feedback states, in-flight "Applying..." label - Add vitest coverage config with >=80% threshold (lines/functions/branches/statements) - Update artifacthub-pkg.yml: - Add install section (Headlamp-native plugin installer only) - Add appVersion: "5.0" (compatible Polaris dashboard version) - Expand distro-compat from "in-cluster" to "in-cluster,web,desktop" - Add changes block documenting v1.0 features Closes privilegedescalation/headlamp-polaris-plugin#81 (partial — test and metadata tasks) Co-Authored-By: Paperclip * style: fix Prettier formatting in ExemptionManager.test.tsx Co-Authored-By: Paperclip * fix: add @vitest/coverage-v8 devDependency for coverage provider vitest.config.mts specifies coverage.provider: 'v8' but the @vitest/coverage-v8 package was missing from devDependencies. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Hugh Hackman Co-authored-by: Paperclip Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Gandalf the Greybeard Co-authored-by: Samuel Stinkpost Co-authored-by: Gandalf the Greybeard --- artifacthub-pkg.yml | 40 ++- package-lock.json | 88 +++++ package.json | 1 + src/components/ExemptionManager.test.tsx | 432 +++++++++++++++++++++++ vitest.config.mts | 11 + 5 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 src/components/ExemptionManager.test.tsx diff --git a/artifacthub-pkg.yml b/artifacthub-pkg.yml index 5c571f3..730cabc 100644 --- a/artifacthub-pkg.yml +++ b/artifacthub-pkg.yml @@ -11,6 +11,7 @@ description: >- `polaris-dashboard` service in the `polaris` namespace. license: Apache-2.0 homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin" +appVersion: "5.0" category: security keywords: - polaris @@ -24,6 +25,43 @@ links: url: "https://github.com/privilegedescalation/headlamp-polaris-plugin" - name: Polaris url: "https://polaris.docs.fairwinds.com/" +install: | + ## Installation + + ### Prerequisites + + 1. [Headlamp](https://headlamp.dev) v0.26.0 or later + 2. [Fairwinds Polaris](https://polaris.docs.fairwinds.com/) installed and the dashboard running in your cluster + + ### Install via Headlamp Plugin Catalog + + 1. Open Headlamp and navigate to **Settings → Plugin Catalog** + 2. Search for **"Polaris"** + 3. Click **Install** and restart Headlamp when prompted + + The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-polaris). + + ## Usage + + After installation, the Polaris plugin adds: + - A **cluster score badge** in the Headlamp app bar + - A **Polaris** section in the sidebar with the full dashboard and namespace drill-downs + - An **inline audit panel** on Deployment, StatefulSet, DaemonSet, Job, and CronJob detail pages + + For more information, see the [README](https://github.com/privilegedescalation/headlamp-polaris-plugin/blob/main/README.md). +changes: + - kind: added + description: ExemptionManager — apply Polaris annotation exemptions directly from the resource detail page + - kind: added + description: Inline audit section on workload detail pages with per-check pass/fail breakdown + - kind: added + description: Namespace drill-down view with per-resource score list and filterable check table + - kind: added + description: App bar score badge showing overall cluster Polaris score + - kind: added + description: PolarisSettings page for configuring dashboard refresh interval + - kind: changed + description: Stable public API — routes, sidebar entries, settings schema, and app bar action are frozen maintainers: - name: privilegedescalation email: "chris@farhood.org" @@ -31,4 +69,4 @@ annotations: headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-polaris-plugin/releases/download/v0.7.2/headlamp-polaris-0.7.2.tar.gz" headlamp/plugin/version-compat: ">=0.26" headlamp/plugin/archive-checksum: sha256:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71 - headlamp/plugin/distro-compat: in-cluster + headlamp/plugin/distro-compat: "in-cluster,web,desktop" diff --git a/package-lock.json b/package-lock.json index 887daa4..1523bc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^3.2.4", "jsdom": "^24.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -37,6 +38,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@apidevtools/json-schema-ref-parser": { "version": "11.7.2", "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz", @@ -431,6 +446,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bundled-es-modules/cookie": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", @@ -4846,6 +4871,40 @@ "vitest": "3.2.4" } }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -5651,6 +5710,35 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", diff --git a/package.json b/package.json index ad2c18e..6e657fb 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^3.2.4", "jsdom": "^24.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/components/ExemptionManager.test.tsx b/src/components/ExemptionManager.test.tsx new file mode 100644 index 0000000..c164549 --- /dev/null +++ b/src/components/ExemptionManager.test.tsx @@ -0,0 +1,432 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { makeResult } from '../test-utils'; + +const { mockApiRequest } = vi.hoisted(() => ({ mockApiRequest: vi.fn() })); + +vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ + ApiProxy: { request: mockApiRequest }, +})); + +vi.mock('@mui/material/styles', () => ({ + useTheme: () => ({ + palette: { + primary: { main: '#1976d2', contrastText: '#fff' }, + action: { disabledBackground: '#e0e0e0', disabled: '#9e9e9e' }, + divider: '#e0e0e0', + }, + }), +})); + +vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ + SectionBox: ({ title, children }: { title?: string; children?: React.ReactNode }) => ( +
+ {children} +
+ ), + StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => ( + + {children} + + ), + Dialog: ({ + open, + children, + title, + }: { + open: boolean; + onClose?: () => void; + title?: string; + children?: React.ReactNode; + }) => + open ? ( +
+ {children} +
+ ) : null, +})); + +import ExemptionManager from './ExemptionManager'; + +const defaultProps = { + workloadResult: makeResult(), + namespace: 'default', + kind: 'Deployment', + name: 'my-deploy', +}; + +const resultWithPodFailures = makeResult({ + PodResult: { + Name: 'pod', + Results: { + hostIPCSet: { + ID: 'hostIPCSet', + Message: 'Host IPC is set', + Details: [], + Success: false, + Severity: 'danger', + Category: 'Security', + }, + hostPIDSet: { + ID: 'hostPIDSet', + Message: 'Host PID is set', + Details: [], + Success: false, + Severity: 'danger', + Category: 'Security', + }, + }, + ContainerResults: [], + }, +}); + +const resultWithContainerFailures = makeResult({ + PodResult: { + Name: 'pod', + Results: {}, + ContainerResults: [ + { + Name: 'container-1', + Results: { + cpuRequestsMissing: { + ID: 'cpuRequestsMissing', + Message: 'CPU requests missing', + Details: [], + Success: false, + Severity: 'warning', + Category: 'Efficiency', + }, + }, + }, + ], + }, +}); + +const resultWithIgnoredFailures = makeResult({ + PodResult: { + Name: 'pod', + Results: { + hostIPCSet: { + ID: 'hostIPCSet', + Message: '', + Details: [], + Success: false, + Severity: 'ignore', + Category: 'Security', + }, + }, + ContainerResults: [], + }, +}); + +describe('ExemptionManager', () => { + describe('rendering failing checks', () => { + it('shows disabled Add Exemption button when no failing checks', () => { + render(); + const btn = screen.getByRole('button', { name: /add exemption/i }); + expect(btn).toBeDisabled(); + }); + + it('shows enabled Add Exemption button when there are failing checks', () => { + render(); + const btn = screen.getByRole('button', { name: /add exemption/i }); + expect(btn).not.toBeDisabled(); + }); + + it('does not include ignored-severity checks as failing', () => { + render(); + const btn = screen.getByRole('button', { name: /add exemption/i }); + expect(btn).toBeDisabled(); + }); + + it('collects failing checks from pod-level results', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByText('Host IPC')).toBeInTheDocument(); + expect(screen.getByText('Host PID')).toBeInTheDocument(); + }); + + it('collects failing checks from container-level results', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByText('CPU Requests')).toBeInTheDocument(); + }); + + it('deduplicates checks that appear in multiple containers', () => { + const resultWithDuplicate = makeResult({ + PodResult: { + Name: 'pod', + Results: {}, + ContainerResults: [ + { + Name: 'container-1', + Results: { + cpuRequestsMissing: { + ID: 'cpuRequestsMissing', + Message: '', + Details: [], + Success: false, + Severity: 'warning', + Category: 'Efficiency', + }, + }, + }, + { + Name: 'container-2', + Results: { + cpuRequestsMissing: { + ID: 'cpuRequestsMissing', + Message: '', + Details: [], + Success: false, + Severity: 'warning', + Category: 'Efficiency', + }, + }, + }, + ], + }, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + const items = screen.getAllByText('CPU Requests'); + expect(items).toHaveLength(1); + }); + }); + + describe('dialog interactions', () => { + it('opens dialog when Add Exemption button is clicked', () => { + render(); + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + }); + + it('closes dialog when Cancel button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument(); + }); + + it('toggles individual check selection', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + + // Find the checkbox next to "Host IPC" + const checkboxes = screen.getAllByRole('checkbox'); + // First checkbox is "Exempt from all checks", rest are individual checks + const hostIPCCheckbox = checkboxes[1]; + expect(hostIPCCheckbox).not.toBeChecked(); + fireEvent.click(hostIPCCheckbox); + expect(hostIPCCheckbox).toBeChecked(); + fireEvent.click(hostIPCCheckbox); + expect(hostIPCCheckbox).not.toBeChecked(); + }); + + it('hides individual checks list when exempt-all is toggled', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByText('Host IPC')).toBeInTheDocument(); + + const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i }); + fireEvent.click(exemptAllCheckbox); + expect(screen.queryByText('Host IPC')).not.toBeInTheDocument(); + }); + + it('Apply button is disabled when no checks selected and exemptAll is false', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + expect(screen.getByRole('button', { name: /apply/i })).toBeDisabled(); + }); + + it('Apply button is enabled when exemptAll is checked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + const exemptAllCheckbox = screen.getByRole('checkbox', { name: /exempt from all checks/i }); + fireEvent.click(exemptAllCheckbox); + expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled(); + }); + + it('Apply button is enabled when at least one individual check is selected', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[1]); // select first individual check + expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled(); + }); + }); + + describe('ApiProxy.request calls', () => { + it('patches with exempt-all annotation when exemptAll is selected', async () => { + mockApiRequest.mockResolvedValue({}); + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/apis/apps/v1/namespaces/default/deployments/my-deploy', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/strategic-merge-patch+json' }, + body: JSON.stringify({ + metadata: { + annotations: { 'polaris.fairwinds.com/exempt': 'true' }, + }, + }), + }) + ); + }); + }); + + it('patches with per-check annotations when individual checks selected', async () => { + mockApiRequest.mockResolvedValue({}); + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + // Select first check (hostIPCSet) + fireEvent.click(screen.getAllByRole('checkbox')[1]); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/apis/apps/v1/namespaces/default/deployments/my-deploy', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ + metadata: { + annotations: { 'polaris.fairwinds.com/hostIPCSet-exempt': 'true' }, + }, + }), + }) + ); + }); + }); + + it('uses core API path for Pod kind (no api group)', async () => { + mockApiRequest.mockResolvedValue({}); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/api/v1/namespaces/default/pods/my-deploy', + expect.anything() + ); + }); + }); + + it('uses batch API group for Job kind', async () => { + mockApiRequest.mockResolvedValue({}); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/apis/batch/v1/namespaces/default/jobs/my-deploy', + expect.anything() + ); + }); + }); + + it('uses batch API group for CronJob kind', async () => { + mockApiRequest.mockResolvedValue({}); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/apis/batch/v1/namespaces/default/cronjobs/my-deploy', + expect.anything() + ); + }); + }); + + it('uses apps API group for StatefulSet kind', async () => { + mockApiRequest.mockResolvedValue({}); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(mockApiRequest).toHaveBeenCalledWith( + '/apis/apps/v1/namespaces/default/statefulsets/my-deploy', + expect.anything() + ); + }); + }); + }); + + describe('feedback states', () => { + it('shows success feedback and closes dialog after successful apply', async () => { + mockApiRequest.mockResolvedValue({}); + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument(); + const label = screen.getByTestId('status-label'); + expect(label).toHaveAttribute('data-status', 'success'); + expect(label).toHaveTextContent('Exemptions applied successfully'); + }); + }); + + it('shows error feedback and keeps dialog closed after failed apply', async () => { + mockApiRequest.mockRejectedValue(new Error('403 Forbidden')); + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => { + const label = screen.getByTestId('status-label'); + expect(label).toHaveAttribute('data-status', 'error'); + expect(label).toHaveTextContent(/failed to apply exemptions/i); + }); + }); + + it('shows "Applying..." text on Apply button while in-flight', async () => { + let resolveRequest!: () => void; + mockApiRequest.mockReturnValue( + new Promise(res => { + resolveRequest = res; + }) + ); + + render(); + fireEvent.click(screen.getByRole('button', { name: /add exemption/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /exempt from all checks/i })); + fireEvent.click(screen.getByRole('button', { name: /apply/i })); + + expect(screen.getByRole('button', { name: /applying/i })).toBeInTheDocument(); + resolveRequest(); + await waitFor(() => { + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/vitest.config.mts b/vitest.config.mts index dd403a9..a316ace 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -9,5 +9,16 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], exclude: ['e2e/**', 'node_modules/**'], + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.test.{ts,tsx}', 'src/test-utils.tsx', 'src/index.tsx'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, }, });