Compare commits

..

81 Commits

Author SHA1 Message Date
Chris Farhood a085dda623 fix: override elliptic to patched version for GHSA-848j-6mx2-7j84 2026-05-05 12:51:05 +00:00
privilegedescalation-engineer[bot] 67602fb279 chore: replace Dependabot references with Renovate (#55)
- SECURITY.md: update to mention Renovate instead of Dependabot
- README.md: update supply chain table
- ADR 003: update mitigation to mention Renovate

Closes PRI-389. Parent PRI-387.

Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 21:19:15 +00:00
privilegedescalation-engineer[bot] ecdee4a95a Regenerate lockfile for lodash+vite overrides (#53)
Co-authored-by: Chris Farhood <chris@farhood.org>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-04 03:24:04 +00:00
privilegedescalation-engineer[bot] 0c2132b013 fix: update vite to >=6.4.2 to patch arbitrary file read vulnerability (#51)
Vite versions >=6.0.0 <=6.4.1 are vulnerable to arbitrary file read via
the Vite Dev Server WebSocket (server.fs.deny bypass with queries).

CVE: GHSA-p9ff-h696-f583

Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 17:44:05 +00:00
privilegedescalation-engineer[bot] 780f58f9d9 release: v1.0.2 (#50)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-15 04:01:28 +00:00
privilegedescalation-ceo[bot] d1ea2fa36e fix: correct artifacthub-pkg.yml checksum on main for v1.0.1
Co-authored-by: privilegedescalation-ceo[bot] <269721483+privilegedescalation-ceo[bot]@users.noreply.github.com>
2026-04-15 03:51:04 +00:00
privilegedescalation-engineer[bot] 9b385b95a3 fix: pass pr_number input to dual-approval-check workflow (#44)
The dual-approval workflow was not re-triggering on pull_request_review events because the shared workflow was using github.event.pull_request.number which is not available in workflow_call context.

This change explicitly passes the pr_number from the pull_request event to the reusable workflow.

Co-authored-by: Hugh Hackman <hugh@privilegedescalation.dev>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-15 03:29:59 +00:00
privilegedescalation-ceo[bot] 395ff7de0b chore: add repository_dispatch trigger for automated release 2026-04-15 02:54:37 +00:00
privilegedescalation-ceo[bot] 6aa2fb9c5a Merge pull request #47 from privilegedescalation/release-v1.0.1
Bump to v1.0.1 — fix ArtifactHub checksum
2026-04-15 02:22:41 +00:00
Gandalf the Greybeard ba6ddc1366 Fix node-forge to ^1.4.0 (patch security vulnerabilities)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:58:16 +00:00
Gandalf the Greybeard 6f1163c1b8 Regenerate pnpm-lock.yaml with node-forge 1.4.0
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:57:58 +00:00
Gandalf the Greybeard 949ce18b12 Set archive-checksum for v1.0.1 tarball
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:54:06 +00:00
Gandalf the Greybeard af87036ef0 Fix package.json formatting - restore proper indentation
The package.json was accidentally minified to a single line. This change
restores the standard formatted version with proper 2-space indentation.
2026-04-15 00:46:57 +00:00
privilegedescalation-engineer[bot] e05423f853 Bump to v1.0.1 — fix ArtifactHub checksum
Bumps version to 1.0.1 and updates artifacthub-pkg.yml with the
correct archive URL for v1.0.1. The archive-checksum is intentionally
left blank so the release workflow can compute it after rebuilding the
tarball (fixes the v1.0.0 ordering bug fixed in PR #80).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-15 00:46:32 +00:00
privilegedescalation-engineer[bot] 2c17512372 fix: update node-forge to 1.4.0 to patch security vulnerabilities (#46)
Resolves 4 high-severity vulnerabilities in node-forge:
- GHSA-2328-f5f3-gj25: basicConstraints bypass
- GHSA-q67f-28xg-22rw: signature forgery Ed25519
- GHSA-5m6q-g25r-mvwx: Denial of Service via Infinite Loop
- GHSA-ppp5-5v6c-4jwp: signature forgery RSA-PKCS

Fixes PRI-21

Co-authored-by: Pawla Abdul (Bot) <pawla@groombook.dev>
2026-04-15 00:14:40 +00:00
privilegedescalation-engineer[bot] 2798bca085 fix: set correct archive checksum for v1.0.0
Co-authored-by: Gandalf the Greybeard <gandalf@privilegedescalation.github>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-03-25 12:15:03 +00:00
privilegedescalation-ceo[bot] 01ebdcfbb1 Merge pull request #39 from privilegedescalation/fix/regenerate-pnpm-lockfile
fix: regenerate pnpm-lock.yaml to include @playwright/test
2026-03-24 23:52:43 +00:00
Gandalf the Greybeard d20e18f13b fix: regenerate pnpm-lock.yaml to include @playwright/test
pnpm-lock.yaml was not updated when @playwright/test@^1.58.2 was added to
package.json, causing CI to fail with ERR_PNPM_OUTDATED_LOCKFILE. This
lockfile-only change resolves that breakage.

Closes https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues/38

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 23:40:35 +00:00
privilegedescalation-ceo[bot] f09376020c Merge pull request #37 from privilegedescalation/feat/playwright-e2e-smoke-tests
feat: add Playwright E2E smoke tests
2026-03-24 23:29:14 +00:00
Gandalf the Greybeard a2ac69c764 feat: add Playwright E2E smoke tests
Follows the pattern established in headlamp-intel-gpu-plugin (PR #25):
- e2e/sealed-secrets.spec.ts: 5 smoke tests covering sidebar navigation,
  list view, sealing keys view, cross-view navigation, and plugin settings
- e2e/auth.setup.ts: shared OIDC + token auth setup
- playwright.config.ts: fail-fast if HEADLAMP_URL not set (no prod URL fallback)
- scripts/deploy-e2e-headlamp.sh: ConfigMap-based plugin injection to privilegedescalation-dev
- scripts/teardown-e2e-headlamp.sh: clean teardown of all E2E resources
2026-03-24 23:19:20 +00:00
privilegedescalation-ceo[bot] 4f474e02bc Merge pull request #36 from privilegedescalation/fix/add-package-manager-field
fix: add packageManager field to package.json
2026-03-24 22:45:12 +00:00
privilegedescalation-ceo[bot] 84f0384a2a Merge pull request #35 from privilegedescalation/release/v1.0.0
release: v1.0.0
2026-03-24 22:36:21 +00:00
github-actions[bot] c223d924bc release: v1.0.0 2026-03-24 22:30:53 +00:00
Gandalf the Greybeard 2d7b73466a fix: add packageManager field to package.json
pnpm/action-setup@v5 requires either a version key in the action config
or a packageManager field in package.json. Add the field to unblock the
release workflow.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 22:12:39 +00:00
privilegedescalation-ceo[bot] b15967a9f4 Merge pull request #31 from privilegedescalation/release/v1.0.0
release: sealed-secrets v1.0.0
2026-03-24 22:01:15 +00:00
Gandalf the Greybeard a7adee4e54 fix(ci): remove typescript from overrides, keep only as devDep
npm/pnpm rejects a package.json that specifies the same package in both
overrides and devDependencies (EOVERRIDE). Since typescript is now a
direct devDependency pinned at ~5.6.2, remove it from overrides.
2026-03-24 21:41:08 +00:00
Gandalf the Greybeard 5c420e58a4 fix(ci): add typescript as explicit devDependency
pnpm strict hoisting means only direct deps are on PATH. The overrides
entry pins the version but does not install tsc as a binary. Without an
explicit devDependency entry pnpm run tsc fails with "tsc: not found".
2026-03-24 21:38:23 +00:00
Gandalf the Greybeard 71649454c9 fix(ci): add missing eslint/prettier devDeps, fix tsconfig types
- Add eslint@^8.57.0, @headlamp-k8s/eslint-config@^0.6.0, prettier@^2.8.8
  as explicit devDependencies — without these the lint and format:check CI
  steps fail with "eslint: not found" / "prettier: not found"
- Remove vite/client and vite-plugin-svgr/client from tsconfig types — these
  are transitive deps that pnpm does not hoist; polaris plugin omits them too
  and tsc passes cleanly without them
- Update pnpm-lock.yaml to reflect new direct deps
2026-03-24 21:36:04 +00:00
Gandalf the Greybeard 2234e2878f release: prepare v1.0.0
- Bump version to 1.0.0 in package.json and artifacthub-pkg.yml
- Add explicit devDependencies: vitest, @testing-library/react,
  @testing-library/jest-dom, @testing-library/user-event, jsdom,
  react, react-dom, @types/react, @types/react-dom, react-router-dom,
  @mui/material, notistack — resolves phantom-dep test failures
- Add process.env.NODE_ENV define to vitest.config.mts (fixes
  "act() not supported in production builds" failures)
- Switch to pnpm lockfile (pnpm-lock.yaml), drop package-lock.json
- Remove install-plugin.sh (violates ArtifactHub-only install policy)
- Fill in CHANGELOG entries for v0.2.22, v0.2.23, v0.2.24
- Update CHANGELOG [1.0.0] and version comparison links
- All 233 tests pass

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 21:25:26 +00:00
privilegedescalation-ceo[bot] b3f31e9b76 Merge pull request #30 from privilegedescalation/feat/renovate-extend-org-config
feat: extend Renovate config from org-level preset
2026-03-24 18:46:02 +00:00
Hugh Hackman 68cdb804e8 feat: extend Renovate config from org-level preset
Replaces the duplicated Renovate config with a simple extend from the
org-level preset (privilegedescalation/.github:renovate-config). All
rules (schedule, pinDigests, npm/github-actions minor+patch+major groups)
are now inherited from the org config, which was updated in PR #66 to add
major-version update rules for GitHub Actions.

This eliminates config drift between repos and reduces maintenance toil —
future rule changes only need to be made in one place.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-24 16:16:41 +00:00
privilegedescalation-ceo[bot] b0ad6573d9 Merge pull request #29 from privilegedescalation/chore/renovate-pin-digests
chore(renovate): add pinDigests for GitHub Actions SHA pinning
2026-03-22 11:06:38 +00:00
privilegedescalation-engineer[bot] a6a3cb27fb chore(renovate): add pinDigests to ensure SHA pinning for GitHub Actions
The org renovate-config.json (PR #63) adds pinDigests: true at the org level,
but this repo extends config:recommended directly. Adding pinDigests: true here
ensures GitHub Actions are pinned to full commit SHAs regardless of whether the
org config is extended.

Related: privilegedescalation/.github#63, PRI-757
2026-03-22 07:16:08 +00:00
privilegedescalation-ceo[bot] 724541c329 Merge pull request #28 from privilegedescalation/feat/dual-approval-status-check
ci: add dual-approval status check (CTO + QA)
2026-03-22 04:12:37 +00:00
privilegedescalation-engineer[bot] f5c78ddb9c ci: add dual-approval caller workflow
Calls the shared privilegedescalation/.github dual-approval-check
reusable workflow to enforce CTO + QA approval as a GitHub status check.

Once privilegedescalation/.github#47 is merged, this status check can
be added to required_status_checks in branch protection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-21 23:55:49 +00:00
privilegedescalation-ceo[bot] 33a834cd1f Merge pull request #27 from privilegedescalation/fix/artifacthub-metadata-install-methods
fix: update ArtifactHub metadata - remove non-ArtifactHub install methods
2026-03-21 07:36:50 +00:00
Gandalf the Greybeard 0f46892d75 fix: update artifacthub-pkg.yml - remove non-ArtifactHub install methods
- Replace NPM and build-from-source install options with Headlamp native
  plugin installer instructions (Settings → Plugin Catalog)
- Reconcile appVersion (0.36.1 → 0.24.0) to match containersImages ref
- Add changes block documenting v1.0 features for ArtifactHub changelog

Closes privilegedescalation/headlamp-sealed-secrets-plugin#26 (partial)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-21 03:17:28 +00:00
privilegedescalation-paperclip[bot] 5d296d9c72 ci: pass GitHub App token secrets to release workflow (#24)
The shared release workflow now requires RELEASE_APP_ID and
RELEASE_APP_PRIVATE_KEY secrets for PR creation, since the org
blocks GITHUB_TOKEN from creating PRs.

Depends on privilegedescalation/.github#31

Co-authored-by: privilegedescalation-paperclip[bot] <268365651+privilegedescalation-paperclip[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 13:24:42 +00:00
privilegedescalation-paperclip[bot] 1b4fe0a8b2 Merge pull request #23 from privilegedescalation/release/v0.2.24
release: v0.2.24
2026-03-19 21:50:49 +00:00
github-actions[bot] 0fed41a466 release: v0.2.24 2026-03-19 21:39:34 +00:00
privilegedescalation-paperclip[bot] bfd90f9acd fix: add pull-requests write permission to release workflow (#22)
The reusable release workflow declares pull-requests:write but the
caller didn't grant it, causing startup_failure on GitHub Actions.

Co-authored-by: Hugh Hackman [bot] <hugh-hackman[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:33:09 +00:00
null-pointer-nancy[bot] 960e768a99 Merge pull request #21 from privilegedescalation/fix/dep-security-overrides-tar-undici
fix: add npm overrides for tar and undici security advisories
2026-03-18 23:14:08 +00:00
Hugh Hackman 9558542d9d fix: add npm overrides for tar and undici security advisories
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-18 22:55:42 +00:00
dependabot[bot] 3cbb09d596 chore(deps-dev): bump qs from 6.14.1 to 6.15.0 (#18)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.15.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.15.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-18 02:40:28 +00:00
hugh-hackman[bot] 6ba022d943 Merge pull request #17 from privilegedescalation/dependabot/npm_and_yarn/storybook-9.1.20
chore(deps-dev): bump storybook from 9.1.17 to 9.1.20
2026-03-18 02:32:58 +00:00
hugh-hackman[bot] 376fe870ba Merge pull request #15 from privilegedescalation/dependabot/npm_and_yarn/rollup-4.59.0
chore(deps-dev): bump rollup from 4.46.3 to 4.59.0
2026-03-18 02:32:56 +00:00
hugh-hackman[bot] 6f49f1e7bb Merge pull request #14 from privilegedescalation/dependabot/npm_and_yarn/tar-7.5.11
chore(deps-dev): bump tar from 7.5.7 to 7.5.11
2026-03-18 02:32:54 +00:00
hugh-hackman[bot] badf3ed3b9 Merge pull request #13 from privilegedescalation/dependabot/npm_and_yarn/undici-7.24.4
chore(deps-dev): bump undici from 7.14.0 to 7.24.4
2026-03-18 02:32:45 +00:00
hugh-hackman[bot] 37aa9511da Merge pull request #16 from privilegedescalation/dependabot/npm_and_yarn/minimatch-3.1.5
chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5
2026-03-18 02:32:00 +00:00
hugh-hackman[bot] b82d0f6323 Merge pull request #12 from privilegedescalation/dependabot/npm_and_yarn/multi-0d13b2d87f
chore(deps): bump serialize-javascript and terser-webpack-plugin
2026-03-18 02:31:51 +00:00
dependabot[bot] 1c58cf7226 chore(deps-dev): bump storybook from 9.1.17 to 9.1.20
Bumps [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core) from 9.1.17 to 9.1.20.
- [Release notes](https://github.com/storybookjs/storybook/releases)
- [Changelog](https://github.com/storybookjs/storybook/blob/v9.1.20/CHANGELOG.md)
- [Commits](https://github.com/storybookjs/storybook/commits/v9.1.20/code/core)

---
updated-dependencies:
- dependency-name: storybook
  dependency-version: 9.1.20
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:32 +00:00
dependabot[bot] 953e8c30af chore(deps-dev): bump minimatch from 3.1.2 to 3.1.5
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:20 +00:00
dependabot[bot] b73be9a587 chore(deps-dev): bump rollup from 4.46.3 to 4.59.0
Bumps [rollup](https://github.com/rollup/rollup) from 4.46.3 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.46.3...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:18 +00:00
dependabot[bot] 2fb8c8223a chore(deps-dev): bump tar from 7.5.7 to 7.5.11
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.11.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.11)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:12 +00:00
dependabot[bot] c897dfbb31 chore(deps-dev): bump undici from 7.14.0 to 7.24.4
Bumps [undici](https://github.com/nodejs/undici) from 7.14.0 to 7.24.4.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.14.0...v7.24.4)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.24.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:10 +00:00
dependabot[bot] 2d54372fda chore(deps): bump serialize-javascript and terser-webpack-plugin
Removes [serialize-javascript](https://github.com/yahoo/serialize-javascript). It's no longer used after updating ancestor dependency [terser-webpack-plugin](https://github.com/webpack/terser-webpack-plugin). These dependencies need to be updated together.


Removes `serialize-javascript`

Updates `terser-webpack-plugin` from 5.3.14 to 5.4.0
- [Release notes](https://github.com/webpack/terser-webpack-plugin/releases)
- [Changelog](https://github.com/webpack/terser-webpack-plugin/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/terser-webpack-plugin/compare/v5.3.14...v5.4.0)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-version: 
  dependency-type: indirect
- dependency-name: terser-webpack-plugin
  dependency-version: 5.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-18 02:07:01 +00:00
null-pointer-nancy[bot] e351e72f9c Merge pull request #11 from privilegedescalation/docs/remove-manual-install
docs: remove manual install sections from README
2026-03-17 12:19:25 +00:00
Gandalf the Greybeard 7b5a9c5ceb docs: remove manual install sections from README
ArtifactHub plugin installer is the only supported installation method.
Remove manual tarball, sidecar, and build-from-source install options
to align documentation with company policy.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-17 12:15:44 +00:00
null-pointer-nancy[bot] 60d1d195af ci: retrigger after shared workflow fix (#10)
CI retrigger after shared workflow fix (.github PR#14)
2026-03-15 17:54:24 +00:00
Chris Farhood a1fb0a2eed Merge pull request #9 from privilegedescalation/policy/artifacthub-only
policy: add ArtifactHub-only installation requirement
2026-03-15 12:44:57 -04:00
null-pointer-nancy[bot] 388920473d policy: add ArtifactHub-only installation policy
Per CEO directive, ArtifactHub via the Headlamp plugin installer is the
only approved installation method. No exceptions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-15 16:36:37 +00:00
github-actions[bot] 39b0d5dbbe release: v0.2.23 2026-03-09 03:21:30 +00:00
github-actions[bot] 171b3895c0 release: v0.2.22 2026-03-09 03:18:49 +00:00
github-actions[bot] b335bf1d7b release: v0.2.22 2026-03-09 03:16:11 +00:00
github-actions[bot] 60ae9391ea release: v0.2.22 2026-03-09 03:11:44 +00:00
DevContainer User d508f38292 fix: add archive checksum to ArtifactHub metadata
Empty checksum causes headlamp plugin manager to reject the plugin
with "Invalid plugin metadata".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 02:53:46 +00:00
Chris Farhood 277b91f2ee Merge pull request #8 from privilegedescalation/gandalf/ah-rename-headlamp-sealed-secrets
Update Artifact Hub metadata for package rename
2026-03-08 11:43:18 -04:00
Chris Farhood ef439583ac Merge pull request #7 from privilegedescalation/feat/add-upstream-appversion-tracking
feat: auto-track upstream appVersion in releases
2026-03-08 11:42:57 -04:00
gandalf-the-greybeard[bot] 067b75ba21 Update Artifact Hub metadata for package rename
Renamed from sealed-secrets to headlamp-sealed-secrets on Artifact Hub
with new repository ID 3d4645ad-d227-4fc0-8cae-8f8ee7794da2.

Ref: PRI-31

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:29:02 +00:00
Hugh Hackman 0bf9c41c98 feat: add upstream appVersion tracking to release workflow
Configures the reusable release workflow to fetch the latest release
tag from bitnami-labs/sealed-secrets and set appVersion in artifacthub-pkg.yml.
This keeps our Artifact Hub listing in sync with the upstream project.
2026-03-08 12:29:14 +00:00
hugh-hackman[bot] 7aa92ac1fb Merge PR #6
* ci: switch to org-level reusable workflows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: retrigger CI after reusable workflows merged

* feat: add workflow_dispatch to CI workflow

---------

Co-authored-by: hugh-hackman[bot] <hugh-hackman[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: hugh-hackman[bot] <266376744+hugh-hackman[bot]@users.noreply.github.com>
2026-03-08 11:16:27 +00:00
gandalf-the-greybeard[bot] 01895297cd Enhance Renovate configuration (#5)
- Target main branch explicitly
- Set weekly schedule (weekends)
- Limit concurrent PRs to 10
- Group minor/patch updates for npm and github-actions to reduce PR noise

Ref: PRI-16

Co-authored-by: Gandalf Greybeard <gandalf@privilegedescalation.dev>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:01:11 +00:00
Chris Farhood 64fd6f31f5 Merge pull request #4 from privilegedescalation/fix/artifacthub-checksum-annotation
fix: add missing archive-checksum annotation for Artifact Hub
2026-03-07 12:47:29 -05:00
Gandalf Greybeard a679e4c16c fix: add missing archive-checksum annotation to artifacthub-pkg.yml
Artifact Hub requires the headlamp/plugin/archive-checksum annotation.
The release workflow's sed replacement (Compute checksum step) expects
this line to already exist in order to substitute the actual SHA256
checksum at release time. Without it, the sed silently does nothing
and AH rejects the package metadata.

Adds an empty placeholder that the release workflow will populate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:06:52 +00:00
Chris Farhood 3997399aef Merge pull request #3 from privilegedescalation/fix/repo-metadata
chore: add LICENSE and FUNDING.yml
2026-03-07 10:37:16 -05:00
Chris Farhood 394c8396c7 chore: add FUNDING.yml 2026-03-07 08:03:03 -05:00
Chris Farhood fff99c03ba chore: add Apache-2.0 LICENSE file 2026-03-07 08:03:03 -05:00
DevContainer User a79b7be961 docs: add architecture decision records for error boundaries and hooks architecture
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 13:49:54 +00:00
DevContainer User e755f69023 Add artifacthub-headlamp agent skill
Adds Claude Code agent skill for ArtifactHub metadata and publishing,
sourced from headlamp-agent-skills repository.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:32:16 +00:00
DevContainer User 4c378015eb release: v0.2.22
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:13:05 +00:00
DevContainer User 9d9bc5f22f fix: remove any types, dead code, unused exports; add comprehensive tests
- Fix handleRotate bug ignoring Result from rotateSealedSecret()
- Fix dead code branch in useControllerHealth
- Replace all `any` types with `unknown` + type guards
- Delete unused functions/exports (452 lines removed)
- Add 18 new test files covering all hooks, libs, and components
- 233 tests passing, zero tsc errors, zero lint issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:13:00 +00:00
65 changed files with 17122 additions and 18937 deletions
+241
View File
@@ -0,0 +1,241 @@
---
name: artifacthub-headlamp
description: Use when working with ArtifactHub metadata, releases, or publishing for Headlamp plugins. Covers artifacthub-repo.yml, artifacthub-pkg.yml, Headlamp-specific annotations, and the release-to-publish workflow.
tools: Read, Write, Edit, Glob, Grep, Bash
model: sonnet
---
You are an expert in publishing Headlamp Kubernetes dashboard plugins to ArtifactHub. You understand exactly how ArtifactHub discovers and indexes Headlamp plugins, what metadata is required, and how the release workflow feeds into ArtifactHub listings.
Before editing any metadata files, read the existing `artifacthub-repo.yml`, `artifacthub-pkg.yml`, and `package.json` to understand the current state.
---
## How ArtifactHub Works (Critical Mental Model)
ArtifactHub is a **pull-based, read-only registry**. It periodically scrapes registered GitHub repositories for metadata. There is:
- **NO push API** — you cannot push packages to ArtifactHub
- **NO reconciliation trigger** — you cannot force ArtifactHub to re-scan
- **NO upload endpoint** — tarballs are hosted on GitHub Releases, not ArtifactHub
- **NO webhook integration** — ArtifactHub polls on its own schedule (~30 min)
**The only interface is two YAML files committed to git.** ArtifactHub reads them, and that's it.
---
## Repository Registration
### artifacthub-repo.yml (root of repo)
This file registers the GitHub repository with ArtifactHub. Created once, rarely changed.
```yaml
# Artifact Hub repository metadata file
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
repositoryID: <uuid> # Assigned by ArtifactHub when you add the repo via the web UI
owners:
- name: <github-username-or-org>
email: <email>
```
**How to get the repositoryID:**
1. Log into artifacthub.io
2. Go to Control Panel → Repositories → Add
3. Select repository kind: "Headlamp plugins"
4. Provide the GitHub repo URL
5. ArtifactHub generates the UUID — copy it into this file
You do NOT generate this UUID yourself. It comes from ArtifactHub's web UI.
---
## Package Metadata
### artifacthub-pkg.yml (root of repo)
This is the primary metadata file that defines how the plugin appears on ArtifactHub. Updated with each release.
```yaml
version: "X.Y.Z" # MUST match package.json version
name: <package-name> # npm package name from package.json
displayName: <Human Readable Name> # Shown on ArtifactHub listing
createdAt: "YYYY-MM-DDTHH:MM:SSZ" # ISO 8601 — update each release
description: >-
Multi-line description of what the plugin does.
Be specific about features and requirements.
license: Apache-2.0
homeURL: https://github.com/<owner>/<repo>
appVersion: "X.Y.Z" # Version of upstream project (optional)
category: <category> # See categories below
keywords:
- headlamp
- kubernetes
- <plugin-specific>
maintainers:
- name: <name>
email: <email>
provider:
name: <name>
links:
- name: GitHub
url: https://github.com/<owner>/<repo>
- name: Issues
url: https://github.com/<owner>/<repo>/issues
changes: # Changelog for this version
- kind: added|changed|fixed|removed
description: "What changed"
annotations: # CRITICAL — Headlamp-specific
headlamp/plugin/archive-url: "https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz"
headlamp/plugin/archive-checksum: "sha256:<checksum>"
headlamp/plugin/version-compat: ">=X.Y.Z"
headlamp/plugin/distro-compat: "<targets>"
```
---
## Headlamp-Specific Annotations (Required)
These annotations in `artifacthub-pkg.yml` are what make ArtifactHub treat the package as a Headlamp plugin:
### headlamp/plugin/archive-url
**Required.** Direct download URL to the plugin tarball on GitHub Releases.
Format: `https://github.com/<owner>/<repo>/releases/download/v<VERSION>/<pkgname>-<VERSION>.tar.gz`
- The tarball is built by `npx @kinvolk/headlamp-plugin build` and then `npx @kinvolk/headlamp-plugin package`
- The `<pkgname>` comes from `package.json` `name` field
- The tarball is uploaded as a GitHub Release asset — NOT to ArtifactHub
### headlamp/plugin/archive-checksum
**Recommended.** SHA256 checksum of the tarball.
Format: `sha256:<hex-digest>`
Generated via: `sha256sum <tarball> | awk '{print $1}'`
Can be empty string if not yet computed (release workflow fills it in).
### headlamp/plugin/version-compat
**Required.** Minimum Headlamp version the plugin works with.
Format: `>=X.Y.Z` (e.g., `>=0.20.0`, `>=0.26`)
### headlamp/plugin/distro-compat
**Required.** Comma-separated list of supported Headlamp deployment targets.
Valid values:
- `in-cluster` — Headlamp running inside a Kubernetes cluster
- `web` — Web-based Headlamp deployment
- `app` — Headlamp desktop application (Electron)
- `desktop` — Alias for desktop app
- `docker-desktop` — Docker Desktop Headlamp extension
Example: `"in-cluster,web,app"`
---
## ArtifactHub Categories
Valid `category` values for Headlamp plugins:
- `security` — Secrets, RBAC, policy enforcement
- `storage` — CSI drivers, persistent volumes, Ceph/Rook
- `monitoring-logging` — Metrics, GPU monitoring, observability
- `networking` — Load balancers, virtual IPs, ingress
---
## Optional Fields
### containersImages
For plugins associated with a specific container/operator:
```yaml
containersImages:
- name: <component-name>
image: docker.io/<org>/<image>:<tag>
```
### recommendations
Link to related ArtifactHub packages:
```yaml
recommendations:
- url: https://artifacthub.io/packages/helm/<repo>/<chart>
```
### install
Custom installation instructions (markdown):
```yaml
install: |
## Install via Headlamp Plugin Manager
...
```
### logoPath
Path to a logo image file in the repo (relative to root).
---
## The Release → ArtifactHub Pipeline
This is the actual flow. There is NO other way to publish:
```
1. Developer triggers release workflow (workflow_dispatch with version)
2. CI runs tests
3. Workflow updates:
- package.json (npm version)
- artifacthub-pkg.yml (version, archive-url, checksum, createdAt, changes)
4. Plugin is built: npx @kinvolk/headlamp-plugin build
5. Plugin is packaged: creates <pkgname>-<version>.tar.gz
6. SHA256 checksum is computed and written to artifacthub-pkg.yml
7. Changes committed to main
8. Git tag created: v<version>
9. GitHub Release created with tarball attached
10. ArtifactHub polls the repo (~30 min) and picks up the new metadata
11. Plugin appears/updates on artifacthub.io
```
**Key points:**
- Steps 1-9 happen in your GitHub Actions workflow
- Step 10 is entirely controlled by ArtifactHub — you cannot trigger it
- The tarball lives on GitHub Releases, not ArtifactHub
- ArtifactHub only reads `artifacthub-pkg.yml` to discover the download URL
---
## Common Mistakes to Avoid
1. **Trying to push/trigger ArtifactHub** — There is no API for this. Just commit metadata and wait.
2. **Version mismatch**`version` in `artifacthub-pkg.yml` MUST match `package.json`. The release workflow should update both.
3. **Wrong archive-url** — Must point to the actual GitHub Release asset URL. Verify the tarball filename matches what the build produces.
4. **Missing checksum** — While optional, missing checksums may cause warnings. The release workflow should compute and write it.
5. **Forgetting createdAt** — Must be updated each release. ArtifactHub uses this for sorting.
6. **Stale changes section** — The `changes` list should reflect the current version's changelog only, not cumulative history.
7. **Assuming ArtifactHub hosts anything** — It's an index/catalog. All artifacts are hosted elsewhere (GitHub Releases).
8. **Trying to generate repositoryID** — This UUID comes from ArtifactHub's web UI when you register the repo. Don't make one up.
---
## Tarball Structure
The plugin tarball built by `@kinvolk/headlamp-plugin` contains:
```
<pkgname>/
main.js # Bundled plugin code
package.json # Plugin metadata
```
The `<pkgname>` directory inside the tarball matches the `name` field from `package.json`.
---
## Validating Metadata
Before committing, check:
1. `version` matches across `package.json` and `artifacthub-pkg.yml`
2. `archive-url` version tag matches the `version` field
3. `name` in `artifacthub-pkg.yml` matches `package.json` `name`
4. `createdAt` is a valid ISO 8601 timestamp
5. All required annotations are present
6. `changes` entries use valid `kind` values: `added`, `changed`, `fixed`, `removed`
+1
View File
@@ -0,0 +1 @@
github: [privilegedescalation]
+2 -30
View File
@@ -5,37 +5,9 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
workflow_call:
jobs:
ci:
runs-on: local-ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Lint
run: npm run lint
- name: Type-check
run: npm run tsc
- name: Format check
run: npm run format:check
- name: Run tests
run: npm test
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
+20
View File
@@ -0,0 +1,20 @@
name: Dual Approval (CTO + QA)
# Calls the shared dual-approval-check workflow.
# Passes when both privilegedescalation-cto and privilegedescalation-qa
# have approved the PR. Add "Dual Approval (CTO + QA)" to required_status_checks
# in branch protection to enforce this gate.
on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
branches: [main]
types: [opened, reopened, synchronize]
jobs:
dual-approval:
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
secrets: inherit
with:
pr_number: ${{ github.event.pull_request.number }}
+9 -95
View File
@@ -7,105 +7,19 @@ on:
description: 'Release version (e.g. 1.0.0)'
required: true
type: string
repository_dispatch:
types: [release]
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
pull-requests: write
jobs:
ci:
uses: ./.github/workflows/ci.yaml
release:
needs: ci
runs-on: local-ubuntu-latest
timeout-minutes: 10
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
secrets:
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
with:
version: ${{ inputs.version || github.event.client_payload.version }}
steps:
- name: Validate version format
run: |
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in X.Y.Z format"
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Configure Git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Update version in package.json
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
- name: Update artifacthub-pkg.yml
run: |
VERSION="${{ inputs.version }}"
PKG_NAME=$(jq -r .name package.json)
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npx @kinvolk/headlamp-plugin build
- name: Package plugin
run: npx @kinvolk/headlamp-plugin package
- name: Prepare release tarball
run: |
VERSION="${{ inputs.version }}"
PKG_NAME=$(jq -r .name package.json)
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "Error: Expected tarball $TARBALL not found"
ls -la *.tar.gz 2>/dev/null || echo "No .tar.gz files found"
exit 1
fi
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
- name: Validate tarball
run: |
echo "Tarball: ${{ env.TARBALL }}"
ls -lh "${{ env.TARBALL }}"
tar -tzf "${{ env.TARBALL }}" | head -20
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
- name: Compute checksum
run: |
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
- name: Commit and tag
run: |
VERSION="${{ inputs.version }}"
git add package.json package-lock.json artifacthub-pkg.yml
git commit -m "release: v${VERSION}"
git tag "v${VERSION}"
git push origin main --tags
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ inputs.version }}
files: ${{ env.TARBALL }}
fail_on_unmatched_files: true
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6
View File
@@ -23,3 +23,9 @@ Thumbs.db
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# E2E
.env.e2e
e2e/.auth/state.json
playwright-report/
test-results/
+48 -2
View File
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0] - 2026-03-24
### Added
- Explicit `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `react`, and `react-dom` devDependencies so tests run reliably without relying on transitive hoisting
### Changed
- Bump to v1.0.0 — stable public API, comprehensive test coverage, ArtifactHub-only installation
### Fixed
- Removed `install-plugin.sh` custom install script in compliance with ArtifactHub-only installation policy
## [0.2.24] - 2026-03-19
### Fixed
- Added npm overrides for `tar` (>=7.5.11) and `undici` (>=7.24.3) to resolve security advisories
- Added `pull-requests: write` permission to release workflow to unblock PR creation
### Changed
- Added ArtifactHub-only installation policy (INSTALLATION_POLICY.md)
- Removed manual install instructions from README
- Dependency bumps: `tar` 7.5.7→7.5.11, `undici` 7.14.0→7.24.4, `rollup` 4.46.3→4.59.0, `minimatch` 3.1.2→3.1.5, `qs` 6.14.1→6.15.0, `storybook` 9.1.17→9.1.20
## [0.2.23] - 2026-03-09
### Changed
- Internal release-pipeline stabilization (re-release of v0.2.22 fixes)
## [0.2.22] - 2026-03-09
### Added
- Architecture decision records for error boundaries and hooks architecture
### Fixed
- Removed remaining `any` types, dead code, and unused exports; added comprehensive tests
- Added missing `archive-checksum` annotation to `artifacthub-pkg.yml`
- Upstream `appVersion` tracking in release workflow (automatically syncs sealed-secrets controller version)
- Package renamed to `headlamp-sealed-secrets` on ArtifactHub for discoverability
- Added `FUNDING.yml` and Apache-2.0 `LICENSE` file
### Changed
- Enhanced Renovate configuration
## [0.2.21] - 2026-03-04
### Added
@@ -126,11 +168,15 @@ Version 0.2.3 was published but with checksum mismatch on Artifact Hub. Supersed
- Dependencies: node-forge for cryptography
- Compatible with Headlamp v0.13.0+
[Unreleased]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.21...HEAD
[Unreleased]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.24...v1.0.0
[0.2.24]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.23...v0.2.24
[0.2.23]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.22...v0.2.23
[0.2.22]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.21...v0.2.22
[0.2.21]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.20...v0.2.21
[0.1.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.1.0
[0.2.4]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.4
[0.2.3]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.3
[0.2.2]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.2
[0.2.1]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.1
[0.2.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.0
[0.1.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.1.0
+24
View File
@@ -0,0 +1,24 @@
# Installation Policy
## Approved Installation Method
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
No other installation method is acceptable. This includes but is not limited to:
- Direct installation from GitHub release assets
- Manual npm pack / tarball extraction
- initContainer workarounds that bypass Artifact Hub
- Direct file copy or sidecar injection
## Enforcement
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
## Rationale
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
---
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
+73
View File
@@ -0,0 +1,73 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
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.
+1 -28
View File
@@ -25,34 +25,8 @@ A comprehensive [Headlamp](https://headlamp.dev) plugin for managing [Bitnami Se
### Installation
#### Option 1: Headlamp Plugin Manager (Recommended)
Browse the Headlamp Plugin Manager (Settings → Plugins → Catalog) and install **sealed-secrets** directly.
#### Option 2: Manual Tarball Install
Download the latest tarball from the [Releases page](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases), then extract it into your Headlamp plugins directory:
```bash
# macOS
tar -xzf sealed-secrets-*.tar.gz -C ~/Library/Application\ Support/Headlamp/plugins/
# Linux
tar -xzf sealed-secrets-*.tar.gz -C ~/.config/Headlamp/plugins/
# Restart Headlamp after installing
```
#### Option 3: Build from Source
```bash
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin.git
cd headlamp-sealed-secrets-plugin
npm install
npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
```
### First Secret
```bash
@@ -177,7 +151,7 @@ Plaintext values never leave your browser.
| Network sniffing | No plaintext on network | ✅ Protected |
| Compromised proxy | Only sees encrypted data | ✅ Protected |
| Browser XSS | Headlamp CSP policies | ⚠️ Standard web security |
| Supply chain | Package locks, dependabot | ⚠️ Ongoing monitoring |
| Supply chain | Package locks, Renovate | ⚠️ Ongoing monitoring |
See: [ADR 003: Client-Side Encryption](docs/architecture/adr/003-client-side-crypto.md)
@@ -321,4 +295,3 @@ Built with:
# Test runner
+1 -1
View File
@@ -70,7 +70,7 @@ Key dependencies with security implications:
- **node-forge**: Used for client-side encryption of secret values with the cluster's sealing certificate. Keep this dependency up to date.
- **@kinvolk/headlamp-plugin**: Peer dependency providing the Kubernetes API proxy. Update by upgrading your Headlamp installation.
The project uses `npm audit` and Dependabot to monitor for known vulnerabilities.
The project uses `npm audit` and Renovate to monitor for known vulnerabilities.
## Contact
+20 -22
View File
@@ -1,13 +1,13 @@
# Artifact Hub package metadata file
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
version: "0.2.21"
name: sealed-secrets
version: "1.0.2"
name: headlamp-sealed-secrets
displayName: Sealed Secrets
createdAt: "2026-02-12T00:00:00Z"
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
license: Apache-2.0
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
appVersion: 0.2.21
appVersion: "0.36.1"
containersImages:
- name: sealed-secrets-controller
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
@@ -19,7 +19,8 @@ keywords:
- encryption
- security
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.21/sealed-secrets-0.2.21.tar.gz"
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v1.0.2/sealed-secrets-1.0.2.tar.gz"
headlamp/plugin/archive-checksum: sha256:0eaf34d380d133120d3a50c890e0c96b23717427887b1f23377a841cb3783b11
headlamp/plugin/version-compat: ">=0.13.0"
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
links:
@@ -34,31 +35,19 @@ install: |
### Prerequisites
1. Headlamp v0.13.0 or later
1. [Headlamp](https://headlamp.dev) v0.13.0 or later
2. Sealed Secrets controller installed on your cluster:
```bash
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
```
### Install the Plugin
### Install via Headlamp Plugin Catalog
#### Option 1: From NPM
```bash
npm install -g headlamp-sealed-secrets
```
1. Open Headlamp and navigate to **Settings → Plugin Catalog**
2. Search for **"Sealed Secrets"**
3. Click **Install** and restart Headlamp when prompted
#### Option 2: Build from Source
```bash
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
cd headlamp-sealed-secrets-plugin
npm install
npm run build
```
Then copy the `dist` folder to your Headlamp plugins directory:
- **Linux**: `~/.config/Headlamp/plugins/headlamp-sealed-secrets/`
- **macOS**: `~/Library/Application Support/Headlamp/plugins/headlamp-sealed-secrets/`
- **Windows**: `%APPDATA%\Headlamp\plugins\headlamp-sealed-secrets\`
The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-sealed-secrets).
## Usage
@@ -69,6 +58,15 @@ install: |
- Configure controller settings
For detailed usage instructions, see the [README](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/blob/main/README.md).
changes:
- kind: fixed
description: "Fix ArtifactHub checksum — release workflow now computes checksums after rebuilding tarball"
- kind: changed
description: "Bump to v1.0.0 — stable public release with comprehensive tests, ArtifactHub-only installation, and full RBAC-aware UI"
- kind: added
description: Explicit vitest and @testing-library devDependencies for reliable test execution
- kind: fixed
description: Removed install-plugin.sh custom install script (ArtifactHub-only policy)
maintainers:
- name: privilegedescalation
email: privilegedescalation@users.noreply.github.com
+1 -1
View File
@@ -1,6 +1,6 @@
# Artifact Hub repository metadata file
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
repositoryID: 5574d37c-c4ae-45ab-a378-ef24aaba5b4c
repositoryID: 3d4645ad-d227-4fc0-8cae-8f8ee7794da2
owners:
- name: privilegedescalation
email: privilegedescalation@users.noreply.github.com
@@ -349,7 +349,7 @@ Added type safety:
**Supply Chain**:
- Risk: Compromised node-forge dependency
- Mitigation: Package lock, dependabot, regular audits
- Mitigation: Package lock, Renovate, regular audits
- Same risk as any JavaScript dependency
**Browser Extensions**:
@@ -0,0 +1,151 @@
# ADR 006: Error Boundary with Dual Variants
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
The Sealed Secrets plugin registers components at two distinct integration points in Headlamp:
1. **Route-level**: Full-page views (`SealedSecretList`, `SealingKeysView`) registered via `registerRoute`
2. **Section-level**: Injected detail sections (`SecretDetailsSection`) registered via `registerDetailsViewSection`
Each integration point has different error recovery requirements:
- **Route-level errors** typically stem from API connectivity issues (controller not found, RBAC misconfiguration). Users need troubleshooting guidance and a retry mechanism.
- **Section-level errors** are isolated failures within a host page. The error should be contained without disrupting the rest of the detail view. A simple reload is sufficient.
A single error boundary class cannot serve both needs because the error messaging, recovery actions, and visual treatment differ significantly.
---
## Decision
Implement a `BaseErrorBoundary` abstract class with a `renderError()` template method, then derive two concrete variants:
- **`ApiErrorBoundary`**: Used at route level. Displays connectivity troubleshooting guidance (check controller namespace, RBAC permissions, pod status) with a Retry button that resets the error state.
- **`GenericErrorBoundary`**: Used at section level. Displays a compact error message with a Reload button. Designed to fail gracefully without affecting the parent detail page.
Both variants use `getDerivedStateFromError` for error capture and expose a reset mechanism via `setState({ hasError: false })`.
```typescript
abstract class BaseErrorBoundary extends React.Component<Props, State> {
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
abstract renderError(error: Error): React.ReactNode;
render() {
if (this.state.hasError) {
return this.renderError(this.state.error);
}
return this.props.children;
}
}
```
---
## Consequences
### Positive
**Appropriate error recovery**: Each integration point gets tailored error messages and recovery actions
**Fault isolation**: Section-level errors don't crash the entire detail page
**Shared base class**: Common error capture logic is defined once in `BaseErrorBoundary`
**Consistent with React patterns**: Error boundaries are the recommended React mechanism for catching render errors
### Negative
⚠️ **Class components required**: React error boundaries must be class components, breaking the otherwise all-functional-component convention
⚠️ **Two components to maintain**: Changes to error handling patterns must be applied to both variants
### Mitigation
- The class component exception is documented and limited to `ErrorBoundary.tsx`
- Both variants share `BaseErrorBoundary`, so common logic changes propagate automatically
---
## Alternatives Considered
### 1. Single generic error boundary
**Pros**:
- Simpler — one component for all uses
- Less code to maintain
**Cons**:
- Cannot provide context-specific troubleshooting guidance
- Route-level errors need different recovery UX than section-level errors
- Generic messages are unhelpful for API connectivity issues
**Rejected**: The error recovery requirements differ too much between route and section contexts.
---
### 2. try/catch in each component
**Pros**:
- No class components needed
- Per-component error handling
**Cons**:
- Cannot catch render-phase errors (React limitation)
- Duplicated error handling logic across every component
- Inconsistent error UX
**Rejected**: React error boundaries are the only mechanism for catching render errors.
---
### 3. React error boundary library (react-error-boundary)
**Pros**:
- Functional component API via `ErrorBoundary` wrapper
- Built-in reset mechanisms
- Well-maintained
**Cons**:
- External dependency not available in plugin runtime
- Plugin cannot add npm dependencies beyond Headlamp peer dependencies
**Rejected**: Dependency constraint makes this infeasible.
---
## Implementation
- `ApiErrorBoundary` wraps `SealedSecretList` and `SealingKeysView` in `index.tsx`
- `GenericErrorBoundary` wraps `SecretDetailsSection` in `index.tsx`
- Both are defined in `src/components/ErrorBoundary.tsx`
- Uses MUI `Alert`, `Box`, `Button`, `Typography` for styled error display
---
## References
- [React Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
- [Headlamp Plugin Registration API](https://headlamp.dev/docs/latest/development/plugins/)
---
## Related ADRs
- [ADR 005: Custom React Hooks](005-react-hooks-extraction.md) — Hooks architecture that error boundaries wrap
---
## Changelog
- **2026-03-05**: Initial decision
@@ -0,0 +1,157 @@
# ADR 007: Custom Hooks Architecture vs Data Context
**Status**: Accepted
**Date**: 2026-03-05
**Deciders**: Development Team
---
## Context
All other Headlamp plugins in this project family (polaris, rook, intel-gpu, kube-vip, tns-csi) use a single React Context provider (`*DataProvider`) to centralize data fetching and share state across components. This is the established pattern.
The Sealed Secrets plugin has different requirements:
1. **Multiple independent data domains**: Controller health, RBAC permissions, SealedSecret CRUD, and encryption are logically separate concerns with different lifecycles.
2. **CRD class extension**: `SealedSecret` extends Headlamp's `KubeObject` class, providing its own `useList()` hook — making a centralized fetch redundant for the primary resource.
3. **Write-heavy workflows**: Unlike read-only plugins, sealed-secrets creates, encrypts, and rotates resources. The encryption workflow involves multi-step state (certificate fetch → encrypt → create resource).
4. **Independent refresh cadences**: Controller health polls every 30 seconds; SealedSecret list is reactive via `useList()`; RBAC checks run once on mount.
A single context provider would either become a monolithic "god context" or force artificial coupling between unrelated concerns.
---
## Decision
Use **independent custom hooks** instead of a shared data context:
- **`useControllerHealth(autoRefresh?, intervalMs?)`**: Polls controller `/healthz` endpoint. Returns `{ healthy, checking, error, refresh }`.
- **`usePermissions()`**: Queries RBAC capabilities on mount. Returns permission flags for create, delete, encrypt operations.
- **`useSealedSecretEncryption()`**: Orchestrates the encryption workflow (fetch cert → encrypt values → build manifest). Returns workflow state and action functions.
- **`SealedSecret.useList()`**: Headlamp's built-in `KubeObject.useList()` — reactive to cluster changes, no custom fetch needed.
Each hook manages its own loading, error, and refresh state. Components compose multiple hooks as needed.
```typescript
function SealedSecretList() {
const [secrets, error] = SealedSecret.useList();
const { healthy } = useControllerHealth(true);
const { canCreate } = usePermissions();
// Each concern is independent
}
```
---
## Consequences
### Positive
**Separation of concerns**: Each hook encapsulates a single domain (health, permissions, encryption, CRUD)
**Independent lifecycles**: Controller health polls at 30s; RBAC checks once; list is reactive — no unnecessary coupling
**Composable**: Components pick only the hooks they need, avoiding unnecessary data in scope
**Testable in isolation**: Each hook can be unit-tested independently without mocking an entire context provider
**Leverages Headlamp's KubeObject**: `SealedSecret.useList()` provides reactive list updates without custom fetch logic
### Negative
⚠️ **Diverges from project convention**: Other plugins use the `*DataProvider` pattern — contributors must learn a different approach for this plugin
⚠️ **No single source of truth**: State is distributed across hooks rather than centralized — harder to debug "what data does the plugin have right now?"
⚠️ **Potential duplicate fetches**: If two components both call `useControllerHealth()`, the health endpoint is polled twice
### Mitigation
- The convention divergence is documented in `CLAUDE.md` and this ADR
- Controller health polling is lightweight (single `/healthz` call)
- `SealedSecret.useList()` is internally deduplicated by Headlamp's hook system
---
## Alternatives Considered
### 1. Single SealedSecretsDataProvider context
**Pros**:
- Consistent with other plugins in the project
- Single source of truth for all sealed-secrets data
- Deduplicates fetches automatically
**Cons**:
- Would become a "god context" with 10+ fields spanning unrelated concerns
- All consumers re-render when any field changes (health poll triggers list re-render)
- Encryption workflow state doesn't belong in shared context (it's dialog-scoped)
- `SealedSecret.useList()` already provides reactive CRUD — wrapping it in context adds indirection
**Rejected**: The data domains are too independent; a single context would create artificial coupling.
---
### 2. Multiple specialized contexts
**Pros**:
- Separation of concerns (like hooks)
- Consistent with React Context pattern
**Cons**:
- Three or four nested providers in `index.tsx` — deep nesting
- More boilerplate than hooks (provider + context + consumer hook per domain)
- No benefit over standalone hooks when providers don't need to share state
**Rejected**: Contexts add boilerplate without benefit when data domains are independent.
---
### 3. State management library (Zustand, Jotai)
**Pros**:
- Lightweight, no provider nesting
- Built-in deduplication and memoization
**Cons**:
- External dependency not available in plugin runtime
- Plugins cannot add npm dependencies beyond Headlamp peer dependencies
**Rejected**: Dependency constraint makes this infeasible.
---
## Implementation
```
src/hooks/
├── useControllerHealth.ts # Health polling with configurable interval
├── usePermissions.ts # RBAC capability check (runs once)
└── useSealedSecretEncryption.ts # Multi-step encryption workflow
```
- Components in `src/components/` import hooks directly
- No provider wrapping needed in `index.tsx` (except error boundaries)
- `SealedSecret` class in `src/lib/SealedSecretCRD.ts` extends `KubeObject` for `useList()`/`useGet()`
---
## References
- [React Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks)
- [Headlamp KubeObject API](https://headlamp.dev/docs/latest/development/api/classes/lib_k8s_cluster.KubeObject/)
---
## Related ADRs
- [ADR 005: Custom React Hooks](005-react-hooks-extraction.md) — Details the hook extraction process
- [ADR 006: Dual Error Boundaries](006-dual-error-boundaries.md) — Error handling that wraps hook-based components
---
## Changelog
- **2026-03-05**: Initial decision
+2
View File
@@ -26,6 +26,8 @@ Each ADR follows this structure:
| [003](003-client-side-crypto.md) | Client-Side Encryption | Accepted | 2026-02-11 |
| [004](004-rbac-integration.md) | RBAC-Aware UI | Accepted | 2026-02-11 |
| [005](005-react-hooks-extraction.md) | Custom React Hooks | Accepted | 2026-02-12 |
| [006](006-dual-error-boundaries.md) | Error Boundary with Dual Variants | Accepted | 2026-03-05 |
| [007](007-hooks-vs-context.md) | Custom Hooks Architecture vs Data Context | Accepted | 2026-03-05 |
## Creating New ADRs
View File
+81
View File
@@ -0,0 +1,81 @@
import { test as setup, expect, Page } from '@playwright/test';
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
// Navigate to login — Headlamp redirects / to /c/main/login
await page.goto('/');
await page.waitForURL('**/login');
// Click "Sign In" and capture the Authentik popup
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: /sign in/i }).click();
const popup = await popupPromise;
// Wait for the Authentik popup to fully load before interacting
await popup.waitForLoadState('domcontentloaded');
await popup.waitForLoadState('networkidle');
// Authentik step 1: fill username — wait for the form to render
const usernameField = popup.getByRole('textbox', { name: /email or username/i });
await usernameField.waitFor({ state: 'visible', timeout: 15_000 });
await usernameField.fill(username);
await popup.getByRole('button', { name: /log in/i }).click();
// Authentik step 2: fill password — wait for the next step to load
await popup.waitForLoadState('networkidle');
const passwordField = popup.getByRole('textbox', { name: /password/i });
await passwordField.waitFor({ state: 'visible', timeout: 15_000 });
await passwordField.fill(password);
await popup.getByRole('button', { name: /continue|log in/i }).click();
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
await popup.waitForEvent('close', { timeout: 15_000 });
// Original page should now be authenticated — wait for sidebar
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
async function authenticateWithToken(page: Page, token: string): Promise<void> {
await page.goto('/');
// Headlamp goes to /token directly when no OIDC is configured,
// or through /login when OIDC is configured
await page.waitForURL(/\/(login|token)$/);
if (page.url().includes('/login')) {
// OIDC login page — click "use a token" to reach token auth.
const useTokenBtn = page.getByRole('button', { name: /use a token/i });
await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 });
await useTokenBtn.click();
await page.waitForURL('**/token');
}
// Fill the "ID token" field and submit
await page.getByRole('textbox', { name: /id token/i }).fill(token);
await page.getByRole('button', { name: /authenticate/i }).click();
// Wait for the main UI to load
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
timeout: 15_000,
});
}
setup('authenticate with Headlamp', async ({ page }) => {
const username = process.env.AUTHENTIK_USERNAME;
const password = process.env.AUTHENTIK_PASSWORD;
const token = process.env.HEADLAMP_TOKEN;
if (username && password) {
await authenticateWithOIDC(page, username, password);
} else if (token) {
await authenticateWithToken(page, token);
} else {
throw new Error(
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
);
}
await page.context().storageState({ path: AUTH_STATE_PATH });
});
+88
View File
@@ -0,0 +1,88 @@
import { test, expect } from '@playwright/test';
test.describe('Sealed Secrets plugin smoke tests', () => {
test('sidebar contains sealed-secrets entry', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar.getByRole('button', { name: /sealed.secrets/i })).toBeVisible();
});
test('sidebar sealed-secrets entry is clickable and navigates to list view', async ({ page }) => {
await page.goto('/');
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
await expect(sidebar).toBeVisible({ timeout: 15_000 });
const sealedSecretsEntry = sidebar.getByRole('button', { name: /sealed.secrets/i });
await expect(sealedSecretsEntry).toBeVisible();
await sealedSecretsEntry.click();
await expect(page).toHaveURL(/\/sealedsecrets/);
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible();
});
test('sealed secrets list page renders table or empty state', async ({ page }) => {
await page.goto('/c/main/sealedsecrets');
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({
timeout: 15_000,
});
// Either a populated table or an empty-state indicator must be visible
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page
.locator('text=/no.*sealed|no.*secret|0 item|empty/i')
.first()
.isVisible()
.catch(() => false);
expect(hasTable || hasEmptyState).toBe(true);
});
test('sealing keys page renders table or empty state', async ({ page }) => {
await page.goto('/c/main/sealedsecrets/keys');
await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible({
timeout: 15_000,
});
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
const hasEmptyState = await page
.locator('text=/no.*key|0 item|empty/i')
.first()
.isVisible()
.catch(() => false);
expect(hasTable || hasEmptyState).toBe(true);
});
test('navigation between sealed-secrets views works', async ({ page }) => {
await page.goto('/c/main/sealedsecrets');
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({
timeout: 15_000,
});
// Navigate to Sealing Keys via sidebar
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
const keysLink = sidebar.getByRole('link', { name: /sealing.key/i });
await expect(keysLink).toBeVisible();
await keysLink.click();
await expect(page).toHaveURL(/\/sealedsecrets\/keys$/);
await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible();
// Navigate back to All Sealed Secrets
const allSecretsLink = sidebar.getByRole('link', { name: /all sealed secrets/i });
await expect(allSecretsLink).toBeVisible();
await allSecretsLink.click();
await expect(page).toHaveURL(/\/sealedsecrets(?!\/keys)/);
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible();
});
test('plugin settings page shows sealed-secrets plugin entry', async ({ page }) => {
await page.goto('/settings/plugins');
// Wait for plugin list to load — plugin scripts load asynchronously
const pluginEntry = page.locator('text=sealed-secrets').first();
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
});
});
-79
View File
@@ -1,79 +0,0 @@
#!/bin/bash
#
# Install Headlamp Sealed Secrets Plugin
#
# This script builds and installs the plugin to your local Headlamp installation.
#
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Headlamp Sealed Secrets Plugin Installer${NC}"
echo "=========================================="
echo
# Detect OS and set plugin directory
if [[ "$OSTYPE" == "darwin"* ]]; then
PLUGIN_DIR="$HOME/Library/Application Support/Headlamp/plugins/headlamp-sealed-secrets"
echo -e "${YELLOW}Detected: macOS${NC}"
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
PLUGIN_DIR="$HOME/.config/Headlamp/plugins/headlamp-sealed-secrets"
echo -e "${YELLOW}Detected: Linux${NC}"
else
echo -e "${RED}Unsupported OS: $OSTYPE${NC}"
echo "For Windows, please see HEADLAMP_INSTALLATION.md"
exit 1
fi
echo "Plugin will be installed to: $PLUGIN_DIR"
echo
# Check if node/npm are available
if ! command -v npm &> /dev/null; then
echo -e "${RED}Error: npm is not installed${NC}"
echo "Please install Node.js and npm first"
exit 1
fi
# Navigate to plugin directory
cd "$(dirname "$0")"
echo -e "${GREEN}Step 1: Installing dependencies...${NC}"
npm install
echo
echo -e "${GREEN}Step 2: Building plugin...${NC}"
npm run build
echo
echo -e "${GREEN}Step 3: Creating plugin directory...${NC}"
mkdir -p "$PLUGIN_DIR"
echo
echo -e "${GREEN}Step 4: Copying plugin files...${NC}"
cp -v dist/main.js "$PLUGIN_DIR/"
cp -v package.json "$PLUGIN_DIR/"
cp -v README.md "$PLUGIN_DIR/" 2>/dev/null || true
cp -v LICENSE "$PLUGIN_DIR/" 2>/dev/null || true
echo
echo -e "${GREEN}✓ Installation complete!${NC}"
echo
echo "Plugin installed to: $PLUGIN_DIR"
echo
echo "Next steps:"
echo "1. Restart Headlamp desktop application"
echo "2. Open Headlamp and connect to your cluster"
echo "3. Look for 'Sealed Secrets' in the sidebar"
echo
echo "To verify sealed-secrets controller is installed:"
echo " kubectl get pods -n kube-system -l name=sealed-secrets-controller"
echo
echo "To install sealed-secrets controller (if not present):"
echo " kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml"
echo
-18215
View File
File diff suppressed because it is too large Load Diff
+28 -4
View File
@@ -1,6 +1,6 @@
{
"name": "sealed-secrets",
"version": "0.2.21",
"version": "1.0.2",
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
"files": [
"dist",
@@ -17,6 +17,7 @@
"homepage": "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin#readme",
"author": "privilegedescalation",
"license": "Apache-2.0",
"packageManager": "pnpm@10.32.1",
"scripts": {
"start": "headlamp-plugin start",
"build": "headlamp-plugin build",
@@ -28,6 +29,8 @@
"format:check": "prettier --check src/",
"test": "vitest run",
"test:watch": "vitest",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"storybook": "headlamp-plugin storybook",
"storybook-build": "headlamp-plugin storybook-build",
"i18n": "headlamp-plugin i18n",
@@ -47,16 +50,37 @@
"k8s"
],
"overrides": {
"typescript": "5.6.2"
"tar": "^7.5.11",
"undici": "^7.24.3",
"vite": ">=6.4.2",
"lodash": ">=4.18.0",
"elliptic": ">=6.6.1"
},
"dependencies": {
"node-forge": "^1.3.1"
"node-forge": "^1.4.0"
},
"devDependencies": {
"@headlamp-k8s/eslint-config": "^0.6.0",
"@playwright/test": "^1.58.2",
"@iconify/react": "^6.0.2",
"@kinvolk/headlamp-plugin": "^0.13.0",
"@mui/material": "^5.15.14",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/node-forge": "^1.3.11",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"eslint": "^8.57.0",
"jsdom": "^24.0.0",
"notistack": "^3.0.0",
"prettier": "^2.8.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^5.3.0",
"typedoc": "^0.28.16",
"typedoc-plugin-markdown": "^4.10.0"
"typescript": "~5.6.2",
"typedoc-plugin-markdown": "^4.10.0",
"vitest": "^3.2.4"
}
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: {
baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(),
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/state.json',
},
dependencies: ['setup'],
},
],
});
+12192
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,4 +1,5 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
"extends": ["github>privilegedescalation/.github:renovate-config"]
}
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env bash
# deploy-e2e-headlamp.sh
#
# Deploys a stock Headlamp instance with the sealed-secrets plugin loaded via
# a ConfigMap volume mount. No custom Docker images — the plugin is built
# in CI and injected as a ConfigMap.
#
# E2E resources are deployed to the `privilegedescalation-dev` namespace. Nothing
# persists beyond the test run — teardown cleans up all created resources.
#
# Prerequisites:
# - Plugin built (dist/ exists with plugin-main.js + package.json)
# - kubectl configured with cluster access
# - RBAC applied: kubectl apply -f deployment/e2e-ci-runner-rbac.yaml
#
# Environment:
# E2E_NAMESPACE — namespace for E2E Headlamp (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist"
E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
if [ ! -d "$DIST_DIR" ]; then
echo "ERROR: dist/ not found. Run 'pnpm build' first." >&2
exit 1
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 " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
# --- Create ConfigMap from built plugin ---
echo ""
echo "Creating ConfigMap with plugin files..."
# Delete existing ConfigMap if present (idempotent redeploy)
kubectl delete configmap headlamp-sealed-secrets-plugin \
-n "$E2E_NAMESPACE" --ignore-not-found
# Create ConfigMap from dist/ contents and package.json
kubectl create configmap headlamp-sealed-secrets-plugin \
-n "$E2E_NAMESPACE" \
--from-file="$DIST_DIR" \
--from-file=package.json="$REPO_ROOT/package.json"
# --- Tear down any existing E2E deployment for a clean start ---
echo ""
echo "Removing any existing E2E deployment (clean-start)..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
# --- Deploy Headlamp via kubectl apply ---
echo ""
echo "Deploying Headlamp E2E instance..."
kubectl apply -f - <<EOF
apiVersion: v1
kind: ServiceAccount
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
template:
metadata:
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
serviceAccountName: ${E2E_RELEASE}
automountServiceAccountToken: true
securityContext: {}
containers:
- name: headlamp
image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}
imagePullPolicy: IfNotPresent
securityContext:
runAsNonRoot: true
privileged: false
runAsUser: 100
runAsGroup: 101
args:
- "-in-cluster"
- "-in-cluster-context-name=main"
- "-plugins-dir=/headlamp/plugins"
ports:
- name: http
containerPort: 4466
protocol: TCP
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 6
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: sealed-secrets-plugin
mountPath: /headlamp/plugins/headlamp-sealed-secrets
readOnly: true
volumes:
- name: sealed-secrets-plugin
configMap:
name: headlamp-sealed-secrets-plugin
---
apiVersion: v1
kind: Service
metadata:
name: ${E2E_RELEASE}
namespace: ${E2E_NAMESPACE}
labels:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: headlamp
app.kubernetes.io/instance: ${E2E_RELEASE}
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
EOF
echo "Waiting for rollout..."
kubectl rollout status "deployment/${E2E_RELEASE}" \
-n "$E2E_NAMESPACE" --timeout=120s
# --- Generate a service URL for tests ---
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
# --- Wait for DNS and HTTP reachability ---
echo ""
echo "Waiting for ${SVC_URL} to be reachable..."
ATTEMPTS=0
MAX_ATTEMPTS=24 # 24 × 5s = 120s max
until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/dev/null; do
ATTEMPTS=$((ATTEMPTS + 1))
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2
exit 1
fi
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..."
sleep 5
done
echo ""
echo "E2E Headlamp is ready at: ${SVC_URL}"
echo " export HEADLAMP_URL=${SVC_URL}"
# --- Generate a token for test auth ---
echo ""
echo "Creating service account token for E2E auth..."
kubectl create serviceaccount headlamp-e2e-test \
-n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "")
if [ -n "$TOKEN" ]; then
echo " export HEADLAMP_TOKEN=<generated>"
echo ""
echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e"
echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e"
echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN"
else
echo " WARNING: Could not generate token. Set HEADLAMP_TOKEN manually or use OIDC."
fi
echo ""
echo "E2E deployment complete."
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# teardown-e2e-headlamp.sh
#
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
#
# Environment:
# E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev)
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
echo "=== E2E Headlamp Teardown ==="
echo " Namespace: $E2E_NAMESPACE"
echo " Release: $E2E_RELEASE"
echo "Removing Headlamp Deployment, Service, and ServiceAccount..."
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up ConfigMap..."
kubectl delete configmap headlamp-sealed-secrets-plugin -n "$E2E_NAMESPACE" --ignore-not-found
echo "Cleaning up test service account..."
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
# Clean up .env.e2e if present
if [ -f "$REPO_ROOT/.env.e2e" ]; then
rm "$REPO_ROOT/.env.e2e"
echo "Removed .env.e2e"
fi
echo ""
echo "E2E teardown complete."
+137
View File
@@ -0,0 +1,137 @@
/**
* Unit tests for ControllerStatus component
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
// Mock dependencies
vi.mock('@iconify/react', () => ({
Icon: ({ icon }: { icon: string }) => <span data-testid="icon">{icon}</span>,
}));
vi.mock('../hooks/useControllerHealth', () => ({
useControllerHealth: vi.fn(),
}));
vi.mock('./LoadingSkeletons', () => ({
ControllerHealthSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
import { useControllerHealth } from '../hooks/useControllerHealth';
import { ControllerStatus } from './ControllerStatus';
const mockUseHealth = vi.mocked(useControllerHealth);
describe('ControllerStatus', () => {
it('should show skeleton while loading', () => {
mockUseHealth.mockReturnValue({
health: null,
loading: true,
refresh: vi.fn(),
});
render(<ControllerStatus />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show healthy chip when controller is healthy', () => {
mockUseHealth.mockReturnValue({
health: {
healthy: true,
reachable: true,
version: '0.24.0',
latencyMs: 15,
},
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus />);
expect(screen.getByText('Healthy')).toBeDefined();
});
it('should show unhealthy chip when reachable but not healthy', () => {
mockUseHealth.mockReturnValue({
health: {
healthy: false,
reachable: true,
error: 'HTTP 500: Internal Server Error',
},
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus />);
expect(screen.getByText('Unhealthy')).toBeDefined();
});
it('should show unreachable chip when not reachable', () => {
mockUseHealth.mockReturnValue({
health: {
healthy: false,
reachable: false,
error: 'Connection refused',
},
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus />);
expect(screen.getByText('Unreachable')).toBeDefined();
});
it('should show latency and version when showDetails is true and healthy', () => {
mockUseHealth.mockReturnValue({
health: {
healthy: true,
reachable: true,
version: '0.24.0',
latencyMs: 42,
},
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus showDetails />);
expect(screen.getByText('42ms')).toBeDefined();
expect(screen.getByText('v0.24.0')).toBeDefined();
});
it('should not show details when showDetails is false', () => {
mockUseHealth.mockReturnValue({
health: {
healthy: true,
reachable: true,
version: '0.24.0',
latencyMs: 42,
},
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus showDetails={false} />);
expect(screen.getByText('Healthy')).toBeDefined();
expect(screen.queryByText('42ms')).toBeNull();
expect(screen.queryByText('v0.24.0')).toBeNull();
});
it('should pass autoRefresh and interval to hook', () => {
mockUseHealth.mockReturnValue({
health: { healthy: true, reachable: true },
loading: false,
refresh: vi.fn(),
});
render(<ControllerStatus autoRefresh refreshIntervalMs={5000} />);
expect(mockUseHealth).toHaveBeenCalledWith(true, 5000);
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Unit tests for DecryptDialog component
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock notistack
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
// Mock iconify
vi.mock('@iconify/react', () => ({
Icon: ({ icon }: { icon: string }) => <span data-testid="icon">{icon}</span>,
}));
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Secret: {
useGet: vi.fn(),
},
},
},
}));
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {},
}));
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { DecryptDialog } from './DecryptDialog';
const mockUseGetSecret = vi.mocked(K8s.ResourceClasses.Secret.useGet);
describe('DecryptDialog', () => {
const mockSealedSecret = {
metadata: {
name: 'my-secret',
namespace: 'default',
},
} as never;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
// Mock clipboard
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.useRealTimers();
});
it('should show "Secret Not Found" when secret does not exist', () => {
mockUseGetSecret.mockReturnValue([null, null] as never);
render(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="password" onClose={vi.fn()} />
);
expect(screen.getByText('Secret Not Found')).toBeDefined();
});
it('should show "Key Not Found" when key does not exist in secret', () => {
mockUseGetSecret.mockReturnValue([{ data: { other: 'value' } }, null] as never);
render(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="missing-key" onClose={vi.fn()} />
);
expect(screen.getByText('Key Not Found')).toBeDefined();
expect(screen.getByText('missing-key')).toBeDefined();
});
it('should decode and display base64 value', () => {
const encoded = btoa('my-secret-value');
mockUseGetSecret.mockReturnValue([{ data: { password: encoded } }, null] as never);
render(
<DecryptDialog sealedSecret={mockSealedSecret} secretKey="password" onClose={vi.fn()} />
);
expect(screen.getByText(/Decrypted Value: password/)).toBeDefined();
// The value should be in a text field (hidden by default as password type)
expect(screen.getByDisplayValue('my-secret-value')).toBeDefined();
});
it('should show countdown timer', () => {
const encoded = btoa('value');
mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never);
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
expect(screen.getByText(/30 seconds/)).toBeDefined();
});
it('should auto-close after countdown', () => {
const encoded = btoa('value');
mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never);
const onClose = vi.fn();
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={onClose} />);
// Advance 30 seconds
act(() => {
vi.advanceTimersByTime(30000);
});
expect(onClose).toHaveBeenCalled();
});
it('should copy to clipboard', () => {
const encoded = btoa('copy-me');
mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never);
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
// Click copy button
const copyButton = screen.getByLabelText('Copy value to clipboard');
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('copy-me');
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Copied to clipboard', {
variant: 'success',
});
});
it('should toggle show/hide value', () => {
const encoded = btoa('toggle-me');
mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never);
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
// Initially hidden (password type)
const showButton = screen.getByLabelText('Show secret value');
fireEvent.click(showButton);
// Now should show hide button
expect(screen.getByLabelText('Hide secret value')).toBeDefined();
});
it('should close on Close button click', () => {
mockUseGetSecret.mockReturnValue([null, null] as never);
const onClose = vi.fn();
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={onClose} />);
fireEvent.click(screen.getByLabelText('Close dialog'));
expect(onClose).toHaveBeenCalled();
});
it('should show security warning', () => {
const encoded = btoa('value');
mockUseGetSecret.mockReturnValue([{ data: { key: encoded } }, null] as never);
render(<DecryptDialog sealedSecret={mockSealedSecret} secretKey="key" onClose={vi.fn()} />);
expect(screen.getByText(/Security Warning/)).toBeDefined();
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* Unit tests for EncryptDialog component
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock notistack
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
// Mock iconify
vi.mock('@iconify/react', () => ({
Icon: ({ icon }: { icon: string }) => <span data-testid={`icon-${icon}`}>{icon}</span>,
}));
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Namespace: {
useList: vi
.fn()
.mockReturnValue([
[{ metadata: { name: 'default' } }, { metadata: { name: 'production' } }],
]),
},
},
},
}));
// Mock encryption hook
const mockEncrypt = vi.fn();
vi.mock('../hooks/useSealedSecretEncryption', () => ({
useSealedSecretEncryption: () => ({
encrypt: mockEncrypt,
encrypting: false,
}),
}));
// Mock SealedSecretCRD
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {
apiEndpoint: {
post: vi.fn(),
},
},
}));
import { SealedSecret } from '../lib/SealedSecretCRD';
import { EncryptDialog } from './EncryptDialog';
const mockPost = vi.mocked(SealedSecret.apiEndpoint.post);
describe('EncryptDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEncrypt.mockResolvedValue({
ok: true,
value: {
sealedSecretData: { apiVersion: 'bitnami.com/v1alpha1', kind: 'SealedSecret' },
},
});
mockPost.mockResolvedValue({});
});
it('should render dialog when open', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByText('Create Sealed Secret')).toBeDefined();
expect(screen.getByLabelText('Secret name')).toBeDefined();
});
it('should not render when closed', () => {
render(<EncryptDialog open={false} onClose={vi.fn()} />);
expect(screen.queryByText('Create Sealed Secret')).toBeNull();
});
it('should have one key-value pair by default', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByLabelText('Key name 1')).toBeDefined();
});
it('should add key-value pair on button click', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
fireEvent.click(screen.getByLabelText('Add another key-value pair'));
expect(screen.getByLabelText('Key name 2')).toBeDefined();
});
it('should not allow removing last key-value pair', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
const removeButton = screen.getByLabelText('Remove key-value pair 1');
expect(removeButton).toHaveAttribute('disabled');
});
it('should allow removing when multiple pairs exist', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
// Add a pair
fireEvent.click(screen.getByLabelText('Add another key-value pair'));
// Both remove buttons should be enabled
const removeButtons = screen.getAllByLabelText(/Remove key-value pair/);
expect(removeButtons).toHaveLength(2);
// Remove one
fireEvent.click(removeButtons[1]);
expect(screen.queryByLabelText('Key name 2')).toBeNull();
});
it('should call encrypt and post on submit', async () => {
const onClose = vi.fn();
render(<EncryptDialog open onClose={onClose} />);
// Fill in name
const nameInput = screen.getByLabelText('Secret name');
fireEvent.change(nameInput, { target: { value: 'my-secret' } });
// Fill in key-value
fireEvent.change(screen.getByLabelText('Key name 1'), {
target: { value: 'password' },
});
fireEvent.change(screen.getByLabelText(/Secret value for password/), {
target: { value: 'secret123' },
});
// Submit
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(mockEncrypt).toHaveBeenCalledWith(
expect.objectContaining({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'password', value: 'secret123' }],
})
);
});
await waitFor(() => {
expect(mockPost).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('SealedSecret created successfully', {
variant: 'success',
});
});
});
it('should not submit when encryption fails', async () => {
mockEncrypt.mockResolvedValue({ ok: false, error: 'Encryption failed' });
render(<EncryptDialog open onClose={vi.fn()} />);
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(mockEncrypt).toHaveBeenCalled();
});
expect(mockPost).not.toHaveBeenCalled();
});
it('should show error when API post fails', async () => {
mockPost.mockRejectedValue(new Error('API error'));
render(<EncryptDialog open onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText('Key name 1'), {
target: { value: 'k' },
});
fireEvent.change(screen.getByLabelText(/Secret value for k/), {
target: { value: 'v' },
});
fireEvent.click(screen.getByText('Create'));
await waitFor(() => {
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
expect.stringContaining('Failed to create SealedSecret'),
{ variant: 'error' }
);
});
});
it('should call onClose on Cancel', () => {
const onClose = vi.fn();
render(<EncryptDialog open onClose={onClose} />);
fireEvent.click(screen.getByLabelText('Cancel creation'));
expect(onClose).toHaveBeenCalled();
});
it('should show security note', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
expect(screen.getByText(/Security Note/)).toBeDefined();
expect(screen.getByText(/encrypted entirely in your browser/)).toBeDefined();
});
it('should toggle password visibility', () => {
render(<EncryptDialog open onClose={vi.fn()} />);
const toggleButton = screen.getByLabelText('Show password');
fireEvent.click(toggleButton);
expect(screen.getByLabelText('Hide password')).toBeDefined();
});
});
+5 -2
View File
@@ -115,8 +115,11 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
setScope('strict');
setKeyValues([{ key: '', value: '', showValue: false }]);
onClose();
} catch (error: any) {
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
} catch (error: unknown) {
enqueueSnackbar(
`Failed to create SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
}
};
+150
View File
@@ -0,0 +1,150 @@
/**
* Unit tests for ErrorBoundary components
*/
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock MUI and iconify
vi.mock('@iconify/react', () => ({
Icon: ({ icon }: { icon: string }) => <span data-testid="icon">{icon}</span>,
}));
import { ApiErrorBoundary, GenericErrorBoundary } from './ErrorBoundary';
// Suppress console.error from error boundaries in tests
const originalError = console.error;
beforeEach(() => {
console.error = vi.fn();
});
afterEach(() => {
console.error = originalError;
});
function ThrowingComponent({ error }: { error: Error }): React.ReactNode {
throw error;
}
function GoodComponent() {
return <div>Working fine</div>;
}
describe('ErrorBoundary', () => {
describe('ApiErrorBoundary', () => {
it('should render children when no error', () => {
render(
<ApiErrorBoundary>
<GoodComponent />
</ApiErrorBoundary>
);
expect(screen.getByText('Working fine')).toBeDefined();
});
it('should catch errors and show API error UI', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('API connection failed')} />
</ApiErrorBoundary>
);
expect(screen.getByText('API Communication Error')).toBeDefined();
expect(screen.getByText(/API connection failed/)).toBeDefined();
});
it('should show retry button that resets error', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('test error')} />
</ApiErrorBoundary>
);
expect(screen.getByText('API Communication Error')).toBeDefined();
// Click retry
fireEvent.click(screen.getByText('Retry'));
// After reset, it will try to render children again (which will throw again)
// The boundary should catch it again
expect(screen.getByText('API Communication Error')).toBeDefined();
});
it('should render custom fallback if provided', () => {
render(
<ApiErrorBoundary fallback={<div>Custom fallback</div>}>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
expect(screen.getByText('Custom fallback')).toBeDefined();
});
it('should call onReset when retry is clicked', () => {
const onReset = vi.fn();
render(
<ApiErrorBoundary onReset={onReset}>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
fireEvent.click(screen.getByText('Retry'));
expect(onReset).toHaveBeenCalledTimes(1);
});
it('should show guidance about troubleshooting', () => {
render(
<ApiErrorBoundary>
<ThrowingComponent error={new Error('error')} />
</ApiErrorBoundary>
);
expect(screen.getByText(/Kubernetes cluster is accessible/)).toBeDefined();
expect(screen.getByText(/Sealed Secrets controller is running/)).toBeDefined();
});
});
describe('GenericErrorBoundary', () => {
it('should render children when no error', () => {
render(
<GenericErrorBoundary>
<GoodComponent />
</GenericErrorBoundary>
);
expect(screen.getByText('Working fine')).toBeDefined();
});
it('should catch errors and show generic error UI', () => {
render(
<GenericErrorBoundary>
<ThrowingComponent error={new Error('Unexpected error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Something Went Wrong')).toBeDefined();
expect(screen.getByText(/Unexpected error/)).toBeDefined();
});
it('should show reload button', () => {
render(
<GenericErrorBoundary>
<ThrowingComponent error={new Error('error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Reload')).toBeDefined();
});
it('should render custom fallback', () => {
render(
<GenericErrorBoundary fallback={<div>Custom error view</div>}>
<ThrowingComponent error={new Error('error')} />
</GenericErrorBoundary>
);
expect(screen.getByText('Custom error view')).toBeDefined();
});
});
});
-52
View File
@@ -63,58 +63,6 @@ abstract class BaseErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoun
}
}
/**
* Error boundary for cryptographic operations
*
* Catches errors during encryption/decryption and provides
* helpful context about what might have gone wrong.
*/
export class CryptoErrorBoundary extends BaseErrorBoundary {
renderError() {
return (
<Box p={3}>
<Alert
severity="error"
icon={<Icon icon="mdi:alert-circle-outline" />}
action={
<Button color="inherit" size="small" onClick={this.handleReset}>
Retry
</Button>
}
>
<Typography variant="h6" gutterBottom>
Cryptographic Operation Failed
</Typography>
<Typography variant="body2" paragraph>
An error occurred during encryption or decryption. This might indicate:
</Typography>
<ul style={{ margin: 0, paddingLeft: 20 }}>
<li>Invalid or expired controller certificate</li>
<li>Browser cryptography compatibility issue</li>
<li>Malformed secret data</li>
<li>Controller not reachable or misconfigured</li>
</ul>
{this.state.error && (
<Typography
variant="body2"
sx={{ mt: 2, fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{(() => {
try {
const msg = this.state.error.message || this.state.error.toString();
return `Error: ${String(msg)}`;
} catch (e) {
return 'Error: [Unable to display error message]';
}
})()}
</Typography>
)}
</Alert>
</Box>
);
}
}
/**
* Error boundary for API operations
*
+47
View File
@@ -0,0 +1,47 @@
/**
* Unit tests for LoadingSkeletons components
*/
import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import {
ControllerHealthSkeleton,
SealedSecretDetailSkeleton,
SealedSecretListSkeleton,
SealingKeysListSkeleton,
} from './LoadingSkeletons';
describe('LoadingSkeletons', () => {
it('should render SealedSecretListSkeleton without errors', () => {
const { container } = render(<SealedSecretListSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render SealedSecretDetailSkeleton without errors', () => {
const { container } = render(<SealedSecretDetailSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render SealingKeysListSkeleton without errors', () => {
const { container } = render(<SealingKeysListSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render ControllerHealthSkeleton without errors', () => {
const { container } = render(<ControllerHealthSkeleton />);
expect(container.querySelector('.MuiSkeleton-root')).toBeTruthy();
});
it('should render list skeleton with multiple rows', () => {
const { container } = render(<SealedSecretListSkeleton />);
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
expect(skeletons.length).toBe(5);
});
it('should render detail skeleton with multiple sections', () => {
const { container } = render(<SealedSecretDetailSkeleton />);
const skeletons = container.querySelectorAll('.MuiSkeleton-root');
expect(skeletons.length).toBeGreaterThanOrEqual(3);
});
});
-16
View File
@@ -116,22 +116,6 @@ export function SealingKeysListSkeleton() {
);
}
/**
* Skeleton for certificate information
*
* Shows placeholder for certificate metadata
*/
export function CertificateInfoSkeleton() {
return (
<Box>
<Skeleton variant="text" width="60%" animation="wave" />
<Skeleton variant="text" width="40%" animation="wave" />
<Skeleton variant="text" width="50%" animation="wave" />
<Skeleton variant="text" width="45%" animation="wave" />
</Box>
);
}
/**
* Skeleton for controller health status
*
+262
View File
@@ -0,0 +1,262 @@
/**
* Unit tests for SealedSecretDetail component
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock react-router-dom
vi.mock('react-router-dom', () => ({
useParams: vi.fn().mockReturnValue({ namespace: 'default', name: 'my-secret' }),
}));
// Mock notistack
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
// Mock iconify
vi.mock('@iconify/react', () => ({
Icon: ({ icon }: { icon: string }) => <span data-testid={`icon-${icon}`}>{icon}</span>,
}));
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Secret: {
useGet: vi.fn().mockReturnValue([null, null]),
},
},
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown; hide?: boolean }> }) => (
<table data-testid="name-value-table">
<tbody>
{rows
.filter(r => !r.hide)
.map((row, i) => (
<tr key={i}>
<td>{row.name}</td>
<td>{typeof row.value === 'string' ? row.value : <>{row.value}</>}</td>
</tr>
))}
</tbody>
</table>
),
SectionBox: ({ title, children }: { title: React.ReactNode; children: React.ReactNode }) => (
<div data-testid="section-box">
<div data-testid="section-title">{title}</div>
{children}
</div>
),
SimpleTable: ({ data }: { data: unknown[] }) => (
<table data-testid="encrypted-table">
<tbody>
{(data || []).map((_, i) => (
<tr key={i}>
<td>row</td>
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
// Mock hooks and libs
vi.mock('../hooks/usePermissions', () => ({
usePermissions: vi.fn().mockReturnValue({
permissions: {
canCreate: true,
canRead: true,
canUpdate: true,
canDelete: true,
canList: true,
},
loading: false,
}),
}));
vi.mock('../lib/controller', () => ({
getPluginConfig: vi.fn().mockReturnValue({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
}),
rotateSealedSecret: vi.fn(),
}));
vi.mock('../lib/rbac', () => ({
canDecryptSecrets: vi.fn().mockResolvedValue(true),
}));
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {
useGet: vi.fn(),
},
}));
vi.mock('./DecryptDialog', () => ({
DecryptDialog: () => <div data-testid="decrypt-dialog" />,
}));
vi.mock('./LoadingSkeletons', () => ({
SealedSecretDetailSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
import { useParams } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions';
import { rotateSealedSecret } from '../lib/controller';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretDetail } from './SealedSecretDetail';
const mockUseGet = vi.mocked(SealedSecret.useGet);
const mockRotate = vi.mocked(rotateSealedSecret);
const mockUsePermissions = vi.mocked(usePermissions);
const mockUseParams = vi.mocked(useParams);
describe('SealedSecretDetail', () => {
const mockSealedSecret = {
metadata: {
name: 'my-secret',
namespace: 'default',
creationTimestamp: '2024-01-01T00:00:00Z',
},
spec: {
encryptedData: {
password: 'encrypted-value-1',
token: 'encrypted-value-2',
},
template: {
type: 'Opaque',
metadata: {},
},
},
scope: 'strict',
isSynced: true,
syncCondition: { type: 'Synced', status: 'True' },
syncMessage: 'Secret synced successfully',
getAge: () => '2d',
jsonData: { spec: { encryptedData: {} } },
delete: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
vi.clearAllMocks();
mockUseParams.mockReturnValue({ namespace: 'default', name: 'my-secret' });
mockUseGet.mockReturnValue([mockSealedSecret, null] as never);
mockUsePermissions.mockReturnValue({
permissions: {
canCreate: true,
canRead: true,
canUpdate: true,
canDelete: true,
canList: true,
},
loading: false,
error: null,
});
mockRotate.mockResolvedValue({ ok: true, value: 'rotated' });
});
it('should show skeleton when loading', () => {
mockUseGet.mockReturnValue([null, null] as never);
render(<SealedSecretDetail />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show error when fetch fails', () => {
mockUseGet.mockReturnValue([null, 'Not found'] as never);
render(<SealedSecretDetail />);
expect(screen.getByText('Failed to load SealedSecret')).toBeDefined();
});
it('should show skeleton when params are missing', () => {
mockUseParams.mockReturnValue({});
render(<SealedSecretDetail />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should render detail view with data', () => {
render(<SealedSecretDetail />);
expect(screen.getAllByText('my-secret').length).toBeGreaterThan(0);
expect(screen.getAllByText('default').length).toBeGreaterThan(0);
expect(screen.getByText('Strict')).toBeDefined();
expect(screen.getByText('Synced')).toBeDefined();
});
it('should render detail content inside drawer', () => {
render(<SealedSecretDetail />);
// Drawer content includes the secret name (appears in title and table)
expect(screen.getAllByText('my-secret').length).toBeGreaterThan(0);
});
it('should render encrypted data section', () => {
render(<SealedSecretDetail />);
expect(screen.getByTestId('encrypted-table')).toBeDefined();
});
it('should render action buttons when user has permissions', () => {
render(<SealedSecretDetail />);
// Buttons are inside a MUI Drawer (portal). Check they exist in the document.
const buttons = Array.from(document.querySelectorAll('button'));
const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt');
const deleteBtn = buttons.find(b => b.textContent === 'Delete');
expect(reencryptBtn || deleteBtn).toBeTruthy();
});
it('should handle rotate success via Result check', async () => {
mockRotate.mockResolvedValue({ ok: true, value: 'rotated-yaml' });
render(<SealedSecretDetail />);
// Find and click Re-encrypt button (rendered in Drawer portal)
const buttons = Array.from(document.querySelectorAll('button'));
const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt');
if (reencryptBtn) {
fireEvent.click(reencryptBtn);
await waitFor(() => {
expect(mockRotate).toHaveBeenCalled();
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('SealedSecret re-encrypted successfully', {
variant: 'success',
});
});
}
});
it('should handle rotate failure (Result error)', async () => {
mockRotate.mockResolvedValue({ ok: false, error: 'Rotation failed: 400' });
render(<SealedSecretDetail />);
const buttons = Array.from(document.querySelectorAll('button'));
const reencryptBtn = buttons.find(b => b.textContent === 'Re-encrypt');
if (reencryptBtn) {
fireEvent.click(reencryptBtn);
await waitFor(() => {
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
'Failed to re-encrypt: Rotation failed: 400',
{ variant: 'error' }
);
});
}
});
});
+39 -12
View File
@@ -71,7 +71,7 @@ export function SealedSecretDetail() {
React.useEffect(() => {
let cancelled = false;
if (namespace) {
canDecryptSecrets(namespace).then((result) => {
canDecryptSecrets(namespace).then(result => {
if (!cancelled) setCanDecrypt(result);
});
}
@@ -110,8 +110,11 @@ export function SealedSecretDetail() {
await sealedSecret.delete();
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
window.history.back();
} catch (error: any) {
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
} catch (error: unknown) {
enqueueSnackbar(
`Failed to delete SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
}
setDeleteDialogOpen(false);
}, [sealedSecret, enqueueSnackbar]);
@@ -121,11 +124,17 @@ export function SealedSecretDetail() {
try {
const config = getPluginConfig();
const yaml = JSON.stringify(sealedSecret.jsonData);
await rotateSealedSecret(config, yaml);
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
// The resource will auto-refresh via the watch
} catch (error: any) {
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
const result = await rotateSealedSecret(config, yaml);
if (result.ok === false) {
enqueueSnackbar(`Failed to re-encrypt: ${result.error}`, { variant: 'error' });
} else {
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
}
} catch (error: unknown) {
enqueueSnackbar(
`Failed to re-encrypt: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
} finally {
setRotating(false);
}
@@ -160,7 +169,12 @@ export function SealedSecretDetail() {
title={
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={1}>
<IconButton onClick={handleClose} edge="start" size="small" aria-label="Close detail panel">
<IconButton
onClick={handleClose}
edge="start"
size="small"
aria-label="Close detail panel"
>
<Icon icon="mdi:close" />
</IconButton>
<span>{sealedSecret.metadata.name}</span>
@@ -252,11 +266,20 @@ export function SealedSecretDetail() {
label: 'Actions',
getter: (row: { key: string; value: string }) =>
canDecrypt ? (
<Button size="small" onClick={() => setDecryptKey(row.key)} aria-label={`Decrypt ${row.key}`}>
<Button
size="small"
onClick={() => setDecryptKey(row.key)}
aria-label={`Decrypt ${row.key}`}
>
Decrypt
</Button>
) : (
<Button size="small" disabled title="No permission to access Secrets" aria-label={`Decrypt ${row.key} (no permission)`}>
<Button
size="small"
disabled
title="No permission to access Secrets"
aria-label={`Decrypt ${row.key} (no permission)`}
>
Decrypt
</Button>
),
@@ -337,7 +360,11 @@ export function SealedSecretDetail() {
/>
)}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)} aria-labelledby="delete-dialog-title">
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
aria-labelledby="delete-dialog-title"
>
<DialogTitle id="delete-dialog-title">Delete SealedSecret?</DialogTitle>
<DialogContent>
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
+160
View File
@@ -0,0 +1,160 @@
/**
* Unit tests for SealedSecretList component
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock react-router-dom
vi.mock('react-router-dom', () => ({
useParams: vi.fn().mockReturnValue({}),
}));
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Link: ({ children }: { children: React.ReactNode }) => <a>{children}</a>,
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
SectionFilterHeader: ({ actions }: { actions?: React.ReactNode[] }) => (
<div data-testid="filter-header">
{actions?.map((action, i) => (
<div key={i}>{action}</div>
))}
</div>
),
SimpleTable: ({ data }: { data: unknown[] }) => (
<table data-testid="simple-table">
<tbody>
{(data || []).map((_, i) => (
<tr key={i}>
<td>row {i}</td>
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
// Mock SealedSecretCRD
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {
useList: vi.fn(),
},
}));
// Mock hooks
vi.mock('../hooks/usePermissions', () => ({
usePermission: vi.fn().mockReturnValue({ loading: false, allowed: true }),
}));
// Mock sub-components
vi.mock('./EncryptDialog', () => ({
EncryptDialog: () => <div data-testid="encrypt-dialog" />,
}));
vi.mock('./LoadingSkeletons', () => ({
SealedSecretListSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
vi.mock('./SealedSecretDetail', () => ({
SealedSecretDetail: () => <div data-testid="detail" />,
}));
vi.mock('./VersionWarning', () => ({
VersionWarning: () => <div data-testid="version-warning" />,
}));
import { usePermission } from '../hooks/usePermissions';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretList } from './SealedSecretList';
const mockUseList = vi.mocked(SealedSecret.useList);
const mockUsePermission = vi.mocked(usePermission);
describe('SealedSecretList', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
});
it('should show loading skeleton', () => {
mockUseList.mockReturnValue([null, null, true] as never);
render(<SealedSecretList />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show error when fetch fails', () => {
mockUseList.mockReturnValue([null, { message: 'Failed to fetch' }, false] as never);
render(<SealedSecretList />);
expect(screen.getByText(/Failed to load Sealed Secrets/)).toBeDefined();
});
it('should show 404 hint when CRD not found', () => {
mockUseList.mockReturnValue([null, { message: '404 Not Found' }, false] as never);
render(<SealedSecretList />);
expect(screen.getByText(/CRD not found/)).toBeDefined();
expect(screen.getByText(/kubectl apply/)).toBeDefined();
});
it('should render table with data', () => {
const mockSecrets = [
{
metadata: { name: 'secret-1', namespace: 'default' },
scope: 'strict',
encryptedKeysCount: 2,
isSynced: true,
getAge: () => '1d',
},
{
metadata: { name: 'secret-2', namespace: 'prod' },
scope: 'namespace-wide',
encryptedKeysCount: 1,
isSynced: false,
getAge: () => '3h',
},
];
mockUseList.mockReturnValue([mockSecrets, null, false] as never);
render(<SealedSecretList />);
expect(screen.getByTestId('simple-table')).toBeDefined();
});
it('should show create button when user has create permission', () => {
mockUseList.mockReturnValue([[], null, false] as never);
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
render(<SealedSecretList />);
expect(screen.getByText('Create Sealed Secret')).toBeDefined();
});
it('should hide create button when user lacks create permission', () => {
mockUseList.mockReturnValue([[], null, false] as never);
mockUsePermission.mockReturnValue({ loading: false, allowed: false });
render(<SealedSecretList />);
expect(screen.queryByText('Create Sealed Secret')).toBeNull();
});
it('should render VersionWarning', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealedSecretList />);
expect(screen.getByTestId('version-warning')).toBeDefined();
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* Unit tests for SealingKeysView component
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock notistack
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: {
ResourceClasses: {
Secret: {
useList: vi.fn(),
},
},
},
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({
title,
children,
headerProps,
}: {
title: string;
children: React.ReactNode;
headerProps?: { actions?: React.ReactNode[] };
}) => (
<div data-testid="section-box">
<h2>{title}</h2>
<div data-testid="header-actions">
{headerProps?.actions?.map((action, i) => (
<div key={i}>{action}</div>
))}
</div>
{children}
</div>
),
SimpleTable: ({
data,
columns,
}: {
data: unknown[];
columns: Array<{ label: string; getter: (row: unknown) => React.ReactNode }>;
}) => (
<table data-testid="keys-table">
<thead>
<tr>
{columns.map((col, i) => (
<th key={i}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col, j) => (
<td key={j}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../lib/controller', () => ({
getPluginConfig: vi.fn().mockReturnValue({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
}),
fetchPublicCertificate: vi.fn(),
}));
vi.mock('../lib/crypto', () => ({
parseCertificateInfo: vi.fn().mockReturnValue({ ok: false, error: 'no cert' }),
isCertificateExpiringSoon: vi.fn().mockReturnValue(false),
}));
vi.mock('./ControllerStatus', () => ({
ControllerStatus: () => <div data-testid="controller-status">Status</div>,
}));
vi.mock('./LoadingSkeletons', () => ({
SealingKeysListSkeleton: () => <div data-testid="skeleton">Loading...</div>,
}));
import { K8s } from '@kinvolk/headlamp-plugin/lib';
import { fetchPublicCertificate } from '../lib/controller';
import { SealingKeysView } from './SealingKeysView';
const mockUseList = vi.mocked(K8s.ResourceClasses.Secret.useList);
const mockFetchCert = vi.mocked(fetchPublicCertificate);
describe('SealingKeysView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should show loading skeleton', () => {
mockUseList.mockReturnValue([null, null, true] as never);
render(<SealingKeysView />);
expect(screen.getByTestId('skeleton')).toBeDefined();
});
it('should show empty message when no sealing keys found', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealingKeysView />);
expect(screen.getByText(/No sealing keys found/)).toBeDefined();
});
it('should render sealing keys table', () => {
const secrets = [
{
metadata: {
name: 'sealed-secrets-key-abc',
labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' },
creationTimestamp: '2024-01-01T00:00:00Z',
},
data: {},
},
{
metadata: {
name: 'sealed-secrets-key-old',
labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'compromised' },
creationTimestamp: '2023-06-01T00:00:00Z',
},
data: {},
},
];
mockUseList.mockReturnValue([secrets, null, false] as never);
render(<SealingKeysView />);
expect(screen.getByTestId('keys-table')).toBeDefined();
expect(screen.getByText('sealed-secrets-key-abc')).toBeDefined();
expect(screen.getByText('sealed-secrets-key-old')).toBeDefined();
});
it('should filter non-sealing-key secrets', () => {
const secrets = [
{
metadata: {
name: 'sealing-key',
labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' },
creationTimestamp: '2024-01-01T00:00:00Z',
},
data: {},
},
{
metadata: {
name: 'other-secret',
labels: {},
creationTimestamp: '2024-01-01T00:00:00Z',
},
data: {},
},
];
mockUseList.mockReturnValue([secrets, null, false] as never);
render(<SealingKeysView />);
expect(screen.getByText('sealing-key')).toBeDefined();
expect(screen.queryByText('other-secret')).toBeNull();
});
it('should sort active keys before compromised', () => {
const secrets = [
{
metadata: {
name: 'compromised-key',
labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'compromised' },
creationTimestamp: '2024-06-01T00:00:00Z',
},
data: {},
},
{
metadata: {
name: 'active-key',
labels: { 'sealedsecrets.bitnami.com/sealed-secrets-key': 'active' },
creationTimestamp: '2024-01-01T00:00:00Z',
},
data: {},
},
];
mockUseList.mockReturnValue([secrets, null, false] as never);
render(<SealingKeysView />);
const rows = screen.getAllByRole('row');
// First data row should be active key (after header row)
expect(rows[1].textContent).toContain('active-key');
});
it('should show download certificate button', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealingKeysView />);
expect(screen.getByText('Download Public Certificate')).toBeDefined();
});
it('should handle certificate download failure', async () => {
mockUseList.mockReturnValue([[], null, false] as never);
mockFetchCert.mockResolvedValue({ ok: false, error: 'Network error' });
render(<SealingKeysView />);
fireEvent.click(screen.getByText('Download Public Certificate'));
await waitFor(() => {
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
expect.stringContaining('Failed to download certificate'),
{ variant: 'error' }
);
});
});
it('should call fetchPublicCertificate on download click', async () => {
mockUseList.mockReturnValue([[], null, false] as never);
mockFetchCert.mockResolvedValue({ ok: true, value: 'cert-pem' as never });
// Mock Blob/URL to prevent DOM issues
global.URL.createObjectURL = vi.fn().mockReturnValue('blob:url');
global.URL.revokeObjectURL = vi.fn();
render(<SealingKeysView />);
fireEvent.click(screen.getByText('Download Public Certificate'));
await waitFor(() => {
expect(mockFetchCert).toHaveBeenCalled();
});
});
it('should show ControllerStatus in header', () => {
mockUseList.mockReturnValue([[], null, false] as never);
render(<SealingKeysView />);
expect(screen.getByTestId('controller-status')).toBeDefined();
});
});
+11 -3
View File
@@ -94,8 +94,11 @@ export function SealingKeysView() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
} catch (error: any) {
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
} catch (error: unknown) {
enqueueSnackbar(
`Failed to create download: ${error instanceof Error ? error.message : String(error)}`,
{ variant: 'error' }
);
}
};
@@ -189,7 +192,12 @@ export function SealingKeysView() {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<span>{expiryDate}</span>
<span style={{ color: 'var(--mui-palette-text-secondary, #666)', fontSize: '0.9em' }}>
<span
style={{
color: 'var(--mui-palette-text-secondary, #666)',
fontSize: '0.9em',
}}
>
({certInfo.daysUntilExpiry} days)
</span>
</Box>
@@ -0,0 +1,190 @@
/**
* Unit tests for SecretDetailsSection component
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
// Mock headlamp
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
K8s: { ResourceClasses: {} },
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
Link: ({ children, ...props }: { children: React.ReactNode }) => (
<a data-testid="link" {...props}>
{children}
</a>
),
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown }> }) => (
<table>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
<td>{row.name}</td>
<td>{typeof row.value === 'string' ? row.value : <>{row.value}</>}</td>
</tr>
))}
</tbody>
</table>
),
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {
useGet: vi.fn(),
},
}));
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SecretDetailsSection } from './SecretDetailsSection';
const mockUseGet = vi.mocked(SealedSecret.useGet);
describe('SecretDetailsSection', () => {
it('should return null when Secret has no SealedSecret owner', () => {
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
ownerReferences: [
{ kind: 'Deployment', apiVersion: 'apps/v1', name: 'my-deploy', uid: '123' },
],
},
};
const { container } = render(<SecretDetailsSection resource={resource} />);
expect(container.innerHTML).toBe('');
});
it('should return null when Secret has no owner references', () => {
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
},
};
const { container } = render(<SecretDetailsSection resource={resource} />);
expect(container.innerHTML).toBe('');
});
it('should show loading text when SealedSecret is still loading', () => {
mockUseGet.mockReturnValue([null, null] as never);
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
ownerReferences: [
{
kind: 'SealedSecret',
apiVersion: 'bitnami.com/v1alpha1',
name: 'my-sealed-secret',
uid: '456',
},
],
},
};
render(<SecretDetailsSection resource={resource} />);
expect(screen.getByText('Sealed Secret')).toBeDefined();
expect(screen.getByText('Loading SealedSecret information...')).toBeDefined();
});
it('should display SealedSecret info when loaded', () => {
const mockSealedSecret = {
metadata: {
name: 'my-sealed-secret',
namespace: 'default',
},
scope: 'strict',
isSynced: true,
};
mockUseGet.mockReturnValue([mockSealedSecret, null] as never);
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
ownerReferences: [
{
kind: 'SealedSecret',
apiVersion: 'bitnami.com/v1alpha1',
name: 'my-sealed-secret',
uid: '789',
},
],
},
};
render(<SecretDetailsSection resource={resource} />);
expect(screen.getByText('Sealed Secret')).toBeDefined();
expect(screen.getByText('my-sealed-secret')).toBeDefined();
expect(screen.getByText('Synced')).toBeDefined();
});
it('should show Not Synced status for unsynced SealedSecret', () => {
const mockSealedSecret = {
metadata: { name: 'ss', namespace: 'default' },
scope: 'namespace-wide',
isSynced: false,
};
mockUseGet.mockReturnValue([mockSealedSecret, null] as never);
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
ownerReferences: [
{
kind: 'SealedSecret',
apiVersion: 'bitnami.com/v1alpha1',
name: 'ss',
uid: '111',
},
],
},
};
render(<SecretDetailsSection resource={resource} />);
expect(screen.getByText('Not Synced')).toBeDefined();
});
it('should filter by correct apiVersion', () => {
const resource = {
kind: 'Secret',
metadata: {
name: 'my-secret',
namespace: 'default',
ownerReferences: [
{
kind: 'SealedSecret',
apiVersion: 'wrong-api/v1',
name: 'wrong-ss',
uid: '222',
},
],
},
};
const { container } = render(<SecretDetailsSection resource={resource} />);
expect(container.innerHTML).toBe('');
});
});
+18 -2
View File
@@ -14,8 +14,24 @@ import {
import React from 'react';
import { SealedSecret } from '../lib/SealedSecretCRD';
interface OwnerReference {
kind: string;
apiVersion: string;
name: string;
uid: string;
}
interface SecretResource {
kind?: string;
metadata?: {
name?: string;
namespace?: string;
ownerReferences?: OwnerReference[];
};
}
interface SecretDetailsSectionProps {
resource: any; // The Secret resource
resource: SecretResource;
}
/**
@@ -24,7 +40,7 @@ interface SecretDetailsSectionProps {
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
// Check if this Secret is owned by a SealedSecret
const ownerRef = resource.metadata?.ownerReferences?.find(
(ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
ref => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
);
if (!ownerRef) {
+144
View File
@@ -0,0 +1,144 @@
/**
* Unit tests for SettingsPage component
*/
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock notistack
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
// Mock controller
vi.mock('../lib/controller', () => ({
getPluginConfig: vi.fn().mockReturnValue({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
}),
savePluginConfig: vi.fn(),
}));
// Mock sub-components
vi.mock('./ControllerStatus', () => ({
ControllerStatus: () => <div data-testid="controller-status">Status</div>,
}));
vi.mock('./VersionWarning', () => ({
VersionWarning: () => <div data-testid="version-warning">Version</div>,
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
<div data-testid="section-box">
<h2>{title}</h2>
{children}
</div>
),
}));
import { savePluginConfig } from '../lib/controller';
import { SettingsPage } from './SettingsPage';
const mockSave = vi.mocked(savePluginConfig);
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should render settings form with default values', () => {
render(<SettingsPage />);
expect(screen.getByText('Sealed Secrets Plugin Settings')).toBeDefined();
expect(screen.getByDisplayValue('sealed-secrets-controller')).toBeDefined();
expect(screen.getByDisplayValue('kube-system')).toBeDefined();
expect(screen.getByDisplayValue('8080')).toBeDefined();
});
it('should render ControllerStatus and VersionWarning', () => {
render(<SettingsPage />);
expect(screen.getByTestId('controller-status')).toBeDefined();
expect(screen.getByTestId('version-warning')).toBeDefined();
});
it('should save config on Save button click', () => {
render(<SettingsPage />);
fireEvent.click(screen.getByText('Save Settings'));
expect(mockSave).toHaveBeenCalledWith({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
});
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Settings saved successfully', {
variant: 'success',
});
});
it('should reset to defaults on Reset button click', () => {
render(<SettingsPage />);
// Change a value first
const nameInput = screen.getByDisplayValue('sealed-secrets-controller');
fireEvent.change(nameInput, { target: { value: 'custom-name' } });
expect(screen.getByDisplayValue('custom-name')).toBeDefined();
// Reset
fireEvent.click(screen.getByText('Reset to Defaults'));
expect(screen.getByDisplayValue('sealed-secrets-controller')).toBeDefined();
expect(screen.getByDisplayValue('kube-system')).toBeDefined();
expect(screen.getByDisplayValue('8080')).toBeDefined();
});
it('should call onDataChange when form fields change', () => {
const onDataChange = vi.fn();
render(<SettingsPage onDataChange={onDataChange} />);
const nameInput = screen.getByDisplayValue('sealed-secrets-controller');
fireEvent.change(nameInput, { target: { value: 'new-controller' } });
expect(onDataChange).toHaveBeenCalledWith(
expect.objectContaining({
controllerName: 'new-controller',
})
);
});
it('should call onDataChange on save', () => {
const onDataChange = vi.fn();
render(<SettingsPage onDataChange={onDataChange} />);
fireEvent.click(screen.getByText('Save Settings'));
expect(onDataChange).toHaveBeenCalled();
});
it('should use data props for initial values when provided', () => {
render(
<SettingsPage
data={{
controllerName: 'from-props',
controllerNamespace: 'custom-ns',
controllerPort: 9090,
}}
/>
);
expect(screen.getByDisplayValue('from-props')).toBeDefined();
expect(screen.getByDisplayValue('custom-ns')).toBeDefined();
expect(screen.getByDisplayValue('9090')).toBeDefined();
});
it('should show default values info section', () => {
render(<SettingsPage />);
expect(screen.getByText('Default Values')).toBeDefined();
});
});
+141
View File
@@ -0,0 +1,141 @@
/**
* Unit tests for VersionWarning component
*/
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock SealedSecretCRD
vi.mock('../lib/SealedSecretCRD', () => ({
SealedSecret: {
detectApiVersion: vi.fn(),
DEFAULT_VERSION: 'bitnami.com/v1alpha1',
},
}));
import { SealedSecret } from '../lib/SealedSecretCRD';
import { VersionWarning } from './VersionWarning';
const mockDetectVersion = vi.mocked(SealedSecret.detectApiVersion);
describe('VersionWarning', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should show nothing while loading', () => {
mockDetectVersion.mockReturnValue(new Promise(() => {}));
const { container } = render(<VersionWarning autoDetect />);
expect(container.innerHTML).toBe('');
});
it('should show nothing on default version detection', async () => {
mockDetectVersion.mockResolvedValue({
ok: true,
value: 'bitnami.com/v1alpha1',
});
const { container } = render(<VersionWarning autoDetect />);
await waitFor(() => {
// Should render null for default version without showDetails
expect(container.innerHTML).toBe('');
});
});
it('should show info alert for non-default version', async () => {
mockDetectVersion.mockResolvedValue({
ok: true,
value: 'bitnami.com/v1alpha2',
});
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText('API Version Detected')).toBeDefined();
});
expect(screen.getByText('bitnami.com/v1alpha2')).toBeDefined();
});
it('should show error with retry button on detection failure', async () => {
mockDetectVersion.mockResolvedValue({
ok: false,
error: 'CRD not found',
});
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText('API Version Detection Failed')).toBeDefined();
});
expect(screen.getByText(/CRD not found/)).toBeDefined();
expect(screen.getByText('Retry')).toBeDefined();
});
it('should retry on button click', async () => {
mockDetectVersion
.mockResolvedValueOnce({ ok: false, error: 'error' })
.mockResolvedValueOnce({ ok: true, value: 'bitnami.com/v1alpha1' });
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText('Retry')).toBeDefined();
});
fireEvent.click(screen.getByText('Retry'));
await waitFor(() => {
expect(mockDetectVersion).toHaveBeenCalledTimes(2);
});
});
it('should show installation hint when CRD not found', async () => {
mockDetectVersion.mockResolvedValue({
ok: false,
error: 'CRD not found',
});
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText(/kubectl apply/)).toBeDefined();
});
});
it('should show success alert when showDetails is true and default version detected', async () => {
mockDetectVersion.mockResolvedValue({
ok: true,
value: 'bitnami.com/v1alpha1',
});
render(<VersionWarning autoDetect showDetails />);
await waitFor(() => {
expect(screen.getByText('API Version Detected')).toBeDefined();
expect(screen.getByText('bitnami.com/v1alpha1')).toBeDefined();
});
});
it('should not auto-detect when autoDetect is false', () => {
render(<VersionWarning autoDetect={false} />);
expect(mockDetectVersion).not.toHaveBeenCalled();
});
it('should handle unexpected exceptions', async () => {
mockDetectVersion.mockRejectedValue(new Error('Unexpected'));
render(<VersionWarning autoDetect />);
await waitFor(() => {
expect(screen.getByText('API Version Detection Failed')).toBeDefined();
expect(screen.getByText(/Unexpected/)).toBeDefined();
});
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* Unit tests for useControllerHealth hook
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock controller module
vi.mock('../lib/controller', () => ({
checkControllerHealth: vi.fn(),
getPluginConfig: vi.fn().mockReturnValue({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
}),
}));
import { checkControllerHealth } from '../lib/controller';
import { useControllerHealth } from './useControllerHealth';
const mockCheckHealth = vi.mocked(checkControllerHealth);
describe('useControllerHealth', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should start in loading state', () => {
mockCheckHealth.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useControllerHealth());
expect(result.current.loading).toBe(true);
expect(result.current.health).toBe(null);
});
it('should fetch health on mount', async () => {
mockCheckHealth.mockResolvedValue({
ok: true,
value: {
healthy: true,
reachable: true,
version: '0.24.0',
latencyMs: 42,
},
});
const { result } = renderHook(() => useControllerHealth());
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.loading).toBe(false);
expect(result.current.health).toEqual({
healthy: true,
reachable: true,
version: '0.24.0',
latencyMs: 42,
});
});
it('should handle error result', async () => {
mockCheckHealth.mockResolvedValue({
ok: false,
error: 'Controller unreachable',
});
const { result } = renderHook(() => useControllerHealth());
await act(async () => {
await vi.runAllTimersAsync();
});
expect(result.current.loading).toBe(false);
expect(result.current.health).toEqual({
healthy: false,
reachable: false,
error: 'Controller unreachable',
});
});
it('should auto-refresh at specified interval', async () => {
mockCheckHealth.mockResolvedValue({
ok: true,
value: { healthy: true, reachable: true },
});
renderHook(() => useControllerHealth(true, 10000));
// Initial call
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
// Advance timer by refresh interval
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(mockCheckHealth).toHaveBeenCalledTimes(2);
// Another interval
await act(async () => {
await vi.advanceTimersByTimeAsync(10000);
});
expect(mockCheckHealth).toHaveBeenCalledTimes(3);
});
it('should not auto-refresh by default', async () => {
mockCheckHealth.mockResolvedValue({
ok: true,
value: { healthy: true, reachable: true },
});
renderHook(() => useControllerHealth());
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(60000);
await vi.runAllTimersAsync();
});
// Still just 1 call - no auto-refresh
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
});
it('should provide manual refresh function', async () => {
mockCheckHealth.mockResolvedValue({
ok: true,
value: { healthy: true, reachable: true },
});
const { result } = renderHook(() => useControllerHealth());
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
// Manual refresh
await act(async () => {
result.current.refresh();
await vi.runAllTimersAsync();
});
expect(mockCheckHealth).toHaveBeenCalledTimes(2);
});
it('should cleanup interval on unmount', async () => {
mockCheckHealth.mockResolvedValue({
ok: true,
value: { healthy: true, reachable: true },
});
const { unmount } = renderHook(() => useControllerHealth(true, 5000));
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
unmount();
// Advance time - no more calls after unmount
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(mockCheckHealth).toHaveBeenCalledTimes(1);
});
});
+3 -5
View File
@@ -37,16 +37,14 @@ export function useControllerHealth(autoRefresh = false, refreshIntervalMs = 300
const config = getPluginConfig();
const result = await checkControllerHealth(config);
if (result.ok) {
setHealth(result.value);
} else if (result.ok === false) {
// Even on error, checkControllerHealth returns a status
// This shouldn't happen, but handle gracefully
if (result.ok === false) {
setHealth({
healthy: false,
reachable: false,
error: result.error,
});
} else {
setHealth(result.value);
}
setLoading(false);
+216
View File
@@ -0,0 +1,216 @@
/**
* Unit tests for usePermissions hooks
*/
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock rbac module
vi.mock('../lib/rbac', () => ({
checkSealedSecretPermissions: vi.fn(),
}));
import { checkSealedSecretPermissions } from '../lib/rbac';
import { usePermission, usePermissions } from './usePermissions';
const mockCheckPerms = vi.mocked(checkSealedSecretPermissions);
describe('usePermissions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('usePermissions', () => {
it('should start in loading state', () => {
mockCheckPerms.mockReturnValue(new Promise(() => {})); // never resolves
const { result } = renderHook(() => usePermissions('default'));
expect(result.current.loading).toBe(true);
expect(result.current.permissions).toBe(null);
expect(result.current.error).toBe(null);
});
it('should transition to loaded with permissions', async () => {
mockCheckPerms.mockResolvedValue({
ok: true,
value: {
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
canList: true,
},
});
const { result } = renderHook(() => usePermissions('default'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.permissions).toEqual({
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
canList: true,
});
expect(result.current.error).toBe(null);
});
it('should set error state on failure', async () => {
mockCheckPerms.mockResolvedValue({
ok: false,
error: 'Permission check failed',
});
const { result } = renderHook(() => usePermissions('default'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.permissions).toBe(null);
expect(result.current.error).toBe('Permission check failed');
});
it('should re-fetch when namespace changes', async () => {
mockCheckPerms.mockResolvedValue({
ok: true,
value: {
canCreate: true,
canRead: true,
canUpdate: true,
canDelete: true,
canList: true,
},
});
const { result, rerender } = renderHook(({ ns }: { ns: string }) => usePermissions(ns), {
initialProps: { ns: 'default' },
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockCheckPerms).toHaveBeenCalledWith('default');
rerender({ ns: 'production' });
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockCheckPerms).toHaveBeenCalledWith('production');
expect(mockCheckPerms).toHaveBeenCalledTimes(2);
});
it('should handle unmount cancellation', async () => {
let resolvePromise: (value: unknown) => void;
mockCheckPerms.mockReturnValue(
new Promise(resolve => {
resolvePromise = resolve;
})
);
const { result, unmount } = renderHook(() => usePermissions('default'));
expect(result.current.loading).toBe(true);
// Unmount before promise resolves
unmount();
// Resolve after unmount - should not cause errors
resolvePromise!({
ok: true,
value: {
canCreate: true,
canRead: true,
canUpdate: true,
canDelete: true,
canList: true,
},
});
});
it('should work without namespace (cluster-wide)', async () => {
mockCheckPerms.mockResolvedValue({
ok: true,
value: {
canCreate: false,
canRead: true,
canUpdate: false,
canDelete: false,
canList: true,
},
});
const { result } = renderHook(() => usePermissions());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(mockCheckPerms).toHaveBeenCalledWith(undefined);
});
});
describe('usePermission', () => {
it('should return specific permission', async () => {
mockCheckPerms.mockResolvedValue({
ok: true,
value: {
canCreate: true,
canRead: true,
canUpdate: false,
canDelete: false,
canList: true,
},
});
const { result } = renderHook(() => usePermission('default', 'canCreate'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.allowed).toBe(true);
});
it('should return false when permission is denied', async () => {
mockCheckPerms.mockResolvedValue({
ok: true,
value: {
canCreate: false,
canRead: true,
canUpdate: false,
canDelete: false,
canList: true,
},
});
const { result } = renderHook(() => usePermission('default', 'canCreate'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.allowed).toBe(false);
});
it('should return false when permissions are null (loading/error)', () => {
mockCheckPerms.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => usePermission('default', 'canCreate'));
expect(result.current.loading).toBe(true);
expect(result.current.allowed).toBe(false);
});
});
});
-50
View File
@@ -85,53 +85,3 @@ export function usePermission(
return { loading, allowed };
}
/**
* Hook to check if user has any write permissions
*
* Returns true if user can create, update, or delete.
* Useful for showing/hiding entire sections of UI.
*
* @param namespace Optional namespace to check
* @returns Object with loading state and hasWriteAccess flag
*
* @example
* const { loading, hasWriteAccess } = useHasWriteAccess('default');
* if (hasWriteAccess) {
* // Show management UI
* }
*/
export function useHasWriteAccess(namespace?: string) {
const { loading, permissions } = usePermissions(namespace);
const hasWriteAccess =
permissions?.canCreate || permissions?.canUpdate || permissions?.canDelete || false;
return { loading, hasWriteAccess };
}
/**
* Hook to check if user has read-only access
*
* Returns true if user can read/list but cannot create/update/delete.
*
* @param namespace Optional namespace to check
* @returns Object with loading state and isReadOnly flag
*
* @example
* const { loading, isReadOnly } = useIsReadOnly('default');
* if (isReadOnly) {
* // Show read-only warning
* }
*/
export function useIsReadOnly(namespace?: string) {
const { loading, permissions } = usePermissions(namespace);
const isReadOnly =
(permissions?.canRead || permissions?.canList) &&
!permissions?.canCreate &&
!permissions?.canUpdate &&
!permissions?.canDelete;
return { loading, isReadOnly };
}
@@ -0,0 +1,402 @@
/**
* Unit tests for useSealedSecretEncryption hook
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies
const mockEnqueueSnackbar = vi.fn();
vi.mock('notistack', () => ({
useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }),
}));
vi.mock('../lib/controller', () => ({
getPluginConfig: vi.fn().mockReturnValue({
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
}),
fetchPublicCertificate: vi.fn(),
}));
vi.mock('../lib/crypto', () => ({
parsePublicKeyFromCert: vi.fn(),
encryptKeyValues: vi.fn(),
parseCertificateInfo: vi.fn(),
isCertificateExpiringSoon: vi.fn(),
}));
vi.mock('../lib/validators', () => ({
validateSecretName: vi.fn().mockReturnValue({ valid: true }),
validateSecretKey: vi.fn().mockReturnValue({ valid: true }),
validateSecretValue: vi.fn().mockReturnValue({ valid: true }),
}));
import { fetchPublicCertificate } from '../lib/controller';
import {
encryptKeyValues,
isCertificateExpiringSoon,
parseCertificateInfo,
parsePublicKeyFromCert,
} from '../lib/crypto';
import { validateSecretKey, validateSecretName, validateSecretValue } from '../lib/validators';
import { useSealedSecretEncryption } from './useSealedSecretEncryption';
const mockFetchCert = vi.mocked(fetchPublicCertificate);
const mockParseKey = vi.mocked(parsePublicKeyFromCert);
const mockEncryptKV = vi.mocked(encryptKeyValues);
const mockParseCertInfo = vi.mocked(parseCertificateInfo);
const mockIsExpiringSoon = vi.mocked(isCertificateExpiringSoon);
const mockValidateName = vi.mocked(validateSecretName);
const mockValidateKey = vi.mocked(validateSecretKey);
const mockValidateValue = vi.mocked(validateSecretValue);
describe('useSealedSecretEncryption', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default happy path mocks
mockFetchCert.mockResolvedValue({ ok: true, value: 'fake-cert' as never });
mockParseKey.mockReturnValue({ ok: true, value: {} as never });
mockEncryptKV.mockReturnValue({
ok: true,
value: { password: 'encrypted' } as never,
});
mockParseCertInfo.mockReturnValue({
ok: true,
value: {
validFrom: new Date(),
validTo: new Date(Date.now() + 365 * 86400000),
isExpired: false,
daysUntilExpiry: 365,
issuer: 'CN=test',
subject: 'CN=test',
fingerprint: 'abc',
serialNumber: '01',
},
});
mockIsExpiringSoon.mockReturnValue(false);
mockValidateName.mockReturnValue({ valid: true });
mockValidateKey.mockReturnValue({ valid: true });
mockValidateValue.mockReturnValue({ valid: true });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should start with encrypting = false', () => {
const { result } = renderHook(() => useSealedSecretEncryption());
expect(result.current.encrypting).toBe(false);
});
it('should return error when name validation fails', async () => {
mockValidateName.mockReturnValue({ valid: false, error: 'Name is required' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: '',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Name is required', { variant: 'error' });
});
it('should return error when key validation fails', async () => {
mockValidateKey.mockReturnValue({ valid: false, error: 'Key name is required' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: '', value: 'v' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
expect.stringContaining('Key name is required'),
{ variant: 'error' }
);
});
it('should return error when value validation fails', async () => {
mockValidateValue.mockReturnValue({ valid: false, error: 'Value is required' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'pass', value: '' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
});
it('should return error for empty keyValues', async () => {
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
expect(mockEnqueueSnackbar).toHaveBeenCalledWith('At least one key-value pair is required', {
variant: 'error',
});
});
it('should return error when certificate fetch fails', async () => {
mockFetchCert.mockResolvedValue({ ok: false, error: 'Controller unreachable' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch certificate'),
{ variant: 'error' }
);
});
it('should warn when certificate is expired', async () => {
mockParseCertInfo.mockReturnValue({
ok: true,
value: {
validFrom: new Date('2020-01-01'),
validTo: new Date('2021-01-01'),
isExpired: true,
daysUntilExpiry: -500,
issuer: 'CN=test',
subject: 'CN=test',
fingerprint: 'abc',
serialNumber: '01',
},
});
const { result } = renderHook(() => useSealedSecretEncryption());
await act(async () => {
await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(expect.stringContaining('expired'), {
variant: 'warning',
});
});
it('should warn when certificate is expiring soon', async () => {
mockIsExpiringSoon.mockReturnValue(true);
mockParseCertInfo.mockReturnValue({
ok: true,
value: {
validFrom: new Date(),
validTo: new Date(Date.now() + 10 * 86400000),
isExpired: false,
daysUntilExpiry: 10,
issuer: 'CN=test',
subject: 'CN=test',
fingerprint: 'abc',
serialNumber: '01',
},
});
const { result } = renderHook(() => useSealedSecretEncryption());
await act(async () => {
await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(expect.stringContaining('expires in'), {
variant: 'warning',
});
});
it('should return error when public key parsing fails', async () => {
mockParseKey.mockReturnValue({ ok: false, error: 'Invalid cert' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
expect(mockEnqueueSnackbar).toHaveBeenCalledWith(
expect.stringContaining('Invalid certificate'),
{ variant: 'error' }
);
});
it('should return error when encryption fails', async () => {
mockEncryptKV.mockReturnValue({ ok: false, error: 'Encryption failed' });
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: unknown;
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect((encryptResult as { ok: boolean }).ok).toBe(false);
});
it('should return SealedSecret data on success', async () => {
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: { ok: boolean; value?: { sealedSecretData: unknown } };
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'password', value: 'secret' }],
});
});
expect(encryptResult!.ok).toBe(true);
if (encryptResult!.ok) {
const data = encryptResult!.value!.sealedSecretData as Record<string, unknown>;
expect(data.apiVersion).toBe('bitnami.com/v1alpha1');
expect(data.kind).toBe('SealedSecret');
expect((data.metadata as Record<string, unknown>).name).toBe('my-secret');
expect((data.metadata as Record<string, unknown>).namespace).toBe('default');
}
});
it('should add namespace-wide scope annotation', async () => {
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: {
ok: boolean;
value?: { sealedSecretData: { metadata: { annotations: Record<string, string> } } };
};
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'namespace-wide',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect(encryptResult!.ok).toBe(true);
if (encryptResult!.ok) {
expect(
encryptResult!.value!.sealedSecretData.metadata.annotations[
'sealedsecrets.bitnami.com/namespace-wide'
]
).toBe('true');
}
});
it('should add cluster-wide scope annotation', async () => {
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptResult: {
ok: boolean;
value?: { sealedSecretData: { metadata: { annotations: Record<string, string> } } };
};
await act(async () => {
encryptResult = await result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'cluster-wide',
keyValues: [{ key: 'k', value: 'v' }],
});
});
expect(encryptResult!.ok).toBe(true);
if (encryptResult!.ok) {
expect(
encryptResult!.value!.sealedSecretData.metadata.annotations[
'sealedsecrets.bitnami.com/cluster-wide'
]
).toBe('true');
}
});
it('should set encrypting state during encryption', async () => {
let resolveEncrypt: (value: unknown) => void;
mockFetchCert.mockReturnValue(
new Promise(resolve => {
resolveEncrypt = resolve;
})
);
const { result } = renderHook(() => useSealedSecretEncryption());
let encryptPromise: Promise<unknown>;
act(() => {
encryptPromise = result.current.encrypt({
name: 'my-secret',
namespace: 'default',
scope: 'strict',
keyValues: [{ key: 'k', value: 'v' }],
});
});
// Should be encrypting
expect(result.current.encrypting).toBe(true);
// Resolve the cert fetch
await act(async () => {
resolveEncrypt!({ ok: true, value: 'cert' });
await encryptPromise;
});
expect(result.current.encrypting).toBe(false);
});
});
+23 -4
View File
@@ -31,12 +31,31 @@ export interface EncryptionRequest {
keyValues: Array<{ key: string; value: string }>;
}
/**
* Shape of the SealedSecret manifest constructed for API submission
*/
interface SealedSecretManifest {
apiVersion: string;
kind: string;
metadata: {
name: string;
namespace: string;
annotations: Record<string, string>;
};
spec: {
encryptedData: Record<string, string>;
template: {
metadata: Record<string, unknown>;
};
};
}
/**
* Result of successful encryption
*/
export interface EncryptionResult {
/** The complete SealedSecret object ready to apply */
sealedSecretData: any;
sealedSecretData: SealedSecretManifest;
/** Information about the certificate used */
certificateInfo?: CertificateInfo;
}
@@ -158,7 +177,7 @@ export function useSealedSecretEncryption() {
}
// Step 6: Construct the SealedSecret object
const sealedSecretData: any = {
const sealedSecretData: SealedSecretManifest = {
apiVersion: 'bitnami.com/v1alpha1',
kind: 'SealedSecret',
metadata: {
@@ -186,8 +205,8 @@ export function useSealedSecretEncryption() {
sealedSecretData,
certificateInfo: certInfo,
});
} catch (error: any) {
const errorMsg = error.message || 'Unknown encryption error';
} catch (error: unknown) {
const errorMsg = error instanceof Error ? error.message : String(error);
enqueueSnackbar(errorMsg, { variant: 'error' });
return Err(errorMsg);
} finally {
+117
View File
@@ -0,0 +1,117 @@
/**
* Unit tests for plugin entry point
*
* Verifies that all registration functions are called at module load
*/
import { beforeAll, describe, expect, it, vi } from 'vitest';
// Mock registration functions
const mockRegisterRoute = vi.fn();
const mockRegisterSidebarEntry = vi.fn();
const mockRegisterDetailsViewSection = vi.fn();
const mockRegisterPluginSettings = vi.fn();
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
registerRoute: mockRegisterRoute,
registerSidebarEntry: mockRegisterSidebarEntry,
registerDetailsViewSection: mockRegisterDetailsViewSection,
registerPluginSettings: mockRegisterPluginSettings,
}));
// Mock all component imports to avoid deep dependency resolution
vi.mock('./components/ErrorBoundary', () => ({
ApiErrorBoundary: ({ children }: { children: React.ReactNode }) => children,
GenericErrorBoundary: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock('./components/SealedSecretList', () => ({
SealedSecretList: () => null,
}));
vi.mock('./components/SealingKeysView', () => ({
SealingKeysView: () => null,
}));
vi.mock('./components/SecretDetailsSection', () => ({
SecretDetailsSection: () => null,
}));
vi.mock('./components/SettingsPage', () => ({
SettingsPage: () => null,
}));
import React from 'react';
describe('Plugin Entry Point', () => {
beforeAll(async () => {
// Import the module to trigger side effects (registrations)
// @ts-expect-error - dynamic import not supported by base tsconfig module setting
await import('./index');
});
it('should register sidebar entries', () => {
// Main entry + 2 children = 3 sidebar entries
expect(mockRegisterSidebarEntry).toHaveBeenCalledTimes(3);
// Main "Sealed Secrets" entry
expect(mockRegisterSidebarEntry).toHaveBeenCalledWith(
expect.objectContaining({
name: 'sealed-secrets',
label: 'Sealed Secrets',
url: '/sealedsecrets',
parent: null,
})
);
// "All Sealed Secrets" child
expect(mockRegisterSidebarEntry).toHaveBeenCalledWith(
expect.objectContaining({
parent: 'sealed-secrets',
name: 'sealed-secrets-list',
})
);
// "Sealing Keys" child
expect(mockRegisterSidebarEntry).toHaveBeenCalledWith(
expect.objectContaining({
parent: 'sealed-secrets',
name: 'sealing-keys',
})
);
});
it('should register routes', () => {
// List route + Keys route = 2
expect(mockRegisterRoute).toHaveBeenCalledTimes(2);
// List/detail view route
expect(mockRegisterRoute).toHaveBeenCalledWith(
expect.objectContaining({
path: '/sealedsecrets/:namespace?/:name?',
name: 'sealedsecret',
})
);
// Keys route
expect(mockRegisterRoute).toHaveBeenCalledWith(
expect.objectContaining({
path: '/sealedsecrets/keys',
})
);
});
it('should register details view section for Secret resources', () => {
expect(mockRegisterDetailsViewSection).toHaveBeenCalledTimes(1);
expect(mockRegisterDetailsViewSection).toHaveBeenCalledWith(expect.any(Function));
});
it('should register plugin settings', () => {
expect(mockRegisterPluginSettings).toHaveBeenCalledTimes(1);
expect(mockRegisterPluginSettings).toHaveBeenCalledWith(
'sealed-secrets',
expect.any(Function),
true
);
});
});
+8 -2
View File
@@ -14,6 +14,12 @@ import {
SealedSecretStatus,
} from '../types';
interface CRDVersion {
name: string;
storage?: boolean;
served?: boolean;
}
/**
* SealedSecret CRD class
* Represents a Bitnami Sealed Secret resource in the cluster
@@ -128,7 +134,7 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
);
// Find the storage version (the version used for persistence)
const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);
const storageVersion = crd.spec?.versions?.find((v: CRDVersion) => v.storage === true);
if (storageVersion) {
const version = `${crd.spec.group}/${storageVersion.name}`;
@@ -137,7 +143,7 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
}
// Fallback to first served version if no storage version found
const servedVersion = crd.spec?.versions?.find((v: any) => v.served === true);
const servedVersion = crd.spec?.versions?.find((v: CRDVersion) => v.served === true);
if (servedVersion) {
const version = `${crd.spec.group}/${servedVersion.name}`;
this.detectedVersion = version;
+289
View File
@@ -0,0 +1,289 @@
/**
* Unit tests for controller API helpers
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
checkControllerHealth,
fetchPublicCertificate,
getPluginConfig,
rotateSealedSecret,
savePluginConfig,
} from './controller';
// Mock retry to avoid real delays
vi.mock('./retry', () => ({
retryWithBackoff: vi.fn((fn: () => Promise<unknown>) => fn()),
}));
describe('controller', () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
localStorage.clear();
});
afterEach(() => {
global.fetch = originalFetch;
localStorage.clear();
vi.restoreAllMocks();
});
describe('getPluginConfig / savePluginConfig', () => {
it('should return default config when no stored config', () => {
const config = getPluginConfig();
expect(config.controllerName).toBe('sealed-secrets-controller');
expect(config.controllerNamespace).toBe('kube-system');
expect(config.controllerPort).toBe(8080);
});
it('should round-trip saved config', () => {
const custom = {
controllerName: 'my-controller',
controllerNamespace: 'sealed-secrets',
controllerPort: 9090,
};
savePluginConfig(custom);
const loaded = getPluginConfig();
expect(loaded).toEqual(custom);
});
it('should return default config on invalid JSON', () => {
localStorage.setItem('sealed-secrets-plugin-config', 'not json');
const config = getPluginConfig();
expect(config.controllerName).toBe('sealed-secrets-controller');
});
it('should overwrite previous config', () => {
savePluginConfig({
controllerName: 'first',
controllerNamespace: 'ns1',
controllerPort: 1111,
});
savePluginConfig({
controllerName: 'second',
controllerNamespace: 'ns2',
controllerPort: 2222,
});
const config = getPluginConfig();
expect(config.controllerName).toBe('second');
});
});
describe('fetchPublicCertificate', () => {
it('should return certificate on success', async () => {
const certPEM = '-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----';
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(certPEM),
});
const config = getPluginConfig();
const result = await fetchPublicCertificate(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(certPEM);
}
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/v1/cert.pem'));
});
it('should return error on HTTP failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
statusText: 'Service Unavailable',
});
const config = getPluginConfig();
const result = await fetchPublicCertificate(config);
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Unable to fetch controller certificate');
}
});
it('should return error on network failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const config = getPluginConfig();
const result = await fetchPublicCertificate(config);
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Unable to fetch controller certificate');
}
});
});
describe('checkControllerHealth', () => {
it('should return healthy status on 200', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers({ 'X-Controller-Version': '0.24.0' }),
});
const config = getPluginConfig();
const result = await checkControllerHealth(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.healthy).toBe(true);
expect(result.value.reachable).toBe(true);
expect(result.value.version).toBe('0.24.0');
expect(result.value.latencyMs).toBeDefined();
}
});
it('should return unhealthy reachable on non-200', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
headers: new Headers(),
});
const config = getPluginConfig();
const result = await checkControllerHealth(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.healthy).toBe(false);
expect(result.value.reachable).toBe(true);
expect(result.value.error).toContain('500');
}
});
it('should return unreachable on network error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
const config = getPluginConfig();
const result = await checkControllerHealth(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.healthy).toBe(false);
expect(result.value.reachable).toBe(false);
expect(result.value.error).toBe('Connection refused');
}
});
it('should handle timeout (AbortError)', async () => {
const abortError = new Error('The operation was aborted');
abortError.name = 'AbortError';
global.fetch = vi.fn().mockRejectedValue(abortError);
const config = getPluginConfig();
const result = await checkControllerHealth(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.healthy).toBe(false);
expect(result.value.reachable).toBe(false);
expect(result.value.error).toContain('timed out');
}
});
it('should return undefined version when header is absent', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
});
const config = getPluginConfig();
const result = await checkControllerHealth(config);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.version).toBeUndefined();
}
});
it('should use correct healthz endpoint', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
});
const config = {
controllerName: 'my-ss',
controllerNamespace: 'my-ns',
controllerPort: 9090,
};
await checkControllerHealth(config);
expect(global.fetch).toHaveBeenCalledWith(
'/api/v1/namespaces/my-ns/services/http:my-ss:9090/proxy/healthz',
expect.objectContaining({ method: 'GET' })
);
});
});
describe('rotateSealedSecret', () => {
it('should return rotated YAML on success', async () => {
const rotatedYaml = '{"apiVersion":"bitnami.com/v1alpha1","kind":"SealedSecret"}';
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve(rotatedYaml),
});
const config = getPluginConfig();
const result = await rotateSealedSecret(config, '{"old":"data"}');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBe(rotatedYaml);
}
});
it('should return error on HTTP failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
});
const config = getPluginConfig();
const result = await rotateSealedSecret(config, '{"data":"test"}');
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Unable to rotate');
}
});
it('should return error on network failure', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('fetch failed'));
const config = getPluginConfig();
const result = await rotateSealedSecret(config, '{}');
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Unable to rotate');
}
});
it('should POST to rotate endpoint with JSON content type', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve('rotated'),
});
const config = getPluginConfig();
const yaml = '{"test":"data"}';
await rotateSealedSecret(config, yaml);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/v1/rotate'),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: yaml,
})
);
});
});
});
+4 -36
View File
@@ -27,7 +27,7 @@ export interface ControllerHealthStatus {
/**
* Build the controller proxy URL
*/
export function getControllerProxyURL(config: PluginConfig, path: string): string {
function getControllerProxyURL(config: PluginConfig, path: string): string {
const { controllerNamespace, controllerName, controllerPort } = config;
return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`;
}
@@ -77,38 +77,6 @@ export async function fetchPublicCertificate(
});
}
/**
* Verify that a SealedSecret can be decrypted by the controller
*
* @param config Plugin configuration
* @param sealedSecretYaml YAML or JSON of the SealedSecret
* @returns Result containing verification status or error message
*/
export async function verifySealedSecret(
config: PluginConfig,
sealedSecretYaml: string
): AsyncResult<boolean, string> {
const url = getControllerProxyURL(config, '/v1/verify');
const result = await tryCatchAsync(async () => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: sealedSecretYaml,
});
return response.ok;
});
if (result.ok === false) {
return Err(`Verification failed: ${result.error.message}`);
}
return result;
}
/**
* Rotate (re-encrypt) a SealedSecret with the current active key
*
@@ -218,14 +186,14 @@ export async function checkControllerHealth(
version,
latencyMs,
});
} catch (error: any) {
} catch (error: unknown) {
const latencyMs = Date.now() - startTime;
// Determine error type
let errorMessage = 'Controller unreachable';
if (error.name === 'AbortError') {
if (error instanceof Error && error.name === 'AbortError') {
errorMessage = 'Request timed out after 5 seconds';
} else if (error.message) {
} else if (error instanceof Error) {
errorMessage = error.message;
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Unit tests for client-side encryption utilities
*/
import forge from 'node-forge';
import { beforeAll, describe, expect, it } from 'vitest';
import { PEMCertificate, PlaintextValue } from '../types';
import {
encryptKeyValues,
isCertificateExpiringSoon,
parseCertificateInfo,
parsePublicKeyFromCert,
} from './crypto';
// Generate a real self-signed cert for testing
let validPEM: PEMCertificate;
let expiredPEM: PEMCertificate;
let expiringSoonPEM: PEMCertificate;
beforeAll(() => {
// Generate RSA key pair
const keys = forge.pki.rsa.generateKeyPair(2048);
// Valid cert (expires in 365 days)
const validCert = forge.pki.createCertificate();
validCert.publicKey = keys.publicKey;
validCert.serialNumber = '01';
validCert.validity.notBefore = new Date();
validCert.validity.notAfter = new Date();
validCert.validity.notAfter.setFullYear(validCert.validity.notAfter.getFullYear() + 1);
validCert.setSubject([{ name: 'commonName', value: 'test-controller' }]);
validCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]);
validCert.sign(keys.privateKey, forge.md.sha256.create());
validPEM = PEMCertificate(forge.pki.certificateToPem(validCert));
// Expired cert
const expiredCert = forge.pki.createCertificate();
expiredCert.publicKey = keys.publicKey;
expiredCert.serialNumber = '02';
expiredCert.validity.notBefore = new Date('2020-01-01');
expiredCert.validity.notAfter = new Date('2021-01-01');
expiredCert.setSubject([{ name: 'commonName', value: 'expired-controller' }]);
expiredCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]);
expiredCert.sign(keys.privateKey, forge.md.sha256.create());
expiredPEM = PEMCertificate(forge.pki.certificateToPem(expiredCert));
// Expiring soon cert (15 days from now)
const expiringSoonCert = forge.pki.createCertificate();
expiringSoonCert.publicKey = keys.publicKey;
expiringSoonCert.serialNumber = '03';
expiringSoonCert.validity.notBefore = new Date();
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + 15);
expiringSoonCert.validity.notAfter = expiryDate;
expiringSoonCert.setSubject([{ name: 'commonName', value: 'expiring-controller' }]);
expiringSoonCert.setIssuer([{ name: 'commonName', value: 'test-issuer' }]);
expiringSoonCert.sign(keys.privateKey, forge.md.sha256.create());
expiringSoonPEM = PEMCertificate(forge.pki.certificateToPem(expiringSoonCert));
});
describe('crypto', () => {
describe('parsePublicKeyFromCert', () => {
it('should parse valid PEM certificate', () => {
const result = parsePublicKeyFromCert(validPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toBeDefined();
expect(result.value.n).toBeDefined(); // RSA modulus
expect(result.value.e).toBeDefined(); // RSA exponent
}
});
it('should return error for invalid PEM', () => {
const result = parsePublicKeyFromCert(PEMCertificate('not a cert'));
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Failed to parse certificate');
}
});
it('should return error for empty string', () => {
const result = parsePublicKeyFromCert(PEMCertificate(''));
expect(result.ok).toBe(false);
});
it('should return error for malformed PEM markers', () => {
const result = parsePublicKeyFromCert(
PEMCertificate('-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----')
);
expect(result.ok).toBe(false);
});
});
describe('encryptKeyValues', () => {
it('should encrypt key-value pairs with strict scope', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
expect(keyResult.ok).toBe(true);
if (!keyResult.ok) return;
const result = encryptKeyValues(
keyResult.value,
[{ key: 'password', value: PlaintextValue('secret123') }],
'default',
'my-secret',
'strict'
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value).toHaveProperty('password');
// Should be base64 encoded
expect(() => forge.util.decode64(result.value.password)).not.toThrow();
}
});
it('should encrypt with namespace-wide scope', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
if (!keyResult.ok) return;
const result = encryptKeyValues(
keyResult.value,
[{ key: 'token', value: PlaintextValue('abc') }],
'prod',
'my-secret',
'namespace-wide'
);
expect(result.ok).toBe(true);
});
it('should encrypt with cluster-wide scope', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
if (!keyResult.ok) return;
const result = encryptKeyValues(
keyResult.value,
[{ key: 'key', value: PlaintextValue('val') }],
'ns',
'name',
'cluster-wide'
);
expect(result.ok).toBe(true);
});
it('should encrypt multiple keys', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
if (!keyResult.ok) return;
const result = encryptKeyValues(
keyResult.value,
[
{ key: 'username', value: PlaintextValue('admin') },
{ key: 'password', value: PlaintextValue('secret') },
{ key: 'token', value: PlaintextValue('abc123') },
],
'default',
'my-secret',
'strict'
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(Object.keys(result.value)).toHaveLength(3);
expect(result.value).toHaveProperty('username');
expect(result.value).toHaveProperty('password');
expect(result.value).toHaveProperty('token');
}
});
it('should produce different ciphertext for same plaintext (randomness)', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
if (!keyResult.ok) return;
const encrypt = () =>
encryptKeyValues(
keyResult.value,
[{ key: 'key', value: PlaintextValue('same-value') }],
'ns',
'name',
'strict'
);
const result1 = encrypt();
const result2 = encrypt();
expect(result1.ok).toBe(true);
expect(result2.ok).toBe(true);
if (result1.ok && result2.ok) {
expect(result1.value.key).not.toBe(result2.value.key);
}
});
it('should handle empty key-value array', () => {
const keyResult = parsePublicKeyFromCert(validPEM);
if (!keyResult.ok) return;
const result = encryptKeyValues(keyResult.value, [], 'ns', 'name', 'strict');
expect(result.ok).toBe(true);
if (result.ok) {
expect(Object.keys(result.value)).toHaveLength(0);
}
});
});
describe('parseCertificateInfo', () => {
it('should parse valid certificate info', () => {
const result = parseCertificateInfo(validPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.validFrom).toBeInstanceOf(Date);
expect(result.value.validTo).toBeInstanceOf(Date);
expect(result.value.isExpired).toBe(false);
expect(result.value.daysUntilExpiry).toBeGreaterThan(0);
expect(result.value.subject).toContain('test-controller');
expect(result.value.issuer).toContain('test-issuer');
expect(result.value.fingerprint).toBeDefined();
expect(result.value.serialNumber).toBeDefined();
}
});
it('should detect expired certificate', () => {
const result = parseCertificateInfo(expiredPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isExpired).toBe(true);
expect(result.value.daysUntilExpiry).toBeLessThan(0);
}
});
it('should calculate days until expiry for expiring-soon cert', () => {
const result = parseCertificateInfo(expiringSoonPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.isExpired).toBe(false);
expect(result.value.daysUntilExpiry).toBeLessThanOrEqual(15);
expect(result.value.daysUntilExpiry).toBeGreaterThanOrEqual(14);
}
});
it('should return error for invalid PEM', () => {
const result = parseCertificateInfo(PEMCertificate('not a cert'));
expect(result.ok).toBe(false);
if (result.ok === false) {
expect(result.error).toContain('Failed to parse certificate info');
}
});
it('should compute SHA-256 fingerprint', () => {
const result = parseCertificateInfo(validPEM);
expect(result.ok).toBe(true);
if (result.ok) {
// Fingerprint should be uppercase hex
expect(result.value.fingerprint).toMatch(/^[0-9A-F]+$/);
expect(result.value.fingerprint.length).toBe(64); // SHA-256 = 32 bytes = 64 hex chars
}
});
});
describe('isCertificateExpiringSoon', () => {
it('should return true when within threshold', () => {
const result = parseCertificateInfo(expiringSoonPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(isCertificateExpiringSoon(result.value, 30)).toBe(true);
}
});
it('should return false when not within threshold', () => {
const result = parseCertificateInfo(validPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(isCertificateExpiringSoon(result.value, 30)).toBe(false);
}
});
it('should return false for expired certificate', () => {
const result = parseCertificateInfo(expiredPEM);
expect(result.ok).toBe(true);
if (result.ok) {
expect(isCertificateExpiringSoon(result.value, 30)).toBe(false);
}
});
it('should work with custom thresholds', () => {
const result = parseCertificateInfo(expiringSoonPEM);
expect(result.ok).toBe(true);
if (result.ok) {
// 15-day cert should be within 20-day threshold
expect(isCertificateExpiringSoon(result.value, 20)).toBe(true);
// But not within 10-day threshold
expect(isCertificateExpiringSoon(result.value, 10)).toBe(false);
}
});
});
});
+2 -13
View File
@@ -52,7 +52,7 @@ export function parsePublicKeyFromCert(
* @param scope The encryption scope
* @returns Result containing base64-encoded encrypted value or error message
*/
export function encryptValue(
function encryptValue(
publicKey: forge.pki.rsa.PublicKey,
value: PlaintextValue,
namespace: string,
@@ -98,7 +98,7 @@ export function encryptValue(
const tag = (cipher.mode as any).tag.getBytes();
// Construct the sealed secret format:
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
// [2-byte key length][encrypted key][IV][ciphertext][auth tag]
const sessionKeyLength = encryptedSessionKey.length;
const lengthBytes =
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
@@ -145,17 +145,6 @@ export function encryptKeyValues(
return Ok(encryptedData);
}
/**
* Validate a PEM certificate
*
* @param pemCert PEM-encoded certificate string (branded type)
* @returns true if certificate is valid, false otherwise
*/
export function validateCertificate(pemCert: PEMCertificate): boolean {
const result = parsePublicKeyFromCert(pemCert);
return result.ok;
}
/**
* Parse certificate and extract metadata
*
+197
View File
@@ -0,0 +1,197 @@
/**
* Unit tests for RBAC permission checking
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { canDecryptSecrets, checkSealedSecretPermissions } from './rbac';
describe('rbac', () => {
let originalFetch: typeof global.fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('checkSealedSecretPermissions', () => {
it('should return all true when all permissions are allowed', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
const result = await checkSealedSecretPermissions('default');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.canCreate).toBe(true);
expect(result.value.canRead).toBe(true);
expect(result.value.canUpdate).toBe(true);
expect(result.value.canDelete).toBe(true);
expect(result.value.canList).toBe(true);
}
});
it('should return all false when all permissions are denied', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: false } }),
});
const result = await checkSealedSecretPermissions('default');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.canCreate).toBe(false);
expect(result.value.canRead).toBe(false);
expect(result.value.canUpdate).toBe(false);
expect(result.value.canDelete).toBe(false);
expect(result.value.canList).toBe(false);
}
});
it('should handle mixed permissions', async () => {
let callCount = 0;
global.fetch = vi.fn().mockImplementation(() => {
callCount++;
// create=true, get=false, update=true, delete=false, list=true
const allowed = callCount % 2 !== 0;
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ status: { allowed } }),
});
});
const result = await checkSealedSecretPermissions('default');
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.canCreate).toBe(true);
expect(result.value.canRead).toBe(false);
expect(result.value.canUpdate).toBe(true);
expect(result.value.canDelete).toBe(false);
expect(result.value.canList).toBe(true);
}
});
it('should make 5 SelfSubjectAccessReview requests', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
await checkSealedSecretPermissions('test-ns');
expect(global.fetch).toHaveBeenCalledTimes(5);
});
it('should send correct request body structure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
await checkSealedSecretPermissions('my-ns');
// Check the first call (create)
const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
const firstCallBody = JSON.parse(calls[0][1].body);
expect(firstCallBody.apiVersion).toBe('authorization.k8s.io/v1');
expect(firstCallBody.kind).toBe('SelfSubjectAccessReview');
expect(firstCallBody.spec.resourceAttributes.resource).toBe('sealedsecrets');
expect(firstCallBody.spec.resourceAttributes.group).toBe('bitnami.com');
expect(firstCallBody.spec.resourceAttributes.namespace).toBe('my-ns');
expect(firstCallBody.spec.resourceAttributes.verb).toBe('create');
});
it('should omit namespace when not provided', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
await checkSealedSecretPermissions();
const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
const firstCallBody = JSON.parse(calls[0][1].body);
expect(firstCallBody.spec.resourceAttributes.namespace).toBeUndefined();
});
it('should return false when fetch fails for individual checks', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
});
const result = await checkSealedSecretPermissions('default');
expect(result.ok).toBe(true);
if (result.ok) {
// Individual failures return false (assume no permission)
expect(result.value.canCreate).toBe(false);
}
});
it('should return Err when Promise.all rejects', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
const result = await checkSealedSecretPermissions('default');
// The tryCatchAsync in checkPermission catches this, so individual results are false
// But if the outer try/catch catches, we get Err
// With current implementation, individual failures return false, not Err
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.canCreate).toBe(false);
}
});
});
describe('canDecryptSecrets', () => {
it('should return true when get secrets is allowed', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
const result = await canDecryptSecrets('default');
expect(result).toBe(true);
});
it('should return false when get secrets is denied', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: false } }),
});
const result = await canDecryptSecrets('default');
expect(result).toBe(false);
});
it('should return false on error', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('network error'));
const result = await canDecryptSecrets('default');
expect(result).toBe(false);
});
it('should check secrets resource with get verb', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: { allowed: true } }),
});
await canDecryptSecrets('prod');
const body = JSON.parse((global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
expect(body.spec.resourceAttributes.resource).toBe('secrets');
expect(body.spec.resourceAttributes.verb).toBe('get');
expect(body.spec.resourceAttributes.namespace).toBe('prod');
});
});
});
+6 -49
View File
@@ -51,8 +51,12 @@ export async function checkSealedSecretPermissions(
canDelete,
canList,
});
} catch (error: any) {
return Err(`Failed to check SealedSecret permissions: ${error.message}`);
} catch (error: unknown) {
return Err(
`Failed to check SealedSecret permissions: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
@@ -70,20 +74,6 @@ export async function canDecryptSecrets(namespace: string): Promise<boolean> {
}
}
/**
* Check if user can view sealing keys (requires get permission on Secrets in controller namespace)
*
* @param controllerNamespace Namespace where sealed-secrets controller is running
* @returns true if user has permission to get Secrets in controller namespace
*/
export async function canViewSealingKeys(controllerNamespace: string): Promise<boolean> {
try {
return await checkPermission('get', 'secrets', '', controllerNamespace);
} catch {
return false;
}
}
/**
* Check a specific permission using SelfSubjectAccessReview
*
@@ -130,36 +120,3 @@ async function checkPermission(
// Return false on error (assume no permission)
return result.ok ? result.value : false;
}
/**
* Check permissions for multiple namespaces
*
* Useful for multi-namespace views to determine which namespaces the user
* can interact with.
*
* @param namespaces Array of namespace names to check
* @returns Map of namespace to permissions
*/
export async function checkMultiNamespacePermissions(
namespaces: string[]
): AsyncResult<Record<string, ResourcePermissions>, string> {
try {
const results = await Promise.all(
namespaces.map(async ns => {
const perms = await checkSealedSecretPermissions(ns);
return { namespace: ns, permissions: perms };
})
);
const permissionsMap: Record<string, ResourcePermissions> = {};
for (const { namespace, permissions } of results) {
if (permissions.ok) {
permissionsMap[namespace] = permissions.value;
}
}
return Ok(permissionsMap);
} catch (error: any) {
return Err(`Failed to check multi-namespace permissions: ${error.message}`);
}
}
-51
View File
@@ -133,54 +133,3 @@ export async function retryWithBackoff<T, E>(
// Should never reach here, but TypeScript needs it
return Err(`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`);
}
/**
* Predicate to check if error is a network error (retryable)
*
* @param error Error to check
* @returns true if error is network-related
*/
export function isNetworkError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes('network') ||
message.includes('timeout') ||
message.includes('fetch') ||
message.includes('connection') ||
message.includes('econnrefused') ||
message.includes('enotfound')
);
}
/**
* Predicate to check if HTTP error is retryable (5xx, 429, 408)
*
* @param error Error to check
* @returns true if HTTP status is retryable
*/
export function isRetryableHttpError(error: Error): boolean {
const message = error.message;
// Check for 5xx server errors
if (/5\d{2}/.test(message)) {
return true;
}
// Check for specific retryable status codes
return (
message.includes('429') || // Too Many Requests
message.includes('408') || // Request Timeout
message.includes('503') || // Service Unavailable
message.includes('504')
); // Gateway Timeout
}
/**
* Combined predicate for network and HTTP errors
*
* @param error Error to check
* @returns true if error is retryable
*/
export function isRetryableError(error: Error): boolean {
return isNetworkError(error) || isRetryableHttpError(error);
}
-22
View File
@@ -15,7 +15,6 @@ const localStorageMock = {
import { describe, expect, it } from 'vitest';
import {
isValidNamespace,
validatePEMCertificate,
validateSecretKey,
validateSecretName,
@@ -80,27 +79,6 @@ describe('validators', () => {
});
});
describe('validateNamespace', () => {
it('should accept valid namespace names', () => {
expect(isValidNamespace('default')).toBe(true);
expect(isValidNamespace('kube-system')).toBe(true);
expect(isValidNamespace('my-namespace')).toBe(true);
expect(isValidNamespace('ns-123')).toBe(true);
});
it('should reject invalid namespace names', () => {
expect(isValidNamespace('')).toBe(false);
expect(isValidNamespace('My-Namespace')).toBe(false);
expect(isValidNamespace('-namespace')).toBe(false);
expect(isValidNamespace('namespace-')).toBe(false);
expect(isValidNamespace('namespace_name')).toBe(false);
});
it('should reject namespaces exceeding 253 characters', () => {
expect(isValidNamespace('x'.repeat(254))).toBe(false);
});
});
describe('validateSecretKey', () => {
it('should accept valid secret keys', () => {
expect(validateSecretKey('password').valid).toBe(true);
+3 -96
View File
@@ -5,51 +5,6 @@
* and runtime type checking for SealedSecret objects.
*/
import { SealedSecretInterface, SealedSecretScope } from '../types';
import { SealedSecret } from './SealedSecretCRD';
/**
* Runtime type guard for SealedSecret
*
* @param obj Object to check
* @returns true if obj is a SealedSecret instance
*/
export function isSealedSecret(obj: any): obj is SealedSecret {
return (
obj instanceof SealedSecret &&
obj.jsonData &&
'spec' in obj.jsonData &&
'encryptedData' in obj.jsonData.spec
);
}
/**
* Validate SealedSecret structure
*
* @param obj Object to validate
* @returns true if obj has valid SealedSecret structure
*/
export function validateSealedSecretInterface(obj: any): obj is SealedSecretInterface {
return (
typeof obj === 'object' &&
obj !== null &&
'spec' in obj &&
typeof obj.spec === 'object' &&
'encryptedData' in obj.spec &&
typeof obj.spec.encryptedData === 'object'
);
}
/**
* Validate scope value
*
* @param value Value to check
* @returns true if value is a valid SealedSecretScope
*/
export function isSealedSecretScope(value: any): value is SealedSecretScope {
return ['strict', 'namespace-wide', 'cluster-wide'].includes(value);
}
/**
* Validate Kubernetes resource name
*
@@ -61,7 +16,7 @@ export function isSealedSecretScope(value: any): value is SealedSecretScope {
* @param name Name to validate
* @returns true if valid Kubernetes resource name
*/
export function isValidK8sName(name: string): boolean {
function isValidK8sName(name: string): boolean {
if (!name || name.length === 0 || name.length > 253) {
return false;
}
@@ -76,7 +31,7 @@ export function isValidK8sName(name: string): boolean {
* @param key Key to validate
* @returns true if valid Kubernetes key
*/
export function isValidK8sKey(key: string): boolean {
function isValidK8sKey(key: string): boolean {
if (!key || key.length === 0 || key.length > 253) {
return false;
}
@@ -93,7 +48,7 @@ export function isValidK8sKey(key: string): boolean {
* @param value String to validate
* @returns true if valid PEM format
*/
export function isValidPEM(value: string): boolean {
function isValidPEM(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
@@ -103,28 +58,6 @@ export function isValidPEM(value: string): boolean {
return pemRegex.test(value.trim());
}
/**
* Validate that a value is not empty
*
* @param value Value to check
* @returns true if value is non-empty string
*/
export function isNonEmpty(value: string): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
/**
* Validate namespace name
*
* Same rules as resource names
*
* @param namespace Namespace to validate
* @returns true if valid namespace name
*/
export function isValidNamespace(namespace: string): boolean {
return isValidK8sName(namespace);
}
/**
* Validation result with error message
*/
@@ -223,29 +156,3 @@ export function validatePEMCertificate(pem: string): ValidationResult {
return { valid: true };
}
/**
* Validate plugin configuration
*
* @param config Configuration to validate
* @returns Validation result with error message if invalid
*/
export function validatePluginConfig(config: {
controllerName?: string;
controllerNamespace?: string;
controllerPort?: number;
}): ValidationResult {
if (!config.controllerName || !isValidK8sName(config.controllerName)) {
return { valid: false, error: 'Invalid controller name' };
}
if (!config.controllerNamespace || !isValidNamespace(config.controllerNamespace)) {
return { valid: false, error: 'Invalid controller namespace' };
}
if (!config.controllerPort || config.controllerPort < 1 || config.controllerPort > 65535) {
return { valid: false, error: 'Invalid controller port (must be 1-65535)' };
}
return { valid: true };
}
+1 -42
View File
@@ -75,17 +75,6 @@ export function PlaintextValue(value: string): PlaintextValue {
return value as PlaintextValue;
}
/**
* Create a branded encrypted value
* This is typically used by encryption functions
*
* @example
* return Ok(EncryptedValue(encryptedString));
*/
export function EncryptedValue(value: string): EncryptedValue {
return value as EncryptedValue;
}
/**
* Create a branded base64 string
*
@@ -106,17 +95,6 @@ export function PEMCertificate(value: string): PEMCertificate {
return value as PEMCertificate;
}
/**
* Unwrap a branded type to get the raw string
* Use sparingly - only when you need the raw value
*
* @example
* const rawValue = unwrap(plaintextValue);
*/
export function unwrap<T extends string>(value: T): string {
return value;
}
/**
* Helper to create a success result
*
@@ -196,7 +174,7 @@ export interface SealedSecretSpec {
/**
* SealedSecret status condition
*/
export interface SealedSecretCondition {
interface SealedSecretCondition {
type: string;
status: 'True' | 'False' | 'Unknown';
lastTransitionTime?: string;
@@ -233,15 +211,6 @@ export interface PluginConfig {
controllerPort: number;
}
/**
* Default plugin configuration
*/
export const DEFAULT_CONFIG: PluginConfig = {
controllerName: 'sealed-secrets-controller',
controllerNamespace: 'kube-system',
controllerPort: 8080,
};
/**
* Key-value pair for encryption dialog
*/
@@ -250,16 +219,6 @@ export interface SecretKeyValue {
value: string;
}
/**
* Encryption request parameters
*/
export interface EncryptionRequest {
name: string;
namespace: string;
scope: SealedSecretScope;
keyValues: SecretKeyValue[];
}
/**
* Certificate information extracted from PEM certificate
*/
+1 -1
View File
@@ -1,7 +1,7 @@
{
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
"compilerOptions": {
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+3
View File
@@ -1,6 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
define: {
'process.env.NODE_ENV': '"test"',
},
test: {
globals: true,
environment: 'jsdom',