feat: add ExemptionManager tests, coverage threshold, and ArtifactHub metadata polish #82
@@ -11,7 +11,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
E2E_NAMESPACE: headlamp-e2e
|
E2E_NAMESPACE: default
|
||||||
E2E_RELEASE: headlamp-e2e
|
E2E_RELEASE: headlamp-e2e
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
+39
-1
@@ -11,6 +11,7 @@ description: >-
|
|||||||
`polaris-dashboard` service in the `polaris` namespace.
|
`polaris-dashboard` service in the `polaris` namespace.
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
homeURL: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
||||||
|
appVersion: "5.0"
|
||||||
category: security
|
category: security
|
||||||
keywords:
|
keywords:
|
||||||
- polaris
|
- polaris
|
||||||
@@ -24,6 +25,43 @@ links:
|
|||||||
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
url: "https://github.com/privilegedescalation/headlamp-polaris-plugin"
|
||||||
- name: Polaris
|
- name: Polaris
|
||||||
url: "https://polaris.docs.fairwinds.com/"
|
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:
|
maintainers:
|
||||||
- name: privilegedescalation
|
- name: privilegedescalation
|
||||||
email: "chris@farhood.org"
|
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/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/version-compat: ">=0.26"
|
||||||
headlamp/plugin/archive-checksum: sha256:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71
|
headlamp/plugin/archive-checksum: sha256:ce75449a05d3d3dd3c546db36a2257fae3e4601e466108182e64310a1a4f6d71
|
||||||
headlamp/plugin/distro-compat: in-cluster
|
headlamp/plugin/distro-compat: "in-cluster,web,desktop"
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
# RBAC for the GitHub Actions CI runner to manage the E2E Headlamp instance.
|
# RBAC for the GitHub Actions CI runner to manage the E2E Headlamp instance.
|
||||||
# CI-only test fixture — NOT for production use.
|
# CI-only test fixture — NOT for production use.
|
||||||
#
|
#
|
||||||
# Grants the ARC runner service account permissions in the headlamp-e2e
|
# Grants the ARC runner service account permissions in the default namespace
|
||||||
# namespace to deploy and tear down a dedicated Headlamp instance via Helm.
|
# to deploy and tear down a dedicated Headlamp instance via Helm.
|
||||||
|
# E2E resources run in `default` — nothing persists beyond a test run.
|
||||||
#
|
#
|
||||||
# No kube-system access needed — E2E tests use a separate namespace.
|
|
||||||
# Plugin is loaded via ConfigMap volume mount — no custom Docker images.
|
# Plugin is loaded via ConfigMap volume mount — no custom Docker images.
|
||||||
#
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# kubectl create namespace headlamp-e2e
|
|
||||||
# kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
|
# kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: Role
|
kind: Role
|
||||||
metadata:
|
metadata:
|
||||||
name: e2e-ci-runner
|
name: e2e-ci-runner
|
||||||
namespace: headlamp-e2e
|
namespace: default
|
||||||
rules:
|
rules:
|
||||||
# Helm needs to manage these resources for the Headlamp chart
|
# Helm needs to manage these resources for the Headlamp chart
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
@@ -36,7 +35,7 @@ apiVersion: rbac.authorization.k8s.io/v1
|
|||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
metadata:
|
metadata:
|
||||||
name: e2e-ci-runner-binding
|
name: e2e-ci-runner-binding
|
||||||
namespace: headlamp-e2e
|
namespace: default
|
||||||
subjects:
|
subjects:
|
||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: runners-privilegedescalation-gha-rs-no-permission
|
name: runners-privilegedescalation-gha-rs-no-permission
|
||||||
@@ -45,29 +44,3 @@ roleRef:
|
|||||||
kind: Role
|
kind: Role
|
||||||
name: e2e-ci-runner
|
name: e2e-ci-runner
|
||||||
apiGroup: rbac.authorization.k8s.io
|
apiGroup: rbac.authorization.k8s.io
|
||||||
---
|
|
||||||
# ClusterRole to allow the runner SA to verify the headlamp-e2e namespace
|
|
||||||
# exists before attempting namespaced operations. kubectl get namespace is a
|
|
||||||
# cluster-scoped operation not coverable by a namespaced Role.
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: e2e-ci-namespace-reader
|
|
||||||
rules:
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["namespaces"]
|
|
||||||
verbs: ["get"]
|
|
||||||
resourceNames: ["headlamp-e2e"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: e2e-ci-namespace-reader-binding
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: runners-privilegedescalation-gha-rs-no-permission
|
|
||||||
namespace: arc-runners
|
|
||||||
roleRef:
|
|
||||||
kind: ClusterRole
|
|
||||||
name: e2e-ci-namespace-reader
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# helm install headlamp-e2e headlamp/headlamp \
|
# helm install headlamp-e2e headlamp/headlamp \
|
||||||
# -n headlamp-e2e --create-namespace \
|
# -n default \
|
||||||
# -f deployment/headlamp-e2e-values.yaml \
|
# -f deployment/headlamp-e2e-values.yaml \
|
||||||
# --set image.registry=ghcr.io \
|
# --set image.registry=ghcr.io \
|
||||||
# --set image.repository=headlamp-k8s/headlamp \
|
# --set image.repository=headlamp-k8s/headlamp \
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ No custom Docker images, no PVCs, no kubectl exec/cp, no patching of existing de
|
|||||||
One-time setup by a cluster admin:
|
One-time setup by a cluster admin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl create namespace headlamp-e2e
|
|
||||||
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
|
kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+88
@@ -17,6 +17,7 @@
|
|||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -37,6 +38,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||||
"version": "11.7.2",
|
"version": "11.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
|
"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": ">=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": {
|
"node_modules/@bundled-es-modules/cookie": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz",
|
||||||
@@ -4846,6 +4871,40 @@
|
|||||||
"vitest": "3.2.4"
|
"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": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
@@ -5651,6 +5710,35 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/astral-regex": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"jsdom": "^24.0.0",
|
"jsdom": "^24.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -5,14 +5,17 @@
|
|||||||
# a ConfigMap volume mount. No custom Docker images — the plugin is built
|
# a ConfigMap volume mount. No custom Docker images — the plugin is built
|
||||||
# in CI and injected as a ConfigMap.
|
# in CI and injected as a ConfigMap.
|
||||||
#
|
#
|
||||||
|
# E2E resources are deployed to the `default` namespace. Nothing persists
|
||||||
|
# beyond the test run — teardown cleans up all created resources.
|
||||||
|
#
|
||||||
# Prerequisites:
|
# Prerequisites:
|
||||||
# - Plugin built (dist/ exists with plugin-main.js + package.json)
|
# - Plugin built (dist/ exists with plugin-main.js + package.json)
|
||||||
# - kubectl configured with cluster access
|
# - kubectl configured with cluster access
|
||||||
# - Helm 3 installed
|
# - Helm 3 installed
|
||||||
# - E2E namespace pre-created by cluster admin (see deployment/e2e-ci-runner-rbac.yaml)
|
# - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-e2e)
|
# E2E_NAMESPACE — namespace for E2E Headlamp (default: default)
|
||||||
# E2E_RELEASE — Helm release name (default: headlamp-e2e)
|
# E2E_RELEASE — Helm release name (default: headlamp-e2e)
|
||||||
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
|
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -20,7 +23,7 @@ set -euo pipefail
|
|||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
DIST_DIR="$REPO_ROOT/dist"
|
DIST_DIR="$REPO_ROOT/dist"
|
||||||
|
|
||||||
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-e2e}"
|
E2E_NAMESPACE="${E2E_NAMESPACE:-default}"
|
||||||
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
||||||
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
|
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
|
||||||
|
|
||||||
@@ -29,21 +32,19 @@ if [ ! -d "$DIST_DIR" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Preflight: verify RBAC before touching the cluster ---
|
||||||
|
echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..."
|
||||||
|
if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then
|
||||||
|
echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2
|
||||||
|
echo " Apply RBAC first: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== E2E Headlamp Deployment ==="
|
echo "=== E2E Headlamp Deployment ==="
|
||||||
echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
|
echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
|
||||||
echo " Namespace: $E2E_NAMESPACE"
|
echo " Namespace: $E2E_NAMESPACE"
|
||||||
echo " Release: $E2E_RELEASE"
|
echo " Release: $E2E_RELEASE"
|
||||||
|
|
||||||
# --- Verify namespace exists (must be pre-created by cluster admin) ---
|
|
||||||
echo ""
|
|
||||||
echo "Verifying namespace ${E2E_NAMESPACE} exists..."
|
|
||||||
if ! kubectl get namespace "$E2E_NAMESPACE" >/dev/null 2>&1; then
|
|
||||||
echo "ERROR: Namespace ${E2E_NAMESPACE} does not exist." >&2
|
|
||||||
echo "A cluster admin must create it first: kubectl create namespace ${E2E_NAMESPACE}" >&2
|
|
||||||
echo "Then apply RBAC: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- Create ConfigMap from built plugin ---
|
# --- Create ConfigMap from built plugin ---
|
||||||
echo ""
|
echo ""
|
||||||
echo "Creating ConfigMap with plugin files..."
|
echo "Creating ConfigMap with plugin files..."
|
||||||
|
|||||||
@@ -4,21 +4,15 @@
|
|||||||
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
|
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
|
||||||
#
|
#
|
||||||
# Environment:
|
# Environment:
|
||||||
# E2E_NAMESPACE — namespace to clean up (default: headlamp-e2e)
|
# E2E_NAMESPACE — namespace to clean up (default: default)
|
||||||
# E2E_RELEASE — Helm release to uninstall (default: headlamp-e2e)
|
# E2E_RELEASE — Helm release to uninstall (default: headlamp-e2e)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-e2e}"
|
E2E_NAMESPACE="${E2E_NAMESPACE:-default}"
|
||||||
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
||||||
|
|
||||||
# Exit early if the namespace does not exist — nothing to tear down.
|
|
||||||
if ! kubectl get namespace "$E2E_NAMESPACE" >/dev/null 2>&1; then
|
|
||||||
echo "Namespace $E2E_NAMESPACE does not exist, nothing to tear down."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=== E2E Headlamp Teardown ==="
|
echo "=== E2E Headlamp Teardown ==="
|
||||||
echo " Namespace: $E2E_NAMESPACE"
|
echo " Namespace: $E2E_NAMESPACE"
|
||||||
echo " Release: $E2E_RELEASE"
|
echo " Release: $E2E_RELEASE"
|
||||||
@@ -32,9 +26,6 @@ kubectl delete configmap headlamp-polaris-plugin -n "$E2E_NAMESPACE" --ignore-no
|
|||||||
echo "Cleaning up service account..."
|
echo "Cleaning up service account..."
|
||||||
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
|
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
|
||||||
|
|
||||||
# Note: namespace is NOT deleted — it is managed by a cluster admin.
|
|
||||||
# The runner SA only has namespace-scoped permissions (see deployment/e2e-ci-runner-rbac.yaml).
|
|
||||||
|
|
||||||
# Clean up local env file
|
# Clean up local env file
|
||||||
rm -f "$REPO_ROOT/.env.e2e"
|
rm -f "$REPO_ROOT/.env.e2e"
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
|
<div data-testid="section-box" data-title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
StatusLabel: ({ status, children }: { status: string; children?: React.ReactNode }) => (
|
||||||
|
<span data-testid="status-label" data-status={status}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
Dialog: ({
|
||||||
|
open,
|
||||||
|
children,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
title?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="dialog" data-title={title}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : 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(<ExemptionManager {...defaultProps} />);
|
||||||
|
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows enabled Add Exemption button when there are failing checks', () => {
|
||||||
|
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||||
|
expect(btn).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include ignored-severity checks as failing', () => {
|
||||||
|
render(<ExemptionManager {...defaultProps} workloadResult={resultWithIgnoredFailures} />);
|
||||||
|
const btn = screen.getByRole('button', { name: /add exemption/i });
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collects failing checks from pod-level results', () => {
|
||||||
|
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithContainerFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithDuplicate} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(
|
||||||
|
<ExemptionManager {...defaultProps} kind="Pod" workloadResult={resultWithPodFailures} />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExemptionManager {...defaultProps} kind="Job" workloadResult={resultWithPodFailures} />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExemptionManager {...defaultProps} kind="CronJob" workloadResult={resultWithPodFailures} />
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ExemptionManager
|
||||||
|
{...defaultProps}
|
||||||
|
kind="StatefulSet"
|
||||||
|
workloadResult={resultWithPodFailures}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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<void>(res => {
|
||||||
|
resolveRequest = res;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ExemptionManager {...defaultProps} workloadResult={resultWithPodFailures} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,5 +9,16 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
exclude: ['e2e/**', 'node_modules/**'],
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user