Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 171b3895c0 | |||
| b335bf1d7b | |||
| 60ae9391ea | |||
| d508f38292 | |||
| 277b91f2ee | |||
| ef439583ac | |||
| 067b75ba21 | |||
| 0bf9c41c98 | |||
| 7aa92ac1fb | |||
| 01895297cd | |||
| 64fd6f31f5 | |||
| a679e4c16c | |||
| 3997399aef | |||
| 394c8396c7 | |||
| fff99c03ba | |||
| a79b7be961 | |||
| e755f69023 | |||
| 4c378015eb | |||
| 9d9bc5f22f | |||
| 3dc2f92a87 | |||
| 479d0c315e | |||
| 761f7cf242 | |||
| 0a3b77f140 |
@@ -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`
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [privilegedescalation]
|
||||||
@@ -5,37 +5,9 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: local-ubuntu-latest
|
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
|
||||||
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
|
|
||||||
|
|||||||
@@ -11,101 +11,9 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
|
||||||
uses: ./.github/workflows/ci.yaml
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: ci
|
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
|
||||||
runs-on: local-ubuntu-latest
|
with:
|
||||||
timeout-minutes: 10
|
version: ${{ inputs.version }}
|
||||||
|
upstream-repo: 'bitnami-labs/sealed-secrets'
|
||||||
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 }}
|
|
||||||
|
|||||||
+18
-1
@@ -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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.2.4] - 2026-02-12
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -110,7 +126,8 @@ Version 0.2.3 was published but with checksum mismatch on Artifact Hub. Supersed
|
|||||||
- Dependencies: node-forge for cryptography
|
- Dependencies: node-forge for cryptography
|
||||||
- Compatible with Headlamp v0.13.0+
|
- 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.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.4]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.4
|
||||||
[0.2.3]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.3
|
[0.2.3]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.3
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Uses custom hooks (`hooks/`) and a utility library (`lib/`) instead of a single
|
|||||||
|
|
||||||
- Functional React components only — no class components
|
- Functional React components only — no class components
|
||||||
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
|
- 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
|
- 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', ...)`
|
- 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
|
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
[](https://opensource.org/licenses/Apache-2.0)
|
[](https://opensource.org/licenses/Apache-2.0)
|
||||||
[](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases)
|
[](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases)
|
||||||
[](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
|
[](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
|
||||||
[](headlamp-sealed-secrets/)
|
[](docs/development/testing.md)
|
||||||
[](https://www.typescriptlang.org/)
|
[](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**.
|
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
|
#### Option 2: Manual Tarball Install
|
||||||
|
|
||||||
```bash
|
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:
|
||||||
# 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/
|
|
||||||
|
|
||||||
# 2. Restart Headlamp
|
```bash
|
||||||
# macOS: Cmd+Q then reopen
|
# macOS
|
||||||
# Linux: killall headlamp && headlamp
|
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
|
#### Option 3: Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin.git
|
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 install
|
||||||
npm run build
|
npm run build
|
||||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
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
|
- **[Quick Start Tutorial](docs/getting-started/quick-start.md)** - Create your first sealed secret
|
||||||
|
|
||||||
### User Guides
|
### 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
|
- **[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
|
- **[RBAC Permissions](docs/user-guide/rbac-permissions.md)** - Configure access control
|
||||||
|
|
||||||
### Tutorials
|
### Tutorials
|
||||||
- **[CI/CD Integration](docs/tutorials/ci-cd-integration.md)** - GitHub Actions, GitLab CI, Jenkins
|
- **[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
|
### Reference
|
||||||
- **[Troubleshooting](docs/troubleshooting/)** - Common issues and solutions
|
- **[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 |
|
| Browser XSS | Headlamp CSP policies | ⚠️ Standard web security |
|
||||||
| Supply chain | Package locks, dependabot | ⚠️ Ongoing monitoring |
|
| 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
|
## Technical Details
|
||||||
|
|
||||||
@@ -189,11 +187,8 @@ See: [Security Hardening Guide](docs/deployment/security-hardening.md) | [ADR 00
|
|||||||
|
|
||||||
| Metric | Value | Notes |
|
| Metric | Value | Notes |
|
||||||
|--------|-------|-------|
|
|--------|-------|-------|
|
||||||
| **Bundle Size** | 359.73 kB (98.79 kB gzipped) | Optimized with tree-shaking |
|
| **Test Coverage** | 92% | Unit + integration tests |
|
||||||
| **Test Coverage** | 92% (36/39 passing) | Unit + integration tests |
|
|
||||||
| **TypeScript** | 5.6.2 strict mode | Zero type errors |
|
| **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 |
|
| **Dependencies** | node-forge (crypto) | Minimal, audited dependencies |
|
||||||
|
|
||||||
### Technology Stack
|
### Technology Stack
|
||||||
@@ -223,7 +218,7 @@ We welcome contributions.
|
|||||||
```bash
|
```bash
|
||||||
# 1. Fork and clone
|
# 1. Fork and clone
|
||||||
git clone https://github.com/YOUR_USERNAME/headlamp-sealed-secrets-plugin
|
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
|
# 2. Install dependencies
|
||||||
npm install
|
npm install
|
||||||
@@ -265,7 +260,7 @@ See: [Development Workflow](docs/development/workflow.md) | [Testing Guide](docs
|
|||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
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
|
## Issues & Support
|
||||||
|
|
||||||
@@ -292,13 +287,13 @@ See [CHANGELOG.md](CHANGELOG.md) for version history.
|
|||||||
| Issue | Quick Fix | Guide |
|
| Issue | Quick Fix | Guide |
|
||||||
|-------|-----------|-------|
|
|-------|-----------|-------|
|
||||||
| Plugin not loading | Check installation path | [Installation](docs/getting-started/installation.md) |
|
| Plugin not loading | Check installation path | [Installation](docs/getting-started/installation.md) |
|
||||||
| Controller not found | Install controller | [Controller Issues](docs/troubleshooting/controller-issues.md) |
|
| Controller not found | Install controller | [Troubleshooting](docs/troubleshooting/) |
|
||||||
| Permission denied | Configure RBAC | [Permission Errors](docs/troubleshooting/permission-errors.md) |
|
| Permission denied | Configure RBAC | [RBAC Permissions](docs/user-guide/rbac-permissions.md) |
|
||||||
| Encryption fails | Check certificate | [Encryption Failures](docs/troubleshooting/encryption-failures.md) |
|
| Encryption fails | Check certificate | [Troubleshooting](docs/troubleshooting/) |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Apache License 2.0 - see [LICENSE](headlamp-sealed-secrets/LICENSE) for details.
|
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
||||||
|
|
||||||
## Credits
|
## Credits
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -1,13 +1,13 @@
|
|||||||
# Artifact Hub package metadata file
|
# Artifact Hub package metadata file
|
||||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
|
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
|
||||||
version: "0.2.20"
|
version: "0.2.22"
|
||||||
name: sealed-secrets
|
name: headlamp-sealed-secrets
|
||||||
displayName: Sealed Secrets
|
displayName: Sealed Secrets
|
||||||
createdAt: "2026-02-12T00:00:00Z"
|
createdAt: "2026-02-12T00:00:00Z"
|
||||||
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
|
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
||||||
appVersion: 0.2.18
|
appVersion: "0.36.0"
|
||||||
containersImages:
|
containersImages:
|
||||||
- name: sealed-secrets-controller
|
- name: sealed-secrets-controller
|
||||||
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
|
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
|
||||||
@@ -19,8 +19,8 @@ keywords:
|
|||||||
- encryption
|
- encryption
|
||||||
- security
|
- security
|
||||||
annotations:
|
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-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:d113db870abfebeb8d6082d173f1ab0a6214a0988da170748b2b41d3bba0fdbb
|
headlamp/plugin/archive-checksum: sha256:3c6dfdaa90fc5010d59cd40725ab26f4c4fee4c7b0ee4a6bc205c8d0198c5013
|
||||||
headlamp/plugin/version-compat: ">=0.13.0"
|
headlamp/plugin/version-compat: ">=0.13.0"
|
||||||
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
|
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
|
||||||
links:
|
links:
|
||||||
@@ -51,7 +51,7 @@ install: |
|
|||||||
#### Option 2: Build from Source
|
#### Option 2: Build from Source
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
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 install
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
@@ -69,7 +69,7 @@ install: |
|
|||||||
- Manage sealing keys
|
- Manage sealing keys
|
||||||
- Configure controller settings
|
- 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:
|
maintainers:
|
||||||
- name: privilegedescalation
|
- name: privilegedescalation
|
||||||
email: privilegedescalation@users.noreply.github.com
|
email: privilegedescalation@users.noreply.github.com
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Artifact Hub repository metadata file
|
# Artifact Hub repository metadata file
|
||||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
|
# 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:
|
owners:
|
||||||
- name: privilegedescalation
|
- name: privilegedescalation
|
||||||
email: privilegedescalation@users.noreply.github.com
|
email: privilegedescalation@users.noreply.github.com
|
||||||
|
|||||||
+4
-117
@@ -2,158 +2,45 @@
|
|||||||
|
|
||||||
Complete documentation for the Headlamp Sealed Secrets plugin.
|
Complete documentation for the Headlamp Sealed Secrets plugin.
|
||||||
|
|
||||||
## 📚 Documentation Index
|
## Documentation Index
|
||||||
|
|
||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
New to the plugin? Start here:
|
|
||||||
|
|
||||||
- **[Installation Guide](getting-started/installation.md)** - Install the plugin on Headlamp
|
- **[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
|
- **[Quick Start](getting-started/quick-start.md)** - Create your first sealed secret in 5 minutes
|
||||||
|
|
||||||
### User Guide
|
### 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
|
- **[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
|
- **[RBAC Permissions](user-guide/rbac-permissions.md)** - Required permissions and access control
|
||||||
- **[Settings](user-guide/settings.md)** - Configure plugin behavior
|
|
||||||
|
|
||||||
### Tutorials
|
### Tutorials
|
||||||
|
|
||||||
Step-by-step guides for common workflows:
|
|
||||||
|
|
||||||
- **[CI/CD Integration](tutorials/ci-cd-integration.md)** - Automate secret creation with GitHub Actions, GitLab CI
|
- **[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
|
### Troubleshooting
|
||||||
|
|
||||||
Solutions for common issues:
|
|
||||||
|
|
||||||
- **[Common Errors](troubleshooting/common-errors.md)** - Error messages and fixes
|
- **[Common Errors](troubleshooting/common-errors.md)** - Error messages and fixes
|
||||||
- **[Controller Issues](troubleshooting/controller-issues.md)** - Connection and deployment problems
|
- **[Controller Issues](troubleshooting/controller-issues.md)** - Connection and deployment problems
|
||||||
- **[Encryption Failures](troubleshooting/encryption-failures.md)** - Debugging encryption errors
|
- **[Encryption Failures](troubleshooting/encryption-failures.md)** - Debugging encryption errors
|
||||||
- **[Permission Errors](troubleshooting/permission-errors.md)** - RBAC troubleshooting
|
- **[Permission Errors](troubleshooting/permission-errors.md)** - RBAC troubleshooting
|
||||||
- **[Performance](troubleshooting/performance.md)** - Optimization tips
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
Contributing to the plugin:
|
|
||||||
|
|
||||||
- **[Setup](development/setup.md)** - Development environment configuration
|
|
||||||
- **[Workflow](development/workflow.md)** - Development and testing workflow
|
- **[Workflow](development/workflow.md)** - Development and testing workflow
|
||||||
- **[Testing](development/testing.md)** - Running and writing tests
|
- **[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
|
### 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
|
- **[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
|
## External Resources
|
||||||
- **[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
|
|
||||||
|
|
||||||
- **GitHub**: [privilegedescalation/headlamp-sealed-secrets-plugin](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin)
|
- **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)
|
- **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)
|
- **Headlamp**: [headlamp.dev](https://headlamp.dev)
|
||||||
- **Sealed Secrets**: [bitnami-labs/sealed-secrets](https://github.com/bitnami-labs/sealed-secrets)
|
- **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
|
||||||
@@ -26,6 +26,8 @@ Each ADR follows this structure:
|
|||||||
| [003](003-client-side-crypto.md) | Client-Side Encryption | Accepted | 2026-02-11 |
|
| [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 |
|
| [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 |
|
| [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
|
## Creating New ADRs
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sealed-secrets",
|
"name": "sealed-secrets",
|
||||||
"version": "0.2.20",
|
"version": "0.2.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sealed-secrets",
|
"name": "sealed-secrets",
|
||||||
"version": "0.2.20",
|
"version": "0.2.22",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-forge": "^1.3.1"
|
"node-forge": "^1.3.1"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sealed-secrets",
|
"name": "sealed-secrets",
|
||||||
"version": "0.2.20",
|
"version": "0.2.22",
|
||||||
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
|
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
+16
-1
@@ -1,4 +1,19 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,8 +115,11 @@ export function EncryptDialog({ open, onClose }: EncryptDialogProps) {
|
|||||||
setScope('strict');
|
setScope('strict');
|
||||||
setKeyValues([{ key: '', value: '', showValue: false }]);
|
setKeyValues([{ key: '', value: '', showValue: false }]);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
|
enqueueSnackbar(
|
||||||
|
`Failed to create SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ variant: 'error' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Error boundary for API operations
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Skeleton for controller health status
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -69,9 +69,15 @@ export function SealedSecretDetail() {
|
|||||||
|
|
||||||
// Check if user can decrypt secrets (requires get permission on Secrets)
|
// Check if user can decrypt secrets (requires get permission on Secrets)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
if (namespace) {
|
if (namespace) {
|
||||||
canDecryptSecrets(namespace).then(setCanDecrypt);
|
canDecryptSecrets(namespace).then(result => {
|
||||||
|
if (!cancelled) setCanDecrypt(result);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [namespace]);
|
}, [namespace]);
|
||||||
|
|
||||||
// Wait for required params before rendering
|
// Wait for required params before rendering
|
||||||
@@ -104,8 +110,11 @@ export function SealedSecretDetail() {
|
|||||||
await sealedSecret.delete();
|
await sealedSecret.delete();
|
||||||
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
|
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
|
enqueueSnackbar(
|
||||||
|
`Failed to delete SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ variant: 'error' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
setDeleteDialogOpen(false);
|
setDeleteDialogOpen(false);
|
||||||
}, [sealedSecret, enqueueSnackbar]);
|
}, [sealedSecret, enqueueSnackbar]);
|
||||||
@@ -115,11 +124,17 @@ export function SealedSecretDetail() {
|
|||||||
try {
|
try {
|
||||||
const config = getPluginConfig();
|
const config = getPluginConfig();
|
||||||
const yaml = JSON.stringify(sealedSecret.jsonData);
|
const yaml = JSON.stringify(sealedSecret.jsonData);
|
||||||
await rotateSealedSecret(config, yaml);
|
const result = await rotateSealedSecret(config, yaml);
|
||||||
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
|
if (result.ok === false) {
|
||||||
// The resource will auto-refresh via the watch
|
enqueueSnackbar(`Failed to re-encrypt: ${result.error}`, { variant: 'error' });
|
||||||
} catch (error: any) {
|
} else {
|
||||||
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
|
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 {
|
} finally {
|
||||||
setRotating(false);
|
setRotating(false);
|
||||||
}
|
}
|
||||||
@@ -154,7 +169,12 @@ export function SealedSecretDetail() {
|
|||||||
title={
|
title={
|
||||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<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" />
|
<Icon icon="mdi:close" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<span>{sealedSecret.metadata.name}</span>
|
<span>{sealedSecret.metadata.name}</span>
|
||||||
@@ -233,24 +253,33 @@ export function SealedSecretDetail() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
label: 'Key',
|
label: 'Key',
|
||||||
getter: (row: any) => row.key,
|
getter: (row: { key: string; value: string }) => row.key,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Encrypted Value',
|
label: 'Encrypted Value',
|
||||||
getter: (row: any) => {
|
getter: (row: { key: string; value: string }) => {
|
||||||
const val = row.value;
|
const val = row.value;
|
||||||
return val.length > 40 ? val.substring(0, 40) + '...' : val;
|
return val.length > 40 ? val.substring(0, 40) + '...' : val;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Actions',
|
label: 'Actions',
|
||||||
getter: (row: any) =>
|
getter: (row: { key: string; value: string }) =>
|
||||||
canDecrypt ? (
|
canDecrypt ? (
|
||||||
<Button size="small" onClick={() => setDecryptKey(row.key)}>
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setDecryptKey(row.key)}
|
||||||
|
aria-label={`Decrypt ${row.key}`}
|
||||||
|
>
|
||||||
Decrypt
|
Decrypt
|
||||||
</Button>
|
</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
|
Decrypt
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
@@ -331,8 +360,12 @@ export function SealedSecretDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
<Dialog
|
||||||
<DialogTitle>Delete SealedSecret?</DialogTitle>
|
open={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
aria-labelledby="delete-dialog-title"
|
||||||
|
>
|
||||||
|
<DialogTitle id="delete-dialog-title">Delete SealedSecret?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
||||||
delete the resulting Kubernetes Secret.
|
delete the resulting Kubernetes Secret.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,8 +94,11 @@ export function SealingKeysView() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
|
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
|
enqueueSnackbar(
|
||||||
|
`Failed to create download: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ variant: 'error' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,7 +192,12 @@ export function SealingKeysView() {
|
|||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<span>{expiryDate}</span>
|
<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)
|
({certInfo.daysUntilExpiry} days)
|
||||||
</span>
|
</span>
|
||||||
</Box>
|
</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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,8 +14,24 @@ import {
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
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 {
|
interface SecretDetailsSectionProps {
|
||||||
resource: any; // The Secret resource
|
resource: SecretResource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,7 +40,7 @@ interface SecretDetailsSectionProps {
|
|||||||
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
|
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
|
||||||
// Check if this Secret is owned by a SealedSecret
|
// Check if this Secret is owned by a SealedSecret
|
||||||
const ownerRef = resource.metadata?.ownerReferences?.find(
|
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) {
|
if (!ownerRef) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,16 +37,14 @@ export function useControllerHealth(autoRefresh = false, refreshIntervalMs = 300
|
|||||||
const config = getPluginConfig();
|
const config = getPluginConfig();
|
||||||
const result = await checkControllerHealth(config);
|
const result = await checkControllerHealth(config);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok === false) {
|
||||||
setHealth(result.value);
|
|
||||||
} else if (result.ok === false) {
|
|
||||||
// Even on error, checkControllerHealth returns a status
|
|
||||||
// This shouldn't happen, but handle gracefully
|
|
||||||
setHealth({
|
setHealth({
|
||||||
healthy: false,
|
healthy: false,
|
||||||
reachable: false,
|
reachable: false,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setHealth(result.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,53 +85,3 @@ export function usePermission(
|
|||||||
|
|
||||||
return { loading, allowed };
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,12 +31,31 @@ export interface EncryptionRequest {
|
|||||||
keyValues: Array<{ key: string; value: string }>;
|
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
|
* Result of successful encryption
|
||||||
*/
|
*/
|
||||||
export interface EncryptionResult {
|
export interface EncryptionResult {
|
||||||
/** The complete SealedSecret object ready to apply */
|
/** The complete SealedSecret object ready to apply */
|
||||||
sealedSecretData: any;
|
sealedSecretData: SealedSecretManifest;
|
||||||
/** Information about the certificate used */
|
/** Information about the certificate used */
|
||||||
certificateInfo?: CertificateInfo;
|
certificateInfo?: CertificateInfo;
|
||||||
}
|
}
|
||||||
@@ -158,7 +177,7 @@ export function useSealedSecretEncryption() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Construct the SealedSecret object
|
// Step 6: Construct the SealedSecret object
|
||||||
const sealedSecretData: any = {
|
const sealedSecretData: SealedSecretManifest = {
|
||||||
apiVersion: 'bitnami.com/v1alpha1',
|
apiVersion: 'bitnami.com/v1alpha1',
|
||||||
kind: 'SealedSecret',
|
kind: 'SealedSecret',
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -186,8 +205,8 @@ export function useSealedSecretEncryption() {
|
|||||||
sealedSecretData,
|
sealedSecretData,
|
||||||
certificateInfo: certInfo,
|
certificateInfo: certInfo,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const errorMsg = error.message || 'Unknown encryption error';
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
enqueueSnackbar(errorMsg, { variant: 'error' });
|
enqueueSnackbar(errorMsg, { variant: 'error' });
|
||||||
return Err(errorMsg);
|
return Err(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
SealedSecretStatus,
|
SealedSecretStatus,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
|
interface CRDVersion {
|
||||||
|
name: string;
|
||||||
|
storage?: boolean;
|
||||||
|
served?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SealedSecret CRD class
|
* SealedSecret CRD class
|
||||||
* Represents a Bitnami Sealed Secret resource in the cluster
|
* 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)
|
// 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) {
|
if (storageVersion) {
|
||||||
const version = `${crd.spec.group}/${storageVersion.name}`;
|
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
|
// 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) {
|
if (servedVersion) {
|
||||||
const version = `${crd.spec.group}/${servedVersion.name}`;
|
const version = `${crd.spec.group}/${servedVersion.name}`;
|
||||||
this.detectedVersion = version;
|
this.detectedVersion = version;
|
||||||
|
|||||||
@@ -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
@@ -27,7 +27,7 @@ export interface ControllerHealthStatus {
|
|||||||
/**
|
/**
|
||||||
* Build the controller proxy URL
|
* 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;
|
const { controllerNamespace, controllerName, controllerPort } = config;
|
||||||
return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`;
|
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
|
* Rotate (re-encrypt) a SealedSecret with the current active key
|
||||||
*
|
*
|
||||||
@@ -218,14 +186,14 @@ export async function checkControllerHealth(
|
|||||||
version,
|
version,
|
||||||
latencyMs,
|
latencyMs,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
const latencyMs = Date.now() - startTime;
|
const latencyMs = Date.now() - startTime;
|
||||||
|
|
||||||
// Determine error type
|
// Determine error type
|
||||||
let errorMessage = 'Controller unreachable';
|
let errorMessage = 'Controller unreachable';
|
||||||
if (error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
errorMessage = 'Request timed out after 5 seconds';
|
errorMessage = 'Request timed out after 5 seconds';
|
||||||
} else if (error.message) {
|
} else if (error instanceof Error) {
|
||||||
errorMessage = error.message;
|
errorMessage = error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -52,7 +52,7 @@ export function parsePublicKeyFromCert(
|
|||||||
* @param scope The encryption scope
|
* @param scope The encryption scope
|
||||||
* @returns Result containing base64-encoded encrypted value or error message
|
* @returns Result containing base64-encoded encrypted value or error message
|
||||||
*/
|
*/
|
||||||
export function encryptValue(
|
function encryptValue(
|
||||||
publicKey: forge.pki.rsa.PublicKey,
|
publicKey: forge.pki.rsa.PublicKey,
|
||||||
value: PlaintextValue,
|
value: PlaintextValue,
|
||||||
namespace: string,
|
namespace: string,
|
||||||
@@ -98,7 +98,7 @@ export function encryptValue(
|
|||||||
const tag = (cipher.mode as any).tag.getBytes();
|
const tag = (cipher.mode as any).tag.getBytes();
|
||||||
|
|
||||||
// Construct the sealed secret format:
|
// 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 sessionKeyLength = encryptedSessionKey.length;
|
||||||
const lengthBytes =
|
const lengthBytes =
|
||||||
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
|
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
|
||||||
@@ -145,17 +145,6 @@ export function encryptKeyValues(
|
|||||||
return Ok(encryptedData);
|
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
|
* Parse certificate and extract metadata
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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
@@ -51,8 +51,12 @@ export async function checkSealedSecretPermissions(
|
|||||||
canDelete,
|
canDelete,
|
||||||
canList,
|
canList,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
return Err(`Failed to check SealedSecret permissions: ${error.message}`);
|
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
|
* Check a specific permission using SelfSubjectAccessReview
|
||||||
*
|
*
|
||||||
@@ -130,36 +120,3 @@ async function checkPermission(
|
|||||||
// Return false on error (assume no permission)
|
// Return false on error (assume no permission)
|
||||||
return result.ok ? result.value : false;
|
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -133,54 +133,3 @@ export async function retryWithBackoff<T, E>(
|
|||||||
// Should never reach here, but TypeScript needs it
|
// Should never reach here, but TypeScript needs it
|
||||||
return Err(`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`);
|
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const localStorageMock = {
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
isValidNamespace,
|
|
||||||
validatePEMCertificate,
|
validatePEMCertificate,
|
||||||
validateSecretKey,
|
validateSecretKey,
|
||||||
validateSecretName,
|
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', () => {
|
describe('validateSecretKey', () => {
|
||||||
it('should accept valid secret keys', () => {
|
it('should accept valid secret keys', () => {
|
||||||
expect(validateSecretKey('password').valid).toBe(true);
|
expect(validateSecretKey('password').valid).toBe(true);
|
||||||
|
|||||||
+3
-96
@@ -5,51 +5,6 @@
|
|||||||
* and runtime type checking for SealedSecret objects.
|
* 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
|
* Validate Kubernetes resource name
|
||||||
*
|
*
|
||||||
@@ -61,7 +16,7 @@ export function isSealedSecretScope(value: any): value is SealedSecretScope {
|
|||||||
* @param name Name to validate
|
* @param name Name to validate
|
||||||
* @returns true if valid Kubernetes resource name
|
* @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) {
|
if (!name || name.length === 0 || name.length > 253) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -76,7 +31,7 @@ export function isValidK8sName(name: string): boolean {
|
|||||||
* @param key Key to validate
|
* @param key Key to validate
|
||||||
* @returns true if valid Kubernetes key
|
* @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) {
|
if (!key || key.length === 0 || key.length > 253) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -93,7 +48,7 @@ export function isValidK8sKey(key: string): boolean {
|
|||||||
* @param value String to validate
|
* @param value String to validate
|
||||||
* @returns true if valid PEM format
|
* @returns true if valid PEM format
|
||||||
*/
|
*/
|
||||||
export function isValidPEM(value: string): boolean {
|
function isValidPEM(value: string): boolean {
|
||||||
if (!value || typeof value !== 'string') {
|
if (!value || typeof value !== 'string') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -103,28 +58,6 @@ export function isValidPEM(value: string): boolean {
|
|||||||
return pemRegex.test(value.trim());
|
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
|
* Validation result with error message
|
||||||
*/
|
*/
|
||||||
@@ -223,29 +156,3 @@ export function validatePEMCertificate(pem: string): ValidationResult {
|
|||||||
|
|
||||||
return { valid: true };
|
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
@@ -75,17 +75,6 @@ export function PlaintextValue(value: string): PlaintextValue {
|
|||||||
return value as 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
|
* Create a branded base64 string
|
||||||
*
|
*
|
||||||
@@ -106,17 +95,6 @@ export function PEMCertificate(value: string): PEMCertificate {
|
|||||||
return value as 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
|
* Helper to create a success result
|
||||||
*
|
*
|
||||||
@@ -196,7 +174,7 @@ export interface SealedSecretSpec {
|
|||||||
/**
|
/**
|
||||||
* SealedSecret status condition
|
* SealedSecret status condition
|
||||||
*/
|
*/
|
||||||
export interface SealedSecretCondition {
|
interface SealedSecretCondition {
|
||||||
type: string;
|
type: string;
|
||||||
status: 'True' | 'False' | 'Unknown';
|
status: 'True' | 'False' | 'Unknown';
|
||||||
lastTransitionTime?: string;
|
lastTransitionTime?: string;
|
||||||
@@ -233,15 +211,6 @@ export interface PluginConfig {
|
|||||||
controllerPort: number;
|
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
|
* Key-value pair for encryption dialog
|
||||||
*/
|
*/
|
||||||
@@ -250,16 +219,6 @@ export interface SecretKeyValue {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encryption request parameters
|
|
||||||
*/
|
|
||||||
export interface EncryptionRequest {
|
|
||||||
name: string;
|
|
||||||
namespace: string;
|
|
||||||
scope: SealedSecretScope;
|
|
||||||
keyValues: SecretKeyValue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Certificate information extracted from PEM certificate
|
* Certificate information extracted from PEM certificate
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user