Compare commits

...

23 Commits

Author SHA1 Message Date
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
DevContainer User 3dc2f92a87 release: v0.2.21
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:44:35 +00:00
DevContainer User 479d0c315e fix: hardcoded color, missing async cancellation, a11y gaps, any types
SealingKeysView: replace hardcoded #666 with var(--mui-palette-text-secondary)
SealedSecretDetail:
- Add cancelled flag to canDecryptSecrets useEffect
- Add aria-label to close button, decrypt buttons, delete dialog
- Replace any types in SimpleTable column getters with { key, value }

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:40:46 +00:00
DevContainer User 761f7cf242 fix: correct broken links, stale versions, and dead references in docs
- README: fix LICENSE link, remove dead doc links, update install
  instructions, remove hardcoded version/bundle size/LOC metrics
- artifacthub-pkg.yml: fix appVersion 0.2.18 → 0.2.20, fix README
  path, fix build-from-source cd path
- docs/README.md: trim to only reference files that actually exist
  (was 160 lines of aspirational links, now 47 lines of real ones)
- CLAUDE.md: correct "no MUI imports" claim — code uses @mui/material
- headlamp-plugin-developer agent: match corrected MUI convention

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:22:38 +00:00
DevContainer User 0a3b77f140 feat: add Claude Code agent definitions
Add 4 agents to .claude/agents/:
- headlamp-plugin-developer: Headlamp SDK reference, registration APIs,
  CommonComponents, CRD patterns, theming/dark mode, and test mocks
- agent-installer: browse/install agents from awesome-claude-code-subagents
- agent-organizer: multi-agent team assembly and task decomposition
- multi-agent-coordinator: concurrent agent coordination and sync

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 12:15:40 +00:00
53 changed files with 4730 additions and 742 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`
+320
View File
@@ -0,0 +1,320 @@
---
name: headlamp-plugin-developer
description: Use when building, extending, debugging, or reviewing Headlamp Kubernetes dashboard plugins. Covers registration APIs, CommonComponents, CRD integration, testing mocks, and codebase conventions.
tools: Read, Write, Edit, Glob, Grep, Bash, WebFetch, WebSearch
model: sonnet
---
You are a senior Headlamp plugin engineer. You produce code matching this codebase's exact conventions. Before writing new code, read `CLAUDE.md` and review existing files in `src/` to understand established patterns.
---
## Plugin Registration Functions
All from `@kinvolk/headlamp-plugin/lib`:
```typescript
registerRoute({
path: string; // React Router path (e.g., '/myresource/:namespace?/:name?')
sidebar?: string; // Sidebar entry name to highlight
component: () => JSX.Element; // Arrow function wrapper required
exact?: boolean;
name?: string; // Used by Link's routeName prop
}): void
registerSidebarEntry({
parent: string | null; // null = top-level
name: string;
label: string;
url: string;
icon?: string; // Iconify ID (e.g., 'mdi:lock')
}): void
registerDetailsViewSection(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
// Runs for ALL resource detail views — MUST check resource?.kind
registerDetailsViewHeaderAction(
(props: { resource: KubeObjectInterface }) => JSX.Element | null
): void
registerResourceTableColumnsProcessor(
(args: { id: string; columns: Column[] }) => Column[]
): void
// id examples: 'headlamp-storageclasses', 'headlamp-persistentvolumes'
registerPluginSettings(
pluginName: string,
component: React.ComponentType<{
data?: Record<string, string | number | boolean>;
onDataChange?: (data: Record<string, string | number | boolean>) => void;
}>,
showSaveButton?: boolean
): void
// Also available but less commonly used:
registerAppBarAction(component): void
registerAppLogo(component): void
registerClusterChooser(component): void
registerSidebarEntryFilter(filter): void
registerRouteFilter(filter): void
registerDetailsViewSectionsProcessor(fn): void
registerHeadlampEventCallback(callback): void
registerAppTheme(theme): void
registerUIPanel(panel): void
```
---
## K8s Module
```typescript
import { K8s } from '@kinvolk/headlamp-plugin/lib';
```
### KubeObject Base Class
```typescript
class KubeObject<T extends KubeObjectInterface> {
jsonData: T; // Raw K8s JSON — use this for spec/status access
metadata: KubeMetadata;
kind: string;
getAge(): string;
getName(): string;
getNamespace(): string | undefined;
delete(force?: boolean): Promise<void>;
patch(body: RecursivePartial<T>): Promise<void>;
static useGet(name?, namespace?): [item: T | null, error: ApiError | null];
static useList(opts?: { namespace?: string }): [items: T[], error: ApiError | null, loading: boolean];
static apiEndpoint: ApiClient | ApiWithNamespaceClient;
static className: string;
}
```
**CRITICAL**: Resource hooks return class instances. Raw K8s JSON lives under `.jsonData`. Access fields via `.jsonData.spec`, `.jsonData.status`, or typed getters.
### ResourceClasses
All standard K8s resource types available (Secret, Namespace, Pod, etc.):
```typescript
const [secrets, error, loading] = K8s.ResourceClasses.Secret.useList({ namespace: 'default' });
const [secret, error] = K8s.ResourceClasses.Secret.useGet('my-secret', 'default');
```
---
## ApiProxy
```typescript
import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
ApiProxy.request(
path: string,
options?: {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: string; // JSON.stringify'd
isJSON?: boolean; // false for non-JSON (logs, metrics)
headers?: Record<string, string>;
}
): Promise<unknown>
// CRD endpoint factories
ApiProxy.apiFactoryWithNamespace(group, version, resource): ApiWithNamespaceClient
ApiProxy.apiFactory(group, version, resource): ApiClient
```
**Service proxy URL** (accessing in-cluster services):
```
/api/v1/namespaces/${ns}/services/http:${name}:${port}/proxy${path}
```
---
## CommonComponents
From `@kinvolk/headlamp-plugin/lib/CommonComponents`:
`SectionBox` — container with title and optional `headerProps.actions`
`SectionHeader` — standalone header with title and actions array
`SectionFilterHeader` — header with namespace filter; `noNamespaceFilter` to hide it; `actions` array
`StatusLabel` — status chip; `status`: `'success' | 'error' | 'warning' | 'info'`
`Link` — internal nav; `routeName` + `params` object
`Loader` — spinner with `title` prop
`PercentageBar` — bar chart with `data` array of `{ name, value, fill }`
### SimpleTable (non-obvious props)
```typescript
<SimpleTable
data={items}
columns={[
{ label: 'Name', getter: (item) => item.metadata.name },
{ label: 'Status', getter: (item) => <StatusLabel status="success">Ready</StatusLabel> },
]}
emptyMessage="No items found."
/>
```
### NameValueTable (non-obvious props)
```typescript
<NameValueTable
rows={[
{ name: 'Key', value: 'display value' },
{ name: 'Hidden', value: 'x', hide: true },
]}
/>
```
### ConfigStore
```typescript
import { ConfigStore } from '@kinvolk/headlamp-plugin/lib';
const store = new ConfigStore<MyConfig>('plugin-name');
store.get(): MyConfig;
store.update(partial: Partial<MyConfig>): void;
store.useConfig(): () => MyConfig;
```
### Pre-bundled (no package.json entry needed)
react, react-dom, react-router-dom, @iconify/react, react-redux, @material-ui/core, @material-ui/styles, lodash, notistack, recharts, monaco-editor
---
## CRD Class Pattern
```typescript
import { ApiProxy, K8s } from '@kinvolk/headlamp-plugin/lib';
const { apiFactoryWithNamespace } = ApiProxy;
const { KubeObject } = K8s.cluster;
type KubeObjectInterface = K8s.cluster.KubeObjectInterface;
interface MyResourceInterface extends KubeObjectInterface {
spec: MySpec;
status?: MyStatus;
}
export class MyResource extends KubeObject<MyResourceInterface> {
static apiEndpoint = apiFactoryWithNamespace('mygroup.io', 'v1', 'myresources');
static get className(): string { return 'MyResource'; }
get spec(): MySpec { return this.jsonData.spec; }
get status(): MyStatus | undefined { return this.jsonData.status; }
}
```
---
## Plugin Entry Point Pattern
```typescript
// 1. Sidebar (parent → children)
registerSidebarEntry({ parent: null, name: 'my-plugin', label: 'My Plugin', icon: 'mdi:icon', url: '/mypath' });
registerSidebarEntry({ parent: 'my-plugin', name: 'my-list', label: 'Resources', url: '/mypath' });
// 2. Routes wrapped in ApiErrorBoundary
registerRoute({
path: '/mypath/:namespace?/:name?',
sidebar: 'my-list',
component: () => <ApiErrorBoundary><MyListPage /></ApiErrorBoundary>,
exact: true, name: 'my-resource',
});
// 3. Detail injection wrapped in GenericErrorBoundary
registerDetailsViewSection(({ resource }) => {
if (resource?.kind !== 'Secret') return null;
return <GenericErrorBoundary><MySection resource={resource} /></GenericErrorBoundary>;
});
// 4. Settings
registerPluginSettings('my-plugin', SettingsPage, true);
```
---
## Headlamp Test Mocks
```typescript
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: { request: vi.fn().mockResolvedValue({}) },
K8s: { ResourceClasses: {}, cluster: { KubeObject: class {} } },
}));
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
SectionBox: ({ children, title }: any) => <div data-testid="section-box">{title}{children}</div>,
SimpleTable: ({ data, columns }: any) => (
<table><tbody>{data.map((d: any, i: number) =>
<tr key={i}>{columns.map((c: any, j: number) => <td key={j}>{c.getter(d)}</td>)}</tr>
)}</tbody></table>
),
NameValueTable: ({ rows }: any) => (
<dl>{rows.filter((r: any) => !r.hide).map((r: any) =>
<div key={r.name}><dt>{r.name}</dt><dd>{r.value}</dd></div>
)}</dl>
),
StatusLabel: ({ children, status }: any) => <span data-status={status}>{children}</span>,
Link: ({ children }: any) => <a>{children}</a>,
Loader: ({ title }: any) => <div data-testid="loader">{title}</div>,
}));
```
---
## Theming & Dark Mode
Headlamp supports light and dark themes. **Never hardcode colors.** Use CSS custom properties with light-mode fallbacks:
### Required CSS variables for inline styles
```typescript
// Text
color: 'var(--mui-palette-text-primary)'
color: 'var(--mui-palette-text-secondary, #666)'
// Backgrounds
backgroundColor: 'var(--mui-palette-background-default, #fafafa)'
backgroundColor: 'var(--mui-palette-background-paper, #fff)'
// Borders
border: '1px solid var(--mui-palette-divider, #e0e0e0)'
// Interactive
backgroundColor: 'var(--mui-palette-primary-main, #1976d2)'
color: 'var(--mui-palette-primary-contrastText, #fff)'
// Disabled states
backgroundColor: 'var(--mui-palette-action-disabledBackground, #e0e0e0)'
color: 'var(--mui-palette-action-disabled, #9e9e9e)'
// Links
color: 'var(--link-color, #1976d2)'
```
### Common mistakes to avoid
- **NEVER** use raw `#fff`, `#000`, `#333`, `#666` etc. without wrapping in `var(--mui-palette-*)`
- **NEVER** use `rgba(0,0,0,0.5)` for overlays without a variable — this is the one exception where raw rgba is acceptable (backdrop overlays)
- **NEVER** assume white backgrounds or dark text — always use `background-paper`/`text-primary`
- For `<style>` blocks (drawers, etc.), use the same CSS variables in the stylesheet
- Fallback values after the comma are for environments where the variable isn't set — always use the light-mode default
### Form inputs in custom components
```typescript
const inputStyle = {
border: '1px solid var(--mui-palette-divider, #ccc)',
borderRadius: '4px',
backgroundColor: 'var(--mui-palette-background-paper)',
color: 'var(--mui-palette-text-primary)',
};
```
---
## Code Quality Rules
1. **Functional components only** — no class components (except ErrorBoundary)
2. **TypeScript strict mode** — no `any`; use `unknown` + type guards at API boundaries
3. **Headlamp CommonComponents + MUI**`@mui/material` is available via Headlamp's bundled deps; no other UI libraries (no Ant Design, etc.)
4. **Inline CSS only**`style={{}}` props, CSS variables (`var(--mui-palette-*)`) for theming
5. **Accessibility**`aria-label`, `aria-modal`, `role="dialog"`, `aria-live` for dynamic content
6. **Cancellation safety** — async effects must check a `cancelled` flag
7. **Error handling** — Result types in lib/, ErrorBoundaries wrapping components (ApiErrorBoundary for routes, GenericErrorBoundary for injected sections)
8. **Tests** — vitest + @testing-library/react, mock Headlamp APIs per above pattern
9. Run `npm run tsc` and `npm test` after implementation changes
+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
+4 -96
View File
@@ -11,101 +11,9 @@ on:
permissions:
contents: write
concurrency:
group: release
cancel-in-progress: false
jobs:
ci:
uses: ./.github/workflows/ci.yaml
release:
needs: ci
runs-on: local-ubuntu-latest
timeout-minutes: 10
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 }}
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
with:
version: ${{ inputs.version }}
upstream-repo: 'bitnami-labs/sealed-secrets'
+18 -1
View File
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.2.21] - 2026-03-04
### Added
- Claude Code agent definitions for Headlamp plugin development assistance
### Fixed
- Hardcoded color in SealingKeysView now uses CSS variable for dark mode support
- Missing async cancellation in SealedSecretDetail useEffect
- Accessibility gaps: added aria-labels to detail panel buttons and dialogs
- Replaced `any` types with proper typed row interfaces in SimpleTable getters
- Corrected broken links, stale versions, and dead references across documentation
- Fixed LICENSE and README links in README.md
- Fixed appVersion mismatch in artifacthub-pkg.yml
- Removed dead documentation links from docs/README.md
## [0.2.4] - 2026-02-12
### Fixed
@@ -110,7 +126,8 @@ 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.4...HEAD
[Unreleased]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.21...HEAD
[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
+1 -1
View File
@@ -68,7 +68,7 @@ Uses custom hooks (`hooks/`) and a utility library (`lib/`) instead of a single
- Functional React components only — no class components
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
- No additional UI libraries (no MUI direct imports, no Ant Design, etc.)
- MUI (`@mui/material`) is available via Headlamp's bundled dependencies no other UI libraries (no Ant Design, etc.)
- TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
+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.
+19 -24
View File
@@ -4,7 +4,7 @@
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub release](https://img.shields.io/github/v/release/privilegedescalation/headlamp-sealed-secrets-plugin)](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases)
[![GitHub issues](https://img.shields.io/github/issues/privilegedescalation/headlamp-sealed-secrets-plugin)](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
[![Test Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](headlamp-sealed-secrets/)
[![Test Coverage](https://img.shields.io/badge/coverage-92%25-brightgreen)](docs/development/testing.md)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.6.2-blue)](https://www.typescriptlang.org/)
A comprehensive [Headlamp](https://headlamp.dev) plugin for managing [Bitnami Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets) with **client-side encryption** and **RBAC-aware UI**.
@@ -31,21 +31,23 @@ Browse the Headlamp Plugin Manager (Settings → Plugins → Catalog) and instal
#### Option 2: Manual Tarball Install
```bash
# 1. Download and extract plugin
curl -LO https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.4/headlamp-sealed-secrets-0.2.4.tar.gz
tar -xzf headlamp-sealed-secrets-0.2.4.tar.gz -C ~/Library/Application\ Support/Headlamp/plugins/
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:
# 2. Restart Headlamp
# macOS: Cmd+Q then reopen
# Linux: killall headlamp && headlamp
```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/headlamp-sealed-secrets
cd headlamp-sealed-secrets-plugin
npm install
npm run build
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
@@ -76,15 +78,11 @@ kubectl get secret <your-secret-name> -n <namespace>
- **[Quick Start Tutorial](docs/getting-started/quick-start.md)** - Create your first sealed secret
### User Guides
- **[Creating Secrets](docs/user-guide/creating-secrets.md)** - Encrypt and create sealed secrets
- **[Managing Keys](docs/user-guide/managing-keys.md)** - View and download sealing certificates
- **[Scopes Explained](docs/user-guide/scopes-explained.md)** - Strict vs namespace-wide vs cluster-wide
- **[RBAC Permissions](docs/user-guide/rbac-permissions.md)** - Configure access control
### Tutorials
- **[CI/CD Integration](docs/tutorials/ci-cd-integration.md)** - GitHub Actions, GitLab CI, Jenkins
- **[Multi-Cluster Setup](docs/tutorials/multi-cluster-setup.md)** - Manage secrets across clusters
- **[Secret Rotation](docs/tutorials/secret-rotation.md)** - Rotate secrets and sealing keys safely
### Reference
- **[Troubleshooting](docs/troubleshooting/)** - Common issues and solutions
@@ -181,7 +179,7 @@ Plaintext values never leave your browser.
| Browser XSS | Headlamp CSP policies | ⚠️ Standard web security |
| Supply chain | Package locks, dependabot | ⚠️ Ongoing monitoring |
See: [Security Hardening Guide](docs/deployment/security-hardening.md) | [ADR 003: Client-Side Encryption](docs/architecture/adr/003-client-side-crypto.md)
See: [ADR 003: Client-Side Encryption](docs/architecture/adr/003-client-side-crypto.md)
## Technical Details
@@ -189,11 +187,8 @@ See: [Security Hardening Guide](docs/deployment/security-hardening.md) | [ADR 00
| Metric | Value | Notes |
|--------|-------|-------|
| **Bundle Size** | 359.73 kB (98.79 kB gzipped) | Optimized with tree-shaking |
| **Test Coverage** | 92% (36/39 passing) | Unit + integration tests |
| **Test Coverage** | 92% | Unit + integration tests |
| **TypeScript** | 5.6.2 strict mode | Zero type errors |
| **Lines of Code** | 4,767 TypeScript/React | Well-documented with JSDoc |
| **Build Time** | ~4 seconds | Fast development iteration |
| **Dependencies** | node-forge (crypto) | Minimal, audited dependencies |
### Technology Stack
@@ -223,7 +218,7 @@ We welcome contributions.
```bash
# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/headlamp-sealed-secrets-plugin
cd headlamp-sealed-secrets-plugin/headlamp-sealed-secrets
cd headlamp-sealed-secrets-plugin
# 2. Install dependencies
npm install
@@ -265,7 +260,7 @@ See: [Development Workflow](docs/development/workflow.md) | [Testing Guide](docs
See [CHANGELOG.md](CHANGELOG.md) for version history.
**Latest release (v0.2.4)**: Type-safe error handling, RBAC integration, accessibility improvements, and 92% test coverage.
See [CHANGELOG.md](CHANGELOG.md) for details on each release.
## Issues & Support
@@ -292,13 +287,13 @@ See [CHANGELOG.md](CHANGELOG.md) for version history.
| Issue | Quick Fix | Guide |
|-------|-----------|-------|
| Plugin not loading | Check installation path | [Installation](docs/getting-started/installation.md) |
| Controller not found | Install controller | [Controller Issues](docs/troubleshooting/controller-issues.md) |
| Permission denied | Configure RBAC | [Permission Errors](docs/troubleshooting/permission-errors.md) |
| Encryption fails | Check certificate | [Encryption Failures](docs/troubleshooting/encryption-failures.md) |
| Controller not found | Install controller | [Troubleshooting](docs/troubleshooting/) |
| Permission denied | Configure RBAC | [RBAC Permissions](docs/user-guide/rbac-permissions.md) |
| Encryption fails | Check certificate | [Troubleshooting](docs/troubleshooting/) |
## License
Apache License 2.0 - see [LICENSE](headlamp-sealed-secrets/LICENSE) for details.
Apache License 2.0 - see [LICENSE](LICENSE) for details.
## Credits
+7 -7
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.20"
name: sealed-secrets
version: "0.2.22"
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.18
appVersion: "0.36.0"
containersImages:
- name: sealed-secrets-controller
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
@@ -19,8 +19,8 @@ keywords:
- encryption
- security
annotations:
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.20/sealed-secrets-0.2.20.tar.gz"
headlamp/plugin/archive-checksum: sha256:d113db870abfebeb8d6082d173f1ab0a6214a0988da170748b2b41d3bba0fdbb
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.22/sealed-secrets-0.2.22.tar.gz"
headlamp/plugin/archive-checksum: sha256:3c6dfdaa90fc5010d59cd40725ab26f4c4fee4c7b0ee4a6bc205c8d0198c5013
headlamp/plugin/version-compat: ">=0.13.0"
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
links:
@@ -51,7 +51,7 @@ install: |
#### Option 2: Build from Source
```bash
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
cd headlamp-sealed-secrets-plugin/headlamp-sealed-secrets
cd headlamp-sealed-secrets-plugin
npm install
npm run build
```
@@ -69,7 +69,7 @@ install: |
- Manage sealing keys
- Configure controller settings
For detailed usage instructions, see the [README](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/blob/main/headlamp-sealed-secrets/README.md).
For detailed usage instructions, see the [README](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/blob/main/README.md).
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
+4 -117
View File
@@ -2,158 +2,45 @@
Complete documentation for the Headlamp Sealed Secrets plugin.
## 📚 Documentation Index
## Documentation Index
### Getting Started
New to the plugin? Start here:
- **[Installation Guide](getting-started/installation.md)** - Install the plugin on Headlamp
- **[Quick Start](getting-started/quick-start.md)** - Create your first sealed secret in 5 minutes
### User Guide
Learn how to use all the features:
- **[Creating Secrets](user-guide/creating-secrets.md)** - Encrypt and create sealed secrets
- **[Managing Keys](user-guide/managing-keys.md)** - View and download sealing certificates
- **[Scopes Explained](user-guide/scopes-explained.md)** - Understand strict/namespace/cluster-wide scopes
- **[RBAC Permissions](user-guide/rbac-permissions.md)** - Required permissions and access control
- **[Settings](user-guide/settings.md)** - Configure plugin behavior
### Tutorials
Step-by-step guides for common workflows:
- **[CI/CD Integration](tutorials/ci-cd-integration.md)** - Automate secret creation with GitHub Actions, GitLab CI
- **[Multi-Cluster Setup](tutorials/multi-cluster-setup.md)** - Manage secrets across multiple clusters
- **[Secret Rotation](tutorials/secret-rotation.md)** - Rotate secrets and sealing keys safely
- **[Disaster Recovery](tutorials/disaster-recovery.md)** - Backup and restore procedures
- **[Migration from kubeseal](tutorials/migration-from-kubeseal.md)** - Migrate from CLI-based workflow
### Troubleshooting
Solutions for common issues:
- **[Common Errors](troubleshooting/common-errors.md)** - Error messages and fixes
- **[Controller Issues](troubleshooting/controller-issues.md)** - Connection and deployment problems
- **[Encryption Failures](troubleshooting/encryption-failures.md)** - Debugging encryption errors
- **[Permission Errors](troubleshooting/permission-errors.md)** - RBAC troubleshooting
- **[Performance](troubleshooting/performance.md)** - Optimization tips
### Development
Contributing to the plugin:
- **[Setup](development/setup.md)** - Development environment configuration
- **[Workflow](development/workflow.md)** - Development and testing workflow
- **[Testing](development/testing.md)** - Running and writing tests
- **[Code Style](development/code-style.md)** - Coding standards
- **[Debugging](development/debugging.md)** - Debugging tips and tools
- **[Release Process](development/release-process.md)** - How to release new versions
### API Reference
Technical documentation:
- **[Functions](api-reference/functions.md)** - Exported function reference
- **[Types](api-reference/types.md)** - TypeScript type definitions
- **[Hooks](api-reference/hooks.md)** - React hooks API
- **[Components](api-reference/components.md)** - Component props reference
- **[Examples](api-reference/examples.md)** - Code examples and patterns
### Architecture
Technical design and decisions:
- **[Overview](architecture/overview.md)** - System architecture
- **[Encryption Flow](architecture/encryption-flow.md)** - How encryption works
- **[Type System](architecture/type-system.md)** - Result types and branded types explained
- **[Error Handling](architecture/error-handling.md)** - Error handling patterns
- **[Accessibility](architecture/accessibility.md)** - WCAG 2.1 AA compliance details
- **[ADRs](architecture/adr/)** - Architecture Decision Records
### Deployment
### API Reference
Production deployment guides:
- **[Generated API Docs](api-reference/generated/)** - Auto-generated TypeScript reference
- **[Kubernetes](deployment/kubernetes.md)** - Deploy in K8s clusters
- **[Helm](deployment/helm.md)** - Using with Helm deployments
- **[Security Hardening](deployment/security-hardening.md)** - Security best practices
- **[Monitoring](deployment/monitoring.md)** - Observability setup
## 🔍 Quick Links
### Popular Pages
- [Quick Start Guide](getting-started/quick-start.md) - Get started in 5 minutes
- [CI/CD Integration](tutorials/ci-cd-integration.md) - Automate your workflow
- [Troubleshooting](troubleshooting/README.md) - Solve common issues
- [Development Workflow](development/workflow.md) - Contribute to the plugin
### External Resources
## External Resources
- **GitHub**: [privilegedescalation/headlamp-sealed-secrets-plugin](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin)
- **Issues**: [Report bugs](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
- **Discussions**: [Ask questions](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/discussions)
- **Headlamp**: [headlamp.dev](https://headlamp.dev)
- **Sealed Secrets**: [bitnami-labs/sealed-secrets](https://github.com/bitnami-labs/sealed-secrets)
## 📖 About This Documentation
This documentation is organized by user journey:
- **Getting Started** - For new users
- **User Guide** - For daily usage
- **Tutorials** - For specific workflows
- **Troubleshooting** - For problem-solving
- **Development** - For contributors
- **API Reference** - For developers using the plugin
- **Architecture** - For understanding the design
- **Deployment** - For production deployments
## 🤝 Contributing to Docs
Found an error or want to improve the documentation?
1. **Quick fixes**: Edit on GitHub and submit a PR
2. **Larger changes**: Open an issue first to discuss
3. **New tutorials**: Share your use case in Discussions
See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines.
## 📝 Documentation Status
### Completed ✅
- Installation guides
- Quick start tutorial
- Development workflow documentation
- Testing guides
- Architecture overview
### In Progress 🚧
- User guide sections (creating secrets, managing keys, scopes)
- Tutorial content (CI/CD, multi-cluster, rotation)
- Troubleshooting guides
- API reference (auto-generated coming soon)
### Planned 📅
- Video tutorials
- Interactive examples
- Detailed architecture diagrams
- More CI/CD platform examples
- Advanced use cases
## 🔄 Documentation Updates
This documentation is kept in sync with code changes:
- **Version**: Matches plugin version (currently v0.2.0)
- **Auto-generated**: API reference generated from TypeScript source
- **CI Checks**: Links validated on every pull request
- **Examples Tested**: Code examples validated against current API
Last updated: 2026-02-12
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sealed-secrets",
"version": "0.2.20",
"version": "0.2.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sealed-secrets",
"version": "0.2.20",
"version": "0.2.22",
"license": "Apache-2.0",
"dependencies": {
"node-forge": "^1.3.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sealed-secrets",
"version": "0.2.20",
"version": "0.2.22",
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
"files": [
"dist",
+16 -1
View File
@@ -1,4 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"]
"extends": ["config:recommended"],
"baseBranches": ["main"],
"schedule": ["every weekend"],
"prConcurrentLimit": 10,
"packageRules": [
{
"matchManagers": ["npm"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "npm minor and patch"
},
{
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"],
"groupName": "github-actions minor and patch"
}
]
}
+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' }
);
});
}
});
});
+49 -16
View File
@@ -69,9 +69,15 @@ export function SealedSecretDetail() {
// Check if user can decrypt secrets (requires get permission on Secrets)
React.useEffect(() => {
let cancelled = false;
if (namespace) {
canDecryptSecrets(namespace).then(setCanDecrypt);
canDecryptSecrets(namespace).then(result => {
if (!cancelled) setCanDecrypt(result);
});
}
return () => {
cancelled = true;
};
}, [namespace]);
// Wait for required params before rendering
@@ -104,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]);
@@ -115,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);
}
@@ -154,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">
<IconButton
onClick={handleClose}
edge="start"
size="small"
aria-label="Close detail panel"
>
<Icon icon="mdi:close" />
</IconButton>
<span>{sealedSecret.metadata.name}</span>
@@ -233,24 +253,33 @@ export function SealedSecretDetail() {
columns={[
{
label: 'Key',
getter: (row: any) => row.key,
getter: (row: { key: string; value: string }) => row.key,
},
{
label: 'Encrypted Value',
getter: (row: any) => {
getter: (row: { key: string; value: string }) => {
const val = row.value;
return val.length > 40 ? val.substring(0, 40) + '...' : val;
},
},
{
label: 'Actions',
getter: (row: any) =>
getter: (row: { key: string; value: string }) =>
canDecrypt ? (
<Button size="small" onClick={() => setDecryptKey(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">
<Button
size="small"
disabled
title="No permission to access Secrets"
aria-label={`Decrypt ${row.key} (no permission)`}
>
Decrypt
</Button>
),
@@ -331,8 +360,12 @@ export function SealedSecretDetail() {
/>
)}
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete SealedSecret?</DialogTitle>
<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
delete the resulting Kubernetes Secret.
+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: '#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
*/