Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d94cf1b7de | |||
| cc9b0c4042 | |||
| 84c947ed69 | |||
| e212e601a9 | |||
| e6920dcba4 | |||
| 67602fb279 | |||
| ecdee4a95a | |||
| 0c2132b013 | |||
| 780f58f9d9 | |||
| d1ea2fa36e | |||
| 9b385b95a3 | |||
| 395ff7de0b | |||
| 6aa2fb9c5a | |||
| ba6ddc1366 | |||
| 6f1163c1b8 | |||
| 949ce18b12 | |||
| af87036ef0 | |||
| e05423f853 | |||
| 2c17512372 | |||
| 2798bca085 | |||
| 01ebdcfbb1 | |||
| d20e18f13b | |||
| f09376020c | |||
| a2ac69c764 | |||
| 4f474e02bc | |||
| 84f0384a2a | |||
| c223d924bc | |||
| 2d7b73466a | |||
| b15967a9f4 | |||
| a7adee4e54 | |||
| 5c420e58a4 | |||
| 71649454c9 | |||
| 2234e2878f | |||
| b3f31e9b76 | |||
| 68cdb804e8 | |||
| b0ad6573d9 | |||
| a6a3cb27fb | |||
| 724541c329 | |||
| f5c78ddb9c | |||
| 33a834cd1f | |||
| 0f46892d75 | |||
| 5d296d9c72 | |||
| 1b4fe0a8b2 | |||
| 0fed41a466 | |||
| bfd90f9acd | |||
| 960e768a99 | |||
| 9558542d9d | |||
| 3cbb09d596 | |||
| 6ba022d943 | |||
| 376fe870ba | |||
| 6f49f1e7bb | |||
| badf3ed3b9 | |||
| 37aa9511da | |||
| b82d0f6323 | |||
| 1c58cf7226 | |||
| 953e8c30af | |||
| b73be9a587 | |||
| 2fb8c8223a | |||
| c897dfbb31 | |||
| 2d54372fda | |||
| e351e72f9c | |||
| 7b5a9c5ceb | |||
| 60d1d195af | |||
| a1fb0a2eed | |||
| 388920473d | |||
| 39b0d5dbbe | |||
| 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]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check
|
||||
run: npm run tsc
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-ci.yaml@main
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Dual Approval (CTO + QA)
|
||||
|
||||
# Calls the shared dual-approval-check workflow.
|
||||
# Passes when both privilegedescalation-cto and privilegedescalation-qa
|
||||
# have approved the PR. Add "Dual Approval (CTO + QA)" to required_status_checks
|
||||
# in branch protection to enforce this gate.
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
dual-approval:
|
||||
uses: privilegedescalation/.github/.github/workflows/dual-approval-check.yaml@main
|
||||
secrets: inherit
|
||||
with:
|
||||
pr_number: ${{ github.event.pull_request.number }}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-e2e.yaml@main
|
||||
with:
|
||||
node-version: "22"
|
||||
headlamp-version: v0.40.1
|
||||
e2e-namespace: headlamp-dev
|
||||
@@ -7,105 +7,19 @@ on:
|
||||
description: 'Release version (e.g. 1.0.0)'
|
||||
required: true
|
||||
type: string
|
||||
repository_dispatch:
|
||||
types: [release]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
uses: ./.github/workflows/ci.yaml
|
||||
|
||||
release:
|
||||
needs: ci
|
||||
runs-on: local-ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
uses: privilegedescalation/.github/.github/workflows/plugin-release.yaml@main
|
||||
secrets:
|
||||
RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }}
|
||||
RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
with:
|
||||
version: ${{ inputs.version || github.event.client_payload.version }}
|
||||
|
||||
steps:
|
||||
- name: Validate version format
|
||||
run: |
|
||||
if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: Version must be in X.Y.Z format"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Update version in package.json
|
||||
run: npm version ${{ inputs.version }} --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Update artifacthub-pkg.yml
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}/${PKG_NAME}-${VERSION}.tar.gz"
|
||||
sed -i "s/^version:.*/version: \"${VERSION}\"/" artifacthub-pkg.yml
|
||||
sed -i "s|headlamp/plugin/archive-url:.*|headlamp/plugin/archive-url: \"${RELEASE_URL}\"|" artifacthub-pkg.yml
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npx @kinvolk/headlamp-plugin build
|
||||
|
||||
- name: Package plugin
|
||||
run: npx @kinvolk/headlamp-plugin package
|
||||
|
||||
- name: Prepare release tarball
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
PKG_NAME=$(jq -r .name package.json)
|
||||
TARBALL="${PKG_NAME}-${VERSION}.tar.gz"
|
||||
if [ ! -f "$TARBALL" ]; then
|
||||
echo "Error: Expected tarball $TARBALL not found"
|
||||
ls -la *.tar.gz 2>/dev/null || echo "No .tar.gz files found"
|
||||
exit 1
|
||||
fi
|
||||
echo "TARBALL=$TARBALL" >> $GITHUB_ENV
|
||||
echo "PKG_NAME=$PKG_NAME" >> $GITHUB_ENV
|
||||
|
||||
- name: Validate tarball
|
||||
run: |
|
||||
echo "Tarball: ${{ env.TARBALL }}"
|
||||
ls -lh "${{ env.TARBALL }}"
|
||||
tar -tzf "${{ env.TARBALL }}" | head -20
|
||||
tar -tzf "${{ env.TARBALL }}" | grep -q "main.js" || { echo "Error: main.js not found in tarball"; exit 1; }
|
||||
|
||||
- name: Compute checksum
|
||||
run: |
|
||||
CHECKSUM=$(sha256sum "${{ env.TARBALL }}" | awk '{print $1}')
|
||||
echo "CHECKSUM=$CHECKSUM" >> $GITHUB_ENV
|
||||
sed -i "s|headlamp/plugin/archive-checksum:.*|headlamp/plugin/archive-checksum: sha256:${CHECKSUM}|" artifacthub-pkg.yml
|
||||
|
||||
- name: Commit and tag
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
git add package.json package-lock.json artifacthub-pkg.yml
|
||||
git commit -m "release: v${VERSION}"
|
||||
git tag "v${VERSION}"
|
||||
git push origin main --tags
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.version }}
|
||||
files: ${{ env.TARBALL }}
|
||||
fail_on_unmatched_files: true
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -23,3 +23,9 @@ Thumbs.db
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# E2E
|
||||
.env.e2e
|
||||
e2e/.auth/state.json
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"config": {
|
||||
// Line length — not enforced for docs with code examples
|
||||
"MD013": false,
|
||||
// First line heading — files use YAML frontmatter, not headings
|
||||
"MD041": false,
|
||||
// Emphasis as heading — common pattern for Option 1/2/3 sections
|
||||
"MD036": false,
|
||||
// No duplicate heading — changelog files repeat section names intentionally
|
||||
"MD024": false,
|
||||
// Fenced code language — not always applicable for diagram blocks
|
||||
"MD040": false,
|
||||
// Table column style — table alignment is visual, not semantic
|
||||
"MD060": false,
|
||||
// Ordered list item prefix — number resets are intentional in documents
|
||||
"MD029": false,
|
||||
// No inline HTML — each elements are valid in valid Markdown
|
||||
"MD033": false,
|
||||
// List marker space — spacing after list markers varies by editor
|
||||
"MD030": false,
|
||||
// Blanks around headings — not always needed in compact docs
|
||||
"MD022": false,
|
||||
// Blanks around lists — not always needed in compact docs
|
||||
"MD032": false,
|
||||
// Blanks around fences — not always needed between adjacent blocks
|
||||
"MD031": false,
|
||||
// Multiple blanks — editor artifacts, not semantic
|
||||
"MD012": false,
|
||||
// Single title — files may have multiple H1 sections
|
||||
"MD025": false,
|
||||
// Trailing spaces — editor artifacts
|
||||
"MD009": false,
|
||||
// Bare URLs — URL shortening not always needed
|
||||
"MD034": false,
|
||||
// Single trailing newline — editor artifacts
|
||||
"MD047": false,
|
||||
// Trailing punctuation — heading punctuation is intentional
|
||||
"MD026": false,
|
||||
// Space in emphasis — double-asterisk bold spacing varies by renderer
|
||||
"MD037": false,
|
||||
// No hard tabs — some generated docs use tabs for indentation
|
||||
"MD010": false,
|
||||
// Code block style — generated docs may use inconsistent styles
|
||||
"MD046": false,
|
||||
// Comment style — generated docs have no comments
|
||||
"MD048": false,
|
||||
// Commands show output — shell examples intentionally show only commands
|
||||
"MD014": false
|
||||
},
|
||||
"ignores": [
|
||||
"docs/api-reference/generated/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
docs/api-reference/generated/**
|
||||
+65
-2
@@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
- Explicit `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `react`, and `react-dom` devDependencies so tests run reliably without relying on transitive hoisting
|
||||
|
||||
### Changed
|
||||
- Bump to v1.0.0 — stable public API, comprehensive test coverage, ArtifactHub-only installation
|
||||
|
||||
### Fixed
|
||||
- Removed `install-plugin.sh` custom install script in compliance with ArtifactHub-only installation policy
|
||||
|
||||
## [0.2.24] - 2026-03-19
|
||||
|
||||
### Fixed
|
||||
- Added npm overrides for `tar` (>=7.5.11) and `undici` (>=7.24.3) to resolve security advisories
|
||||
- Added `pull-requests: write` permission to release workflow to unblock PR creation
|
||||
|
||||
### Changed
|
||||
- Added ArtifactHub-only installation policy (INSTALLATION_POLICY.md)
|
||||
- Removed manual install instructions from README
|
||||
- Dependency bumps: `tar` 7.5.7→7.5.11, `undici` 7.14.0→7.24.4, `rollup` 4.46.3→4.59.0, `minimatch` 3.1.2→3.1.5, `qs` 6.14.1→6.15.0, `storybook` 9.1.17→9.1.20
|
||||
|
||||
## [0.2.23] - 2026-03-09
|
||||
|
||||
### Changed
|
||||
- Internal release-pipeline stabilization (re-release of v0.2.22 fixes)
|
||||
|
||||
## [0.2.22] - 2026-03-09
|
||||
|
||||
### Added
|
||||
- Architecture decision records for error boundaries and hooks architecture
|
||||
|
||||
### Fixed
|
||||
- Removed remaining `any` types, dead code, and unused exports; added comprehensive tests
|
||||
- Added missing `archive-checksum` annotation to `artifacthub-pkg.yml`
|
||||
- Upstream `appVersion` tracking in release workflow (automatically syncs sealed-secrets controller version)
|
||||
- Package renamed to `headlamp-sealed-secrets` on ArtifactHub for discoverability
|
||||
- Added `FUNDING.yml` and Apache-2.0 `LICENSE` file
|
||||
|
||||
### Changed
|
||||
- Enhanced Renovate configuration
|
||||
|
||||
## [0.2.21] - 2026-03-04
|
||||
|
||||
### Added
|
||||
- Claude Code agent definitions for Headlamp plugin development assistance
|
||||
|
||||
### Fixed
|
||||
- Hardcoded color in SealingKeysView now uses CSS variable for dark mode support
|
||||
- Missing async cancellation in SealedSecretDetail useEffect
|
||||
- Accessibility gaps: added aria-labels to detail panel buttons and dialogs
|
||||
- Replaced `any` types with proper typed row interfaces in SimpleTable getters
|
||||
- Corrected broken links, stale versions, and dead references across documentation
|
||||
- Fixed LICENSE and README links in README.md
|
||||
- Fixed appVersion mismatch in artifacthub-pkg.yml
|
||||
- Removed dead documentation links from docs/README.md
|
||||
|
||||
## [0.2.4] - 2026-02-12
|
||||
|
||||
### Fixed
|
||||
@@ -110,10 +168,15 @@ Version 0.2.3 was published but with checksum mismatch on Artifact Hub. Supersed
|
||||
- Dependencies: node-forge for cryptography
|
||||
- Compatible with Headlamp v0.13.0+
|
||||
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.4...HEAD
|
||||
[0.1.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.1.0
|
||||
[Unreleased]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.24...v1.0.0
|
||||
[0.2.24]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.23...v0.2.24
|
||||
[0.2.23]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.22...v0.2.23
|
||||
[0.2.22]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.21...v0.2.22
|
||||
[0.2.21]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/compare/v0.2.20...v0.2.21
|
||||
[0.2.4]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.4
|
||||
[0.2.3]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.3
|
||||
[0.2.2]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.2
|
||||
[0.2.1]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.1
|
||||
[0.2.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.2.0
|
||||
[0.1.0]: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/tag/v0.1.0
|
||||
|
||||
@@ -68,7 +68,7 @@ Uses custom hooks (`hooks/`) and a utility library (`lib/`) instead of a single
|
||||
|
||||
- Functional React components only — no class components
|
||||
- All imports from `@kinvolk/headlamp-plugin/lib` and `@kinvolk/headlamp-plugin/lib/CommonComponents`
|
||||
- No additional UI libraries (no MUI direct imports, no Ant Design, etc.)
|
||||
- MUI (`@mui/material`) is available via Headlamp's bundled dependencies — no other UI libraries (no Ant Design, etc.)
|
||||
- TypeScript strict mode — no `any`, use `unknown` + type guards at API boundaries
|
||||
- Tests: vitest + @testing-library/react, mock with `vi.mock('@kinvolk/headlamp-plugin/lib', ...)`
|
||||
- `vitest.setup.ts` provides a spec-compliant `localStorage` shim for Node 22+ compatibility
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Installation Policy
|
||||
|
||||
## Approved Installation Method
|
||||
|
||||
**The ONLY approved method for installing this plugin is via [Artifact Hub](https://artifacthub.io/) using the Headlamp plugin installer.**
|
||||
|
||||
No other installation method is acceptable. This includes but is not limited to:
|
||||
|
||||
- Direct installation from GitHub release assets
|
||||
- Manual npm pack / tarball extraction
|
||||
- initContainer workarounds that bypass Artifact Hub
|
||||
- Direct file copy or sidecar injection
|
||||
|
||||
## Enforcement
|
||||
|
||||
All deployment configurations, CI/CD pipelines, and documentation MUST reference Artifact Hub as the sole plugin distribution channel. Any pull request that introduces an alternative installation method will be rejected.
|
||||
|
||||
## Rationale
|
||||
|
||||
Artifact Hub provides verified checksums, consistent versioning, and a standard discovery mechanism for the CNCF ecosystem. Bypassing it introduces security and integrity risks.
|
||||
|
||||
---
|
||||
|
||||
*This policy is set by the CTO and approved by the CEO of Privileged Escalation.*
|
||||
@@ -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://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases)
|
||||
[](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
|
||||
[](headlamp-sealed-secrets/)
|
||||
[](docs/development/testing.md)
|
||||
[](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**.
|
||||
@@ -25,32 +25,8 @@ A comprehensive [Headlamp](https://headlamp.dev) plugin for managing [Bitnami Se
|
||||
|
||||
### Installation
|
||||
|
||||
#### Option 1: Headlamp Plugin Manager (Recommended)
|
||||
|
||||
Browse the Headlamp Plugin Manager (Settings → Plugins → Catalog) and install **sealed-secrets** directly.
|
||||
|
||||
#### Option 2: Manual Tarball Install
|
||||
|
||||
```bash
|
||||
# 1. Download and extract plugin
|
||||
curl -LO https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.4/headlamp-sealed-secrets-0.2.4.tar.gz
|
||||
tar -xzf headlamp-sealed-secrets-0.2.4.tar.gz -C ~/Library/Application\ Support/Headlamp/plugins/
|
||||
|
||||
# 2. Restart Headlamp
|
||||
# macOS: Cmd+Q then reopen
|
||||
# Linux: killall headlamp && headlamp
|
||||
```
|
||||
|
||||
#### Option 3: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin.git
|
||||
cd headlamp-sealed-secrets-plugin/headlamp-sealed-secrets
|
||||
npm install
|
||||
npm run build
|
||||
npx @kinvolk/headlamp-plugin extract . /headlamp/plugins
|
||||
```
|
||||
|
||||
### First Secret
|
||||
|
||||
```bash
|
||||
@@ -76,15 +52,11 @@ kubectl get secret <your-secret-name> -n <namespace>
|
||||
- **[Quick Start Tutorial](docs/getting-started/quick-start.md)** - Create your first sealed secret
|
||||
|
||||
### User Guides
|
||||
- **[Creating Secrets](docs/user-guide/creating-secrets.md)** - Encrypt and create sealed secrets
|
||||
- **[Managing Keys](docs/user-guide/managing-keys.md)** - View and download sealing certificates
|
||||
- **[Scopes Explained](docs/user-guide/scopes-explained.md)** - Strict vs namespace-wide vs cluster-wide
|
||||
- **[RBAC Permissions](docs/user-guide/rbac-permissions.md)** - Configure access control
|
||||
|
||||
### Tutorials
|
||||
- **[CI/CD Integration](docs/tutorials/ci-cd-integration.md)** - GitHub Actions, GitLab CI, Jenkins
|
||||
- **[Multi-Cluster Setup](docs/tutorials/multi-cluster-setup.md)** - Manage secrets across clusters
|
||||
- **[Secret Rotation](docs/tutorials/secret-rotation.md)** - Rotate secrets and sealing keys safely
|
||||
|
||||
### Reference
|
||||
- **[Troubleshooting](docs/troubleshooting/)** - Common issues and solutions
|
||||
@@ -179,9 +151,9 @@ Plaintext values never leave your browser.
|
||||
| Network sniffing | No plaintext on network | ✅ Protected |
|
||||
| Compromised proxy | Only sees encrypted data | ✅ Protected |
|
||||
| Browser XSS | Headlamp CSP policies | ⚠️ Standard web security |
|
||||
| Supply chain | Package locks, dependabot | ⚠️ Ongoing monitoring |
|
||||
| Supply chain | Package locks, Renovate | ⚠️ Ongoing monitoring |
|
||||
|
||||
See: [Security Hardening Guide](docs/deployment/security-hardening.md) | [ADR 003: Client-Side Encryption](docs/architecture/adr/003-client-side-crypto.md)
|
||||
See: [ADR 003: Client-Side Encryption](docs/architecture/adr/003-client-side-crypto.md)
|
||||
|
||||
## Technical Details
|
||||
|
||||
@@ -189,11 +161,8 @@ See: [Security Hardening Guide](docs/deployment/security-hardening.md) | [ADR 00
|
||||
|
||||
| Metric | Value | Notes |
|
||||
|--------|-------|-------|
|
||||
| **Bundle Size** | 359.73 kB (98.79 kB gzipped) | Optimized with tree-shaking |
|
||||
| **Test Coverage** | 92% (36/39 passing) | Unit + integration tests |
|
||||
| **Test Coverage** | 92% | Unit + integration tests |
|
||||
| **TypeScript** | 5.6.2 strict mode | Zero type errors |
|
||||
| **Lines of Code** | 4,767 TypeScript/React | Well-documented with JSDoc |
|
||||
| **Build Time** | ~4 seconds | Fast development iteration |
|
||||
| **Dependencies** | node-forge (crypto) | Minimal, audited dependencies |
|
||||
|
||||
### Technology Stack
|
||||
@@ -223,7 +192,7 @@ We welcome contributions.
|
||||
```bash
|
||||
# 1. Fork and clone
|
||||
git clone https://github.com/YOUR_USERNAME/headlamp-sealed-secrets-plugin
|
||||
cd headlamp-sealed-secrets-plugin/headlamp-sealed-secrets
|
||||
cd headlamp-sealed-secrets-plugin
|
||||
|
||||
# 2. Install dependencies
|
||||
npm install
|
||||
@@ -265,7 +234,7 @@ See: [Development Workflow](docs/development/workflow.md) | [Testing Guide](docs
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
|
||||
**Latest release (v0.2.4)**: Type-safe error handling, RBAC integration, accessibility improvements, and 92% test coverage.
|
||||
See [CHANGELOG.md](CHANGELOG.md) for details on each release.
|
||||
|
||||
## Issues & Support
|
||||
|
||||
@@ -292,13 +261,13 @@ See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
| Issue | Quick Fix | Guide |
|
||||
|-------|-----------|-------|
|
||||
| Plugin not loading | Check installation path | [Installation](docs/getting-started/installation.md) |
|
||||
| Controller not found | Install controller | [Controller Issues](docs/troubleshooting/controller-issues.md) |
|
||||
| Permission denied | Configure RBAC | [Permission Errors](docs/troubleshooting/permission-errors.md) |
|
||||
| Encryption fails | Check certificate | [Encryption Failures](docs/troubleshooting/encryption-failures.md) |
|
||||
| Controller not found | Install controller | [Troubleshooting](docs/troubleshooting/) |
|
||||
| Permission denied | Configure RBAC | [RBAC Permissions](docs/user-guide/rbac-permissions.md) |
|
||||
| Encryption fails | Check certificate | [Troubleshooting](docs/troubleshooting/) |
|
||||
|
||||
## License
|
||||
|
||||
Apache License 2.0 - see [LICENSE](headlamp-sealed-secrets/LICENSE) for details.
|
||||
Apache License 2.0 - see [LICENSE](LICENSE) for details.
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -326,4 +295,3 @@ Built with:
|
||||
|
||||
|
||||
# Test runner
|
||||
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ Key dependencies with security implications:
|
||||
- **node-forge**: Used for client-side encryption of secret values with the cluster's sealing certificate. Keep this dependency up to date.
|
||||
- **@kinvolk/headlamp-plugin**: Peer dependency providing the Kubernetes API proxy. Update by upgrading your Headlamp installation.
|
||||
|
||||
The project uses `npm audit` and Dependabot to monitor for known vulnerabilities.
|
||||
The project uses `npm audit` and Renovate to monitor for known vulnerabilities.
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
+21
-24
@@ -1,13 +1,13 @@
|
||||
# Artifact Hub package metadata file
|
||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-pkg.yml
|
||||
version: "0.2.20"
|
||||
name: sealed-secrets
|
||||
version: "1.0.2"
|
||||
name: headlamp-sealed-secrets
|
||||
displayName: Sealed Secrets
|
||||
createdAt: "2026-02-12T00:00:00Z"
|
||||
description: A comprehensive Headlamp plugin for managing Bitnami Sealed Secrets with client-side encryption and RBAC-aware UI
|
||||
license: Apache-2.0
|
||||
homeURL: https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
||||
appVersion: 0.2.18
|
||||
appVersion: "0.36.1"
|
||||
containersImages:
|
||||
- name: sealed-secrets-controller
|
||||
image: docker.io/bitnami/sealed-secrets-controller:v0.24.0
|
||||
@@ -19,8 +19,8 @@ keywords:
|
||||
- encryption
|
||||
- security
|
||||
annotations:
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v0.2.20/sealed-secrets-0.2.20.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:d113db870abfebeb8d6082d173f1ab0a6214a0988da170748b2b41d3bba0fdbb
|
||||
headlamp/plugin/archive-url: "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/releases/download/v1.0.2/sealed-secrets-1.0.2.tar.gz"
|
||||
headlamp/plugin/archive-checksum: sha256:0eaf34d380d133120d3a50c890e0c96b23717427887b1f23377a841cb3783b11
|
||||
headlamp/plugin/version-compat: ">=0.13.0"
|
||||
headlamp/plugin/distro-compat: "desktop,in-cluster,web,docker-desktop"
|
||||
links:
|
||||
@@ -35,31 +35,19 @@ install: |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Headlamp v0.13.0 or later
|
||||
1. [Headlamp](https://headlamp.dev) v0.13.0 or later
|
||||
2. Sealed Secrets controller installed on your cluster:
|
||||
```bash
|
||||
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml
|
||||
```
|
||||
|
||||
### Install the Plugin
|
||||
### Install via Headlamp Plugin Catalog
|
||||
|
||||
#### Option 1: From NPM
|
||||
```bash
|
||||
npm install -g headlamp-sealed-secrets
|
||||
```
|
||||
1. Open Headlamp and navigate to **Settings → Plugin Catalog**
|
||||
2. Search for **"Sealed Secrets"**
|
||||
3. Click **Install** and restart Headlamp when prompted
|
||||
|
||||
#### Option 2: Build from Source
|
||||
```bash
|
||||
git clone https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin
|
||||
cd headlamp-sealed-secrets-plugin/headlamp-sealed-secrets
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then copy the `dist` folder to your Headlamp plugins directory:
|
||||
- **Linux**: `~/.config/Headlamp/plugins/headlamp-sealed-secrets/`
|
||||
- **macOS**: `~/Library/Application Support/Headlamp/plugins/headlamp-sealed-secrets/`
|
||||
- **Windows**: `%APPDATA%\Headlamp\plugins\headlamp-sealed-secrets\`
|
||||
The plugin is sourced directly from [ArtifactHub](https://artifacthub.io/packages/headlamp/headlamp/headlamp-sealed-secrets).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -69,7 +57,16 @@ install: |
|
||||
- Manage sealing keys
|
||||
- Configure controller settings
|
||||
|
||||
For detailed usage instructions, see the [README](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/blob/main/headlamp-sealed-secrets/README.md).
|
||||
For detailed usage instructions, see the [README](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/blob/main/README.md).
|
||||
changes:
|
||||
- kind: fixed
|
||||
description: "Fix ArtifactHub checksum — release workflow now computes checksums after rebuilding tarball"
|
||||
- kind: changed
|
||||
description: "Bump to v1.0.0 — stable public release with comprehensive tests, ArtifactHub-only installation, and full RBAC-aware UI"
|
||||
- kind: added
|
||||
description: Explicit vitest and @testing-library devDependencies for reliable test execution
|
||||
- kind: fixed
|
||||
description: Removed install-plugin.sh custom install script (ArtifactHub-only policy)
|
||||
maintainers:
|
||||
- name: privilegedescalation
|
||||
email: privilegedescalation@users.noreply.github.com
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Artifact Hub repository metadata file
|
||||
# https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml
|
||||
repositoryID: 5574d37c-c4ae-45ab-a378-ef24aaba5b4c
|
||||
repositoryID: 3d4645ad-d227-4fc0-8cae-8f8ee7794da2
|
||||
owners:
|
||||
- name: privilegedescalation
|
||||
email: privilegedescalation@users.noreply.github.com
|
||||
|
||||
+4
-117
@@ -2,158 +2,45 @@
|
||||
|
||||
Complete documentation for the Headlamp Sealed Secrets plugin.
|
||||
|
||||
## 📚 Documentation Index
|
||||
## Documentation Index
|
||||
|
||||
### Getting Started
|
||||
|
||||
New to the plugin? Start here:
|
||||
|
||||
- **[Installation Guide](getting-started/installation.md)** - Install the plugin on Headlamp
|
||||
- **[Quick Start](getting-started/quick-start.md)** - Create your first sealed secret in 5 minutes
|
||||
|
||||
### User Guide
|
||||
|
||||
Learn how to use all the features:
|
||||
|
||||
- **[Creating Secrets](user-guide/creating-secrets.md)** - Encrypt and create sealed secrets
|
||||
- **[Managing Keys](user-guide/managing-keys.md)** - View and download sealing certificates
|
||||
- **[Scopes Explained](user-guide/scopes-explained.md)** - Understand strict/namespace/cluster-wide scopes
|
||||
- **[RBAC Permissions](user-guide/rbac-permissions.md)** - Required permissions and access control
|
||||
- **[Settings](user-guide/settings.md)** - Configure plugin behavior
|
||||
|
||||
### Tutorials
|
||||
|
||||
Step-by-step guides for common workflows:
|
||||
|
||||
- **[CI/CD Integration](tutorials/ci-cd-integration.md)** - Automate secret creation with GitHub Actions, GitLab CI
|
||||
- **[Multi-Cluster Setup](tutorials/multi-cluster-setup.md)** - Manage secrets across multiple clusters
|
||||
- **[Secret Rotation](tutorials/secret-rotation.md)** - Rotate secrets and sealing keys safely
|
||||
- **[Disaster Recovery](tutorials/disaster-recovery.md)** - Backup and restore procedures
|
||||
- **[Migration from kubeseal](tutorials/migration-from-kubeseal.md)** - Migrate from CLI-based workflow
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Solutions for common issues:
|
||||
|
||||
- **[Common Errors](troubleshooting/common-errors.md)** - Error messages and fixes
|
||||
- **[Controller Issues](troubleshooting/controller-issues.md)** - Connection and deployment problems
|
||||
- **[Encryption Failures](troubleshooting/encryption-failures.md)** - Debugging encryption errors
|
||||
- **[Permission Errors](troubleshooting/permission-errors.md)** - RBAC troubleshooting
|
||||
- **[Performance](troubleshooting/performance.md)** - Optimization tips
|
||||
|
||||
### Development
|
||||
|
||||
Contributing to the plugin:
|
||||
|
||||
- **[Setup](development/setup.md)** - Development environment configuration
|
||||
- **[Workflow](development/workflow.md)** - Development and testing workflow
|
||||
- **[Testing](development/testing.md)** - Running and writing tests
|
||||
- **[Code Style](development/code-style.md)** - Coding standards
|
||||
- **[Debugging](development/debugging.md)** - Debugging tips and tools
|
||||
- **[Release Process](development/release-process.md)** - How to release new versions
|
||||
|
||||
### API Reference
|
||||
|
||||
Technical documentation:
|
||||
|
||||
- **[Functions](api-reference/functions.md)** - Exported function reference
|
||||
- **[Types](api-reference/types.md)** - TypeScript type definitions
|
||||
- **[Hooks](api-reference/hooks.md)** - React hooks API
|
||||
- **[Components](api-reference/components.md)** - Component props reference
|
||||
- **[Examples](api-reference/examples.md)** - Code examples and patterns
|
||||
|
||||
### Architecture
|
||||
|
||||
Technical design and decisions:
|
||||
|
||||
- **[Overview](architecture/overview.md)** - System architecture
|
||||
- **[Encryption Flow](architecture/encryption-flow.md)** - How encryption works
|
||||
- **[Type System](architecture/type-system.md)** - Result types and branded types explained
|
||||
- **[Error Handling](architecture/error-handling.md)** - Error handling patterns
|
||||
- **[Accessibility](architecture/accessibility.md)** - WCAG 2.1 AA compliance details
|
||||
- **[ADRs](architecture/adr/)** - Architecture Decision Records
|
||||
|
||||
### Deployment
|
||||
### API Reference
|
||||
|
||||
Production deployment guides:
|
||||
- **[Generated API Docs](api-reference/generated/)** - Auto-generated TypeScript reference
|
||||
|
||||
- **[Kubernetes](deployment/kubernetes.md)** - Deploy in K8s clusters
|
||||
- **[Helm](deployment/helm.md)** - Using with Helm deployments
|
||||
- **[Security Hardening](deployment/security-hardening.md)** - Security best practices
|
||||
- **[Monitoring](deployment/monitoring.md)** - Observability setup
|
||||
|
||||
## 🔍 Quick Links
|
||||
|
||||
### Popular Pages
|
||||
|
||||
- [Quick Start Guide](getting-started/quick-start.md) - Get started in 5 minutes
|
||||
- [CI/CD Integration](tutorials/ci-cd-integration.md) - Automate your workflow
|
||||
- [Troubleshooting](troubleshooting/README.md) - Solve common issues
|
||||
- [Development Workflow](development/workflow.md) - Contribute to the plugin
|
||||
|
||||
### External Resources
|
||||
## External Resources
|
||||
|
||||
- **GitHub**: [privilegedescalation/headlamp-sealed-secrets-plugin](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin)
|
||||
- **Issues**: [Report bugs](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/issues)
|
||||
- **Discussions**: [Ask questions](https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin/discussions)
|
||||
- **Headlamp**: [headlamp.dev](https://headlamp.dev)
|
||||
- **Sealed Secrets**: [bitnami-labs/sealed-secrets](https://github.com/bitnami-labs/sealed-secrets)
|
||||
|
||||
## 📖 About This Documentation
|
||||
|
||||
This documentation is organized by user journey:
|
||||
|
||||
- **Getting Started** - For new users
|
||||
- **User Guide** - For daily usage
|
||||
- **Tutorials** - For specific workflows
|
||||
- **Troubleshooting** - For problem-solving
|
||||
- **Development** - For contributors
|
||||
- **API Reference** - For developers using the plugin
|
||||
- **Architecture** - For understanding the design
|
||||
- **Deployment** - For production deployments
|
||||
|
||||
## 🤝 Contributing to Docs
|
||||
|
||||
Found an error or want to improve the documentation?
|
||||
|
||||
1. **Quick fixes**: Edit on GitHub and submit a PR
|
||||
2. **Larger changes**: Open an issue first to discuss
|
||||
3. **New tutorials**: Share your use case in Discussions
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## 📝 Documentation Status
|
||||
|
||||
### Completed ✅
|
||||
|
||||
- Installation guides
|
||||
- Quick start tutorial
|
||||
- Development workflow documentation
|
||||
- Testing guides
|
||||
- Architecture overview
|
||||
|
||||
### In Progress 🚧
|
||||
|
||||
- User guide sections (creating secrets, managing keys, scopes)
|
||||
- Tutorial content (CI/CD, multi-cluster, rotation)
|
||||
- Troubleshooting guides
|
||||
- API reference (auto-generated coming soon)
|
||||
|
||||
### Planned 📅
|
||||
|
||||
- Video tutorials
|
||||
- Interactive examples
|
||||
- Detailed architecture diagrams
|
||||
- More CI/CD platform examples
|
||||
- Advanced use cases
|
||||
|
||||
## 🔄 Documentation Updates
|
||||
|
||||
This documentation is kept in sync with code changes:
|
||||
|
||||
- **Version**: Matches plugin version (currently v0.2.0)
|
||||
- **Auto-generated**: API reference generated from TypeScript source
|
||||
- **CI Checks**: Links validated on every pull request
|
||||
- **Examples Tested**: Code examples validated against current API
|
||||
|
||||
Last updated: 2026-02-12
|
||||
|
||||
@@ -349,7 +349,7 @@ Added type safety:
|
||||
|
||||
**Supply Chain**:
|
||||
- Risk: Compromised node-forge dependency
|
||||
- Mitigation: Package lock, dependabot, regular audits
|
||||
- Mitigation: Package lock, Renovate, regular audits
|
||||
- Same risk as any JavaScript dependency
|
||||
|
||||
**Browser Extensions**:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# ADR 006: Error Boundary with Dual Variants
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The Sealed Secrets plugin registers components at two distinct integration points in Headlamp:
|
||||
|
||||
1. **Route-level**: Full-page views (`SealedSecretList`, `SealingKeysView`) registered via `registerRoute`
|
||||
2. **Section-level**: Injected detail sections (`SecretDetailsSection`) registered via `registerDetailsViewSection`
|
||||
|
||||
Each integration point has different error recovery requirements:
|
||||
|
||||
- **Route-level errors** typically stem from API connectivity issues (controller not found, RBAC misconfiguration). Users need troubleshooting guidance and a retry mechanism.
|
||||
- **Section-level errors** are isolated failures within a host page. The error should be contained without disrupting the rest of the detail view. A simple reload is sufficient.
|
||||
|
||||
A single error boundary class cannot serve both needs because the error messaging, recovery actions, and visual treatment differ significantly.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a `BaseErrorBoundary` abstract class with a `renderError()` template method, then derive two concrete variants:
|
||||
|
||||
- **`ApiErrorBoundary`**: Used at route level. Displays connectivity troubleshooting guidance (check controller namespace, RBAC permissions, pod status) with a Retry button that resets the error state.
|
||||
- **`GenericErrorBoundary`**: Used at section level. Displays a compact error message with a Reload button. Designed to fail gracefully without affecting the parent detail page.
|
||||
|
||||
Both variants use `getDerivedStateFromError` for error capture and expose a reset mechanism via `setState({ hasError: false })`.
|
||||
|
||||
```typescript
|
||||
abstract class BaseErrorBoundary extends React.Component<Props, State> {
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
abstract renderError(error: Error): React.ReactNode;
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.renderError(this.state.error);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
✅ **Appropriate error recovery**: Each integration point gets tailored error messages and recovery actions
|
||||
|
||||
✅ **Fault isolation**: Section-level errors don't crash the entire detail page
|
||||
|
||||
✅ **Shared base class**: Common error capture logic is defined once in `BaseErrorBoundary`
|
||||
|
||||
✅ **Consistent with React patterns**: Error boundaries are the recommended React mechanism for catching render errors
|
||||
|
||||
### Negative
|
||||
|
||||
⚠️ **Class components required**: React error boundaries must be class components, breaking the otherwise all-functional-component convention
|
||||
|
||||
⚠️ **Two components to maintain**: Changes to error handling patterns must be applied to both variants
|
||||
|
||||
### Mitigation
|
||||
|
||||
- The class component exception is documented and limited to `ErrorBoundary.tsx`
|
||||
- Both variants share `BaseErrorBoundary`, so common logic changes propagate automatically
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Single generic error boundary
|
||||
|
||||
**Pros**:
|
||||
- Simpler — one component for all uses
|
||||
- Less code to maintain
|
||||
|
||||
**Cons**:
|
||||
- Cannot provide context-specific troubleshooting guidance
|
||||
- Route-level errors need different recovery UX than section-level errors
|
||||
- Generic messages are unhelpful for API connectivity issues
|
||||
|
||||
**Rejected**: The error recovery requirements differ too much between route and section contexts.
|
||||
|
||||
---
|
||||
|
||||
### 2. try/catch in each component
|
||||
|
||||
**Pros**:
|
||||
- No class components needed
|
||||
- Per-component error handling
|
||||
|
||||
**Cons**:
|
||||
- Cannot catch render-phase errors (React limitation)
|
||||
- Duplicated error handling logic across every component
|
||||
- Inconsistent error UX
|
||||
|
||||
**Rejected**: React error boundaries are the only mechanism for catching render errors.
|
||||
|
||||
---
|
||||
|
||||
### 3. React error boundary library (react-error-boundary)
|
||||
|
||||
**Pros**:
|
||||
- Functional component API via `ErrorBoundary` wrapper
|
||||
- Built-in reset mechanisms
|
||||
- Well-maintained
|
||||
|
||||
**Cons**:
|
||||
- External dependency not available in plugin runtime
|
||||
- Plugin cannot add npm dependencies beyond Headlamp peer dependencies
|
||||
|
||||
**Rejected**: Dependency constraint makes this infeasible.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
- `ApiErrorBoundary` wraps `SealedSecretList` and `SealingKeysView` in `index.tsx`
|
||||
- `GenericErrorBoundary` wraps `SecretDetailsSection` in `index.tsx`
|
||||
- Both are defined in `src/components/ErrorBoundary.tsx`
|
||||
- Uses MUI `Alert`, `Box`, `Button`, `Typography` for styled error display
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [React Error Boundaries](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
|
||||
- [Headlamp Plugin Registration API](https://headlamp.dev/docs/latest/development/plugins/)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR 005: Custom React Hooks](005-react-hooks-extraction.md) — Hooks architecture that error boundaries wrap
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
- **2026-03-05**: Initial decision
|
||||
@@ -0,0 +1,157 @@
|
||||
# ADR 007: Custom Hooks Architecture vs Data Context
|
||||
|
||||
**Status**: Accepted
|
||||
|
||||
**Date**: 2026-03-05
|
||||
|
||||
**Deciders**: Development Team
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
All other Headlamp plugins in this project family (polaris, rook, intel-gpu, kube-vip, tns-csi) use a single React Context provider (`*DataProvider`) to centralize data fetching and share state across components. This is the established pattern.
|
||||
|
||||
The Sealed Secrets plugin has different requirements:
|
||||
|
||||
1. **Multiple independent data domains**: Controller health, RBAC permissions, SealedSecret CRUD, and encryption are logically separate concerns with different lifecycles.
|
||||
2. **CRD class extension**: `SealedSecret` extends Headlamp's `KubeObject` class, providing its own `useList()` hook — making a centralized fetch redundant for the primary resource.
|
||||
3. **Write-heavy workflows**: Unlike read-only plugins, sealed-secrets creates, encrypts, and rotates resources. The encryption workflow involves multi-step state (certificate fetch → encrypt → create resource).
|
||||
4. **Independent refresh cadences**: Controller health polls every 30 seconds; SealedSecret list is reactive via `useList()`; RBAC checks run once on mount.
|
||||
|
||||
A single context provider would either become a monolithic "god context" or force artificial coupling between unrelated concerns.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use **independent custom hooks** instead of a shared data context:
|
||||
|
||||
- **`useControllerHealth(autoRefresh?, intervalMs?)`**: Polls controller `/healthz` endpoint. Returns `{ healthy, checking, error, refresh }`.
|
||||
- **`usePermissions()`**: Queries RBAC capabilities on mount. Returns permission flags for create, delete, encrypt operations.
|
||||
- **`useSealedSecretEncryption()`**: Orchestrates the encryption workflow (fetch cert → encrypt values → build manifest). Returns workflow state and action functions.
|
||||
- **`SealedSecret.useList()`**: Headlamp's built-in `KubeObject.useList()` — reactive to cluster changes, no custom fetch needed.
|
||||
|
||||
Each hook manages its own loading, error, and refresh state. Components compose multiple hooks as needed.
|
||||
|
||||
```typescript
|
||||
function SealedSecretList() {
|
||||
const [secrets, error] = SealedSecret.useList();
|
||||
const { healthy } = useControllerHealth(true);
|
||||
const { canCreate } = usePermissions();
|
||||
// Each concern is independent
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
✅ **Separation of concerns**: Each hook encapsulates a single domain (health, permissions, encryption, CRUD)
|
||||
|
||||
✅ **Independent lifecycles**: Controller health polls at 30s; RBAC checks once; list is reactive — no unnecessary coupling
|
||||
|
||||
✅ **Composable**: Components pick only the hooks they need, avoiding unnecessary data in scope
|
||||
|
||||
✅ **Testable in isolation**: Each hook can be unit-tested independently without mocking an entire context provider
|
||||
|
||||
✅ **Leverages Headlamp's KubeObject**: `SealedSecret.useList()` provides reactive list updates without custom fetch logic
|
||||
|
||||
### Negative
|
||||
|
||||
⚠️ **Diverges from project convention**: Other plugins use the `*DataProvider` pattern — contributors must learn a different approach for this plugin
|
||||
|
||||
⚠️ **No single source of truth**: State is distributed across hooks rather than centralized — harder to debug "what data does the plugin have right now?"
|
||||
|
||||
⚠️ **Potential duplicate fetches**: If two components both call `useControllerHealth()`, the health endpoint is polled twice
|
||||
|
||||
### Mitigation
|
||||
|
||||
- The convention divergence is documented in `CLAUDE.md` and this ADR
|
||||
- Controller health polling is lightweight (single `/healthz` call)
|
||||
- `SealedSecret.useList()` is internally deduplicated by Headlamp's hook system
|
||||
|
||||
---
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. Single SealedSecretsDataProvider context
|
||||
|
||||
**Pros**:
|
||||
- Consistent with other plugins in the project
|
||||
- Single source of truth for all sealed-secrets data
|
||||
- Deduplicates fetches automatically
|
||||
|
||||
**Cons**:
|
||||
- Would become a "god context" with 10+ fields spanning unrelated concerns
|
||||
- All consumers re-render when any field changes (health poll triggers list re-render)
|
||||
- Encryption workflow state doesn't belong in shared context (it's dialog-scoped)
|
||||
- `SealedSecret.useList()` already provides reactive CRUD — wrapping it in context adds indirection
|
||||
|
||||
**Rejected**: The data domains are too independent; a single context would create artificial coupling.
|
||||
|
||||
---
|
||||
|
||||
### 2. Multiple specialized contexts
|
||||
|
||||
**Pros**:
|
||||
- Separation of concerns (like hooks)
|
||||
- Consistent with React Context pattern
|
||||
|
||||
**Cons**:
|
||||
- Three or four nested providers in `index.tsx` — deep nesting
|
||||
- More boilerplate than hooks (provider + context + consumer hook per domain)
|
||||
- No benefit over standalone hooks when providers don't need to share state
|
||||
|
||||
**Rejected**: Contexts add boilerplate without benefit when data domains are independent.
|
||||
|
||||
---
|
||||
|
||||
### 3. State management library (Zustand, Jotai)
|
||||
|
||||
**Pros**:
|
||||
- Lightweight, no provider nesting
|
||||
- Built-in deduplication and memoization
|
||||
|
||||
**Cons**:
|
||||
- External dependency not available in plugin runtime
|
||||
- Plugins cannot add npm dependencies beyond Headlamp peer dependencies
|
||||
|
||||
**Rejected**: Dependency constraint makes this infeasible.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
```
|
||||
src/hooks/
|
||||
├── useControllerHealth.ts # Health polling with configurable interval
|
||||
├── usePermissions.ts # RBAC capability check (runs once)
|
||||
└── useSealedSecretEncryption.ts # Multi-step encryption workflow
|
||||
```
|
||||
|
||||
- Components in `src/components/` import hooks directly
|
||||
- No provider wrapping needed in `index.tsx` (except error boundaries)
|
||||
- `SealedSecret` class in `src/lib/SealedSecretCRD.ts` extends `KubeObject` for `useList()`/`useGet()`
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [React Custom Hooks](https://react.dev/learn/reusing-logic-with-custom-hooks)
|
||||
- [Headlamp KubeObject API](https://headlamp.dev/docs/latest/development/api/classes/lib_k8s_cluster.KubeObject/)
|
||||
|
||||
---
|
||||
|
||||
## Related ADRs
|
||||
|
||||
- [ADR 005: Custom React Hooks](005-react-hooks-extraction.md) — Details the hook extraction process
|
||||
- [ADR 006: Dual Error Boundaries](006-dual-error-boundaries.md) — Error handling that wraps hook-based components
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
- **2026-03-05**: Initial decision
|
||||
@@ -26,6 +26,8 @@ Each ADR follows this structure:
|
||||
| [003](003-client-side-crypto.md) | Client-Side Encryption | Accepted | 2026-02-11 |
|
||||
| [004](004-rbac-integration.md) | RBAC-Aware UI | Accepted | 2026-02-11 |
|
||||
| [005](005-react-hooks-extraction.md) | Custom React Hooks | Accepted | 2026-02-12 |
|
||||
| [006](006-dual-error-boundaries.md) | Error Boundary with Dual Variants | Accepted | 2026-03-05 |
|
||||
| [007](007-hooks-vs-context.md) | Custom Hooks Architecture vs Data Context | Accepted | 2026-03-05 |
|
||||
|
||||
## Creating New ADRs
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ For Headlamp running in Kubernetes:
|
||||
kubectl create configmap headlamp-sealed-secrets-plugin \
|
||||
--from-file=main.js=dist/main.js \
|
||||
--from-file=package.json=package.json \
|
||||
-n headlamp
|
||||
-n <your-namespace>
|
||||
```
|
||||
|
||||
2. **Update Headlamp deployment**:
|
||||
@@ -130,7 +130,7 @@ For Headlamp running in Kubernetes:
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: headlamp
|
||||
namespace: headlamp
|
||||
namespace: <your-namespace>
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
@@ -149,7 +149,7 @@ For Headlamp running in Kubernetes:
|
||||
3. **Apply and restart**:
|
||||
```bash
|
||||
kubectl apply -f headlamp-deployment.yaml
|
||||
kubectl rollout restart deployment/headlamp -n headlamp
|
||||
kubectl rollout restart deployment/headlamp -n <your-namespace>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { test as setup, expect, Page } from '@playwright/test';
|
||||
|
||||
const AUTH_STATE_PATH = 'e2e/.auth/state.json';
|
||||
|
||||
async function authenticateWithOIDC(page: Page, username: string, password: string): Promise<void> {
|
||||
// Navigate to login — Headlamp redirects / to /c/main/login
|
||||
await page.goto('/');
|
||||
await page.waitForURL('**/login');
|
||||
|
||||
// Click "Sign In" and capture the Authentik popup
|
||||
const popupPromise = page.waitForEvent('popup');
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
const popup = await popupPromise;
|
||||
|
||||
// Wait for the Authentik popup to fully load before interacting
|
||||
await popup.waitForLoadState('domcontentloaded');
|
||||
await popup.waitForLoadState('networkidle');
|
||||
|
||||
// Authentik step 1: fill username — wait for the form to render
|
||||
const usernameField = popup.getByRole('textbox', { name: /email or username/i });
|
||||
await usernameField.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await usernameField.fill(username);
|
||||
await popup.getByRole('button', { name: /log in/i }).click();
|
||||
|
||||
// Authentik step 2: fill password — wait for the next step to load
|
||||
await popup.waitForLoadState('networkidle');
|
||||
const passwordField = popup.getByRole('textbox', { name: /password/i });
|
||||
await passwordField.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await passwordField.fill(password);
|
||||
await popup.getByRole('button', { name: /continue|log in/i }).click();
|
||||
|
||||
// Wait for the popup to close (Authentik redirects back, Headlamp processes callback)
|
||||
await popup.waitForEvent('close', { timeout: 15_000 });
|
||||
|
||||
// Original page should now be authenticated — wait for sidebar
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticateWithToken(page: Page, token: string): Promise<void> {
|
||||
await page.goto('/');
|
||||
// Headlamp goes to /token directly when no OIDC is configured,
|
||||
// or through /login when OIDC is configured
|
||||
await page.waitForURL(/\/(login|token)$/);
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
// OIDC login page — click "use a token" to reach token auth.
|
||||
const useTokenBtn = page.getByRole('button', { name: /use a token/i });
|
||||
await useTokenBtn.waitFor({ state: 'visible', timeout: 15_000 });
|
||||
await useTokenBtn.click();
|
||||
await page.waitForURL('**/token');
|
||||
}
|
||||
|
||||
// Fill the "ID token" field and submit
|
||||
await page.getByRole('textbox', { name: /id token/i }).fill(token);
|
||||
await page.getByRole('button', { name: /authenticate/i }).click();
|
||||
|
||||
// Wait for the main UI to load
|
||||
await expect(page.getByRole('navigation', { name: 'Navigation' })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
setup('authenticate with Headlamp', async ({ page }) => {
|
||||
const username = process.env.AUTHENTIK_USERNAME;
|
||||
const password = process.env.AUTHENTIK_PASSWORD;
|
||||
const token = process.env.HEADLAMP_TOKEN;
|
||||
|
||||
if (username && password) {
|
||||
await authenticateWithOIDC(page, username, password);
|
||||
} else if (token) {
|
||||
await authenticateWithToken(page, token);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Set AUTHENTIK_USERNAME + AUTHENTIK_PASSWORD for OIDC auth, or HEADLAMP_TOKEN for token auth'
|
||||
);
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: AUTH_STATE_PATH });
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Sealed Secrets plugin smoke tests', () => {
|
||||
test('sidebar contains sealed-secrets entry', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.getByRole('button', { name: /sealed.secrets/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('sidebar sealed-secrets entry is clickable and navigates to list view', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const sealedSecretsEntry = sidebar.getByRole('button', { name: /sealed.secrets/i });
|
||||
await expect(sealedSecretsEntry).toBeVisible();
|
||||
await sealedSecretsEntry.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/sealedsecrets/);
|
||||
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('sealed secrets list page renders table or empty state', async ({ page }) => {
|
||||
await page.goto('/c/main/sealedsecrets');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Either a populated table or an empty-state indicator must be visible
|
||||
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
|
||||
const hasEmptyState = await page
|
||||
.locator('text=/no.*sealed|no.*secret|0 item|empty/i')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasTable || hasEmptyState).toBe(true);
|
||||
});
|
||||
|
||||
test('sealing keys page renders table or empty state', async ({ page }) => {
|
||||
await page.goto('/c/main/sealedsecrets/keys');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const hasTable = await page.locator('table').first().isVisible().catch(() => false);
|
||||
const hasEmptyState = await page
|
||||
.locator('text=/no.*key|0 item|empty/i')
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
expect(hasTable || hasEmptyState).toBe(true);
|
||||
});
|
||||
|
||||
test('navigation between sealed-secrets views works', async ({ page }) => {
|
||||
await page.goto('/c/main/sealedsecrets');
|
||||
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Navigate to Sealing Keys via sidebar
|
||||
const sidebar = page.getByRole('navigation', { name: 'Navigation' });
|
||||
const keysLink = sidebar.getByRole('link', { name: /sealing.key/i });
|
||||
await expect(keysLink).toBeVisible();
|
||||
await keysLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/sealedsecrets\/keys$/);
|
||||
await expect(page.getByRole('heading', { name: /sealing.key/i })).toBeVisible();
|
||||
|
||||
// Navigate back to All Sealed Secrets
|
||||
const allSecretsLink = sidebar.getByRole('link', { name: /all sealed secrets/i });
|
||||
await expect(allSecretsLink).toBeVisible();
|
||||
await allSecretsLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/sealedsecrets(?!\/keys)/);
|
||||
await expect(page.getByRole('heading', { name: /sealed.secrets/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('plugin settings page shows sealed-secrets plugin entry', async ({ page }) => {
|
||||
await page.goto('/settings/plugins');
|
||||
|
||||
// Wait for plugin list to load — plugin scripts load asynchronously
|
||||
const pluginEntry = page.locator('text=sealed-secrets').first();
|
||||
await expect(pluginEntry).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Install Headlamp Sealed Secrets Plugin
|
||||
#
|
||||
# This script builds and installs the plugin to your local Headlamp installation.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Headlamp Sealed Secrets Plugin Installer${NC}"
|
||||
echo "=========================================="
|
||||
echo
|
||||
|
||||
# Detect OS and set plugin directory
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
PLUGIN_DIR="$HOME/Library/Application Support/Headlamp/plugins/headlamp-sealed-secrets"
|
||||
echo -e "${YELLOW}Detected: macOS${NC}"
|
||||
elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
PLUGIN_DIR="$HOME/.config/Headlamp/plugins/headlamp-sealed-secrets"
|
||||
echo -e "${YELLOW}Detected: Linux${NC}"
|
||||
else
|
||||
echo -e "${RED}Unsupported OS: $OSTYPE${NC}"
|
||||
echo "For Windows, please see HEADLAMP_INSTALLATION.md"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Plugin will be installed to: $PLUGIN_DIR"
|
||||
echo
|
||||
|
||||
# Check if node/npm are available
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo -e "${RED}Error: npm is not installed${NC}"
|
||||
echo "Please install Node.js and npm first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Navigate to plugin directory
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo -e "${GREEN}Step 1: Installing dependencies...${NC}"
|
||||
npm install
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Step 2: Building plugin...${NC}"
|
||||
npm run build
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Step 3: Creating plugin directory...${NC}"
|
||||
mkdir -p "$PLUGIN_DIR"
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}Step 4: Copying plugin files...${NC}"
|
||||
cp -v dist/main.js "$PLUGIN_DIR/"
|
||||
cp -v package.json "$PLUGIN_DIR/"
|
||||
cp -v README.md "$PLUGIN_DIR/" 2>/dev/null || true
|
||||
cp -v LICENSE "$PLUGIN_DIR/" 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}✓ Installation complete!${NC}"
|
||||
echo
|
||||
echo "Plugin installed to: $PLUGIN_DIR"
|
||||
echo
|
||||
echo "Next steps:"
|
||||
echo "1. Restart Headlamp desktop application"
|
||||
echo "2. Open Headlamp and connect to your cluster"
|
||||
echo "3. Look for 'Sealed Secrets' in the sidebar"
|
||||
echo
|
||||
echo "To verify sealed-secrets controller is installed:"
|
||||
echo " kubectl get pods -n kube-system -l name=sealed-secrets-controller"
|
||||
echo
|
||||
echo "To install sealed-secrets controller (if not present):"
|
||||
echo " kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml"
|
||||
echo
|
||||
Generated
+2231
-1569
File diff suppressed because it is too large
Load Diff
+28
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sealed-secrets",
|
||||
"version": "0.2.20",
|
||||
"version": "1.0.2",
|
||||
"description": "Headlamp plugin for Bitnami Sealed Secrets - manage encrypted Kubernetes secrets",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -17,6 +17,7 @@
|
||||
"homepage": "https://github.com/privilegedescalation/headlamp-sealed-secrets-plugin#readme",
|
||||
"author": "privilegedescalation",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"scripts": {
|
||||
"start": "headlamp-plugin start",
|
||||
"build": "headlamp-plugin build",
|
||||
@@ -28,6 +29,8 @@
|
||||
"format:check": "prettier --check src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"storybook": "headlamp-plugin storybook",
|
||||
"storybook-build": "headlamp-plugin storybook-build",
|
||||
"i18n": "headlamp-plugin i18n",
|
||||
@@ -47,16 +50,37 @@
|
||||
"k8s"
|
||||
],
|
||||
"overrides": {
|
||||
"typescript": "5.6.2"
|
||||
"tar": "^7.5.11",
|
||||
"undici": "^7.24.3",
|
||||
"vite": ">=6.4.2",
|
||||
"lodash": ">=4.18.0",
|
||||
"elliptic": ">=6.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-forge": "^1.3.1"
|
||||
"node-forge": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@headlamp-k8s/eslint-config": "^0.6.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@iconify/react": "^6.0.2",
|
||||
"@kinvolk/headlamp-plugin": "^0.13.0",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"eslint": "^8.57.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"notistack": "^3.0.0",
|
||||
"prettier": "^2.8.8",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^5.3.0",
|
||||
"typedoc": "^0.28.16",
|
||||
"typedoc-plugin-markdown": "^4.10.0"
|
||||
"typescript": "~5.6.2",
|
||||
"typedoc-plugin-markdown": "^4.10.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: process.env.HEADLAMP_URL || (() => { throw new Error('HEADLAMP_URL is required — run scripts/deploy-e2e-headlamp.sh first'); })(),
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.ts/, timeout: 60_000 },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/state.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+12192
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"]
|
||||
"extends": ["github>privilegedescalation/.github:renovate-config"]
|
||||
}
|
||||
|
||||
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy-e2e-headlamp.sh
|
||||
#
|
||||
# Deploys a stock Headlamp instance with the sealed-secrets plugin loaded via
|
||||
# a ConfigMap volume mount. No custom Docker images — the plugin is built
|
||||
# in CI and injected as a ConfigMap.
|
||||
#
|
||||
# E2E resources are deployed to the `headlamp-dev` namespace. Nothing
|
||||
# persists beyond the test run — teardown cleans up all created resources.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Plugin built (dist/ exists with plugin-main.js + package.json)
|
||||
# - kubectl configured with cluster access
|
||||
# RBAC is managed via Flux from privilegedescalation/infra/apps/base/e2e-ci-runner-rbac.yaml.
|
||||
# The infra repo is the source of truth — do not apply this file directly.
|
||||
#
|
||||
# Environment:
|
||||
# E2E_NAMESPACE — namespace for E2E Headlamp (default: headlamp-dev)
|
||||
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
|
||||
# HEADLAMP_VERSION — Headlamp image tag (default: latest)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DIST_DIR="$REPO_ROOT/dist"
|
||||
|
||||
E2E_NAMESPACE="${E2E_NAMESPACE:-headlamp-dev}"
|
||||
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
||||
HEADLAMP_VERSION="${HEADLAMP_VERSION:-latest}"
|
||||
|
||||
if [ ! -d "$DIST_DIR" ]; then
|
||||
echo "ERROR: dist/ not found. Run 'pnpm build' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Preflight: verify RBAC before touching the cluster ---
|
||||
echo "Checking RBAC permissions in namespace '${E2E_NAMESPACE}'..."
|
||||
if ! kubectl auth can-i delete configmaps -n "$E2E_NAMESPACE" --quiet 2>/dev/null; then
|
||||
echo "ERROR: Missing RBAC — cannot delete configmaps in namespace '${E2E_NAMESPACE}'." >&2
|
||||
echo " RBAC is managed via Flux from privilegedescalation/infra/apps/base/e2e-ci-runner-rbac.yaml" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== E2E Headlamp Deployment ==="
|
||||
echo " Image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}"
|
||||
echo " Namespace: $E2E_NAMESPACE"
|
||||
echo " Release: $E2E_RELEASE"
|
||||
|
||||
# --- Create ConfigMap from built plugin ---
|
||||
echo ""
|
||||
echo "Creating ConfigMap with plugin files..."
|
||||
|
||||
# Delete existing ConfigMap if present (idempotent redeploy)
|
||||
kubectl delete configmap headlamp-sealed-secrets-plugin \
|
||||
-n "$E2E_NAMESPACE" --ignore-not-found
|
||||
|
||||
# Create ConfigMap from dist/ contents and package.json
|
||||
kubectl create configmap headlamp-sealed-secrets-plugin \
|
||||
-n "$E2E_NAMESPACE" \
|
||||
--from-file="$DIST_DIR" \
|
||||
--from-file=package.json="$REPO_ROOT/package.json"
|
||||
|
||||
# --- Tear down any existing E2E deployment for a clean start ---
|
||||
echo ""
|
||||
echo "Removing any existing E2E deployment (clean-start)..."
|
||||
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
|
||||
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
|
||||
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found --wait
|
||||
|
||||
# --- Deploy Headlamp via kubectl apply ---
|
||||
echo ""
|
||||
echo "Deploying Headlamp E2E instance..."
|
||||
|
||||
kubectl apply -f - <<EOF
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: ${E2E_RELEASE}
|
||||
namespace: ${E2E_NAMESPACE}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: ${E2E_RELEASE}
|
||||
namespace: ${E2E_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/instance: ${E2E_RELEASE}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/instance: ${E2E_RELEASE}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/instance: ${E2E_RELEASE}
|
||||
spec:
|
||||
serviceAccountName: ${E2E_RELEASE}
|
||||
automountServiceAccountToken: false
|
||||
securityContext: {}
|
||||
containers:
|
||||
- name: headlamp
|
||||
image: ghcr.io/headlamp-k8s/headlamp:${HEADLAMP_VERSION}
|
||||
imagePullPolicy: IfNotPresent
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
privileged: false
|
||||
runAsUser: 100
|
||||
runAsGroup: 101
|
||||
args:
|
||||
- "-in-cluster"
|
||||
- "-in-cluster-context-name=main"
|
||||
- "-plugins-dir=/headlamp/plugins"
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4466
|
||||
protocol: TCP
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
failureThreshold: 6
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- name: sealed-secrets-plugin
|
||||
mountPath: /headlamp/plugins/headlamp-sealed-secrets
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: sealed-secrets-plugin
|
||||
configMap:
|
||||
name: headlamp-sealed-secrets-plugin
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: ${E2E_RELEASE}
|
||||
namespace: ${E2E_NAMESPACE}
|
||||
labels:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/instance: ${E2E_RELEASE}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: headlamp
|
||||
app.kubernetes.io/instance: ${E2E_RELEASE}
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
EOF
|
||||
|
||||
echo "Waiting for rollout..."
|
||||
sleep 2
|
||||
kubectl rollout status "deployment/${E2E_RELEASE}" \
|
||||
-n "$E2E_NAMESPACE" --timeout=120s
|
||||
|
||||
# --- Generate a service URL for tests ---
|
||||
SVC_URL="http://${E2E_RELEASE}.${E2E_NAMESPACE}.svc.cluster.local"
|
||||
|
||||
# --- Wait for DNS and HTTP reachability ---
|
||||
echo ""
|
||||
echo "Waiting for ${SVC_URL} to be reachable..."
|
||||
ATTEMPTS=0
|
||||
MAX_ATTEMPTS=24 # 24 × 5s = 120s max
|
||||
until curl -sf --max-time 5 "${SVC_URL}" -o /dev/null 2>/dev/null; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
if [ "$ATTEMPTS" -ge "$MAX_ATTEMPTS" ]; then
|
||||
echo "ERROR: ${SVC_URL} not reachable after $((MAX_ATTEMPTS * 5))s" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " [${ATTEMPTS}/${MAX_ATTEMPTS}] not yet reachable, retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo ""
|
||||
echo "E2E Headlamp is ready at: ${SVC_URL}"
|
||||
echo " export HEADLAMP_URL=${SVC_URL}"
|
||||
|
||||
# --- Generate a token for test auth ---
|
||||
echo ""
|
||||
echo "Creating service account token for E2E auth..."
|
||||
kubectl create serviceaccount headlamp-e2e-test \
|
||||
-n "$E2E_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
TOKEN=$(kubectl create token headlamp-e2e-test -n "$E2E_NAMESPACE" --duration=1h 2>/dev/null || echo "")
|
||||
if [ -n "$TOKEN" ]; then
|
||||
echo " export HEADLAMP_TOKEN=<generated>"
|
||||
echo ""
|
||||
echo "HEADLAMP_URL=${SVC_URL}" > "$REPO_ROOT/.env.e2e"
|
||||
echo "HEADLAMP_TOKEN=${TOKEN}" >> "$REPO_ROOT/.env.e2e"
|
||||
echo "Wrote .env.e2e with HEADLAMP_URL and HEADLAMP_TOKEN"
|
||||
else
|
||||
echo " WARNING: Could not generate token. Set HEADLAMP_TOKEN manually or use OIDC."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "E2E deployment complete."
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# teardown-e2e-headlamp.sh
|
||||
#
|
||||
# Tears down the dedicated E2E Headlamp instance deployed by deploy-e2e-headlamp.sh.
|
||||
#
|
||||
# RBAC is managed via Flux from privilegedescalation/infra/base/rbac/e2e-ci-runner-headlamp-rbac.yaml.
|
||||
# The infra repo is the source of truth — do not apply this file directly.
|
||||
#
|
||||
# Environment:
|
||||
# E2E_NAMESPACE — namespace to clean up (default: privilegedescalation-dev)
|
||||
# E2E_RELEASE — release/resource name prefix (default: headlamp-e2e)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
E2E_NAMESPACE="${E2E_NAMESPACE:-privilegedescalation-dev}"
|
||||
E2E_RELEASE="${E2E_RELEASE:-headlamp-e2e}"
|
||||
|
||||
echo "=== E2E Headlamp Teardown ==="
|
||||
echo " Namespace: $E2E_NAMESPACE"
|
||||
echo " Release: $E2E_RELEASE"
|
||||
|
||||
echo "Removing Headlamp Deployment, Service, and ServiceAccount..."
|
||||
kubectl delete deployment "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
|
||||
kubectl delete service "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
|
||||
kubectl delete serviceaccount "${E2E_RELEASE}" -n "$E2E_NAMESPACE" --ignore-not-found
|
||||
|
||||
echo "Cleaning up ConfigMap..."
|
||||
kubectl delete configmap headlamp-sealed-secrets-plugin -n "$E2E_NAMESPACE" --ignore-not-found
|
||||
|
||||
echo "Cleaning up test service account..."
|
||||
kubectl delete serviceaccount headlamp-e2e-test -n "$E2E_NAMESPACE" --ignore-not-found
|
||||
|
||||
# Clean up .env.e2e if present
|
||||
if [ -f "$REPO_ROOT/.env.e2e" ]; then
|
||||
rm "$REPO_ROOT/.env.e2e"
|
||||
echo "Removed .env.e2e"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "E2E teardown complete."
|
||||
@@ -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');
|
||||
setKeyValues([{ key: '', value: '', showValue: false }]);
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to create SealedSecret: ${error.message}`, { variant: 'error' });
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(
|
||||
`Failed to create SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (namespace) {
|
||||
canDecryptSecrets(namespace).then(setCanDecrypt);
|
||||
canDecryptSecrets(namespace).then(result => {
|
||||
if (!cancelled) setCanDecrypt(result);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [namespace]);
|
||||
|
||||
// Wait for required params before rendering
|
||||
@@ -104,8 +110,11 @@ export function SealedSecretDetail() {
|
||||
await sealedSecret.delete();
|
||||
enqueueSnackbar('SealedSecret deleted successfully', { variant: 'success' });
|
||||
window.history.back();
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to delete SealedSecret: ${error.message}`, { variant: 'error' });
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(
|
||||
`Failed to delete SealedSecret: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
}
|
||||
setDeleteDialogOpen(false);
|
||||
}, [sealedSecret, enqueueSnackbar]);
|
||||
@@ -115,11 +124,17 @@ export function SealedSecretDetail() {
|
||||
try {
|
||||
const config = getPluginConfig();
|
||||
const yaml = JSON.stringify(sealedSecret.jsonData);
|
||||
await rotateSealedSecret(config, yaml);
|
||||
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
|
||||
// The resource will auto-refresh via the watch
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to re-encrypt: ${error.message}`, { variant: 'error' });
|
||||
const result = await rotateSealedSecret(config, yaml);
|
||||
if (result.ok === false) {
|
||||
enqueueSnackbar(`Failed to re-encrypt: ${result.error}`, { variant: 'error' });
|
||||
} else {
|
||||
enqueueSnackbar('SealedSecret re-encrypted successfully', { variant: 'success' });
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(
|
||||
`Failed to re-encrypt: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
@@ -154,7 +169,12 @@ export function SealedSecretDetail() {
|
||||
title={
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<IconButton onClick={handleClose} edge="start" size="small">
|
||||
<IconButton
|
||||
onClick={handleClose}
|
||||
edge="start"
|
||||
size="small"
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<Icon icon="mdi:close" />
|
||||
</IconButton>
|
||||
<span>{sealedSecret.metadata.name}</span>
|
||||
@@ -233,24 +253,33 @@ export function SealedSecretDetail() {
|
||||
columns={[
|
||||
{
|
||||
label: 'Key',
|
||||
getter: (row: any) => row.key,
|
||||
getter: (row: { key: string; value: string }) => row.key,
|
||||
},
|
||||
{
|
||||
label: 'Encrypted Value',
|
||||
getter: (row: any) => {
|
||||
getter: (row: { key: string; value: string }) => {
|
||||
const val = row.value;
|
||||
return val.length > 40 ? val.substring(0, 40) + '...' : val;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Actions',
|
||||
getter: (row: any) =>
|
||||
getter: (row: { key: string; value: string }) =>
|
||||
canDecrypt ? (
|
||||
<Button size="small" onClick={() => setDecryptKey(row.key)}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setDecryptKey(row.key)}
|
||||
aria-label={`Decrypt ${row.key}`}
|
||||
>
|
||||
Decrypt
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" disabled title="No permission to access Secrets">
|
||||
<Button
|
||||
size="small"
|
||||
disabled
|
||||
title="No permission to access Secrets"
|
||||
aria-label={`Decrypt ${row.key} (no permission)`}
|
||||
>
|
||||
Decrypt
|
||||
</Button>
|
||||
),
|
||||
@@ -331,8 +360,12 @@ export function SealedSecretDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Delete SealedSecret?</DialogTitle>
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
aria-labelledby="delete-dialog-title"
|
||||
>
|
||||
<DialogTitle id="delete-dialog-title">Delete SealedSecret?</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete the SealedSecret <strong>{name}</strong>? This will also
|
||||
delete the resulting Kubernetes Secret.
|
||||
|
||||
@@ -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);
|
||||
URL.revokeObjectURL(url);
|
||||
enqueueSnackbar('Certificate downloaded', { variant: 'success' });
|
||||
} catch (error: any) {
|
||||
enqueueSnackbar(`Failed to create download: ${error.message}`, { variant: 'error' });
|
||||
} catch (error: unknown) {
|
||||
enqueueSnackbar(
|
||||
`Failed to create download: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ variant: 'error' }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,7 +192,12 @@ export function SealingKeysView() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<span>{expiryDate}</span>
|
||||
<span style={{ color: '#666', fontSize: '0.9em' }}>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--mui-palette-text-secondary, #666)',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
({certInfo.daysUntilExpiry} days)
|
||||
</span>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Unit tests for SecretDetailsSection component
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock headlamp
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
|
||||
K8s: { ResourceClasses: {} },
|
||||
}));
|
||||
|
||||
vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({
|
||||
Link: ({ children, ...props }: { children: React.ReactNode }) => (
|
||||
<a data-testid="link" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
NameValueTable: ({ rows }: { rows: Array<{ name: string; value: unknown }> }) => (
|
||||
<table>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.name}</td>
|
||||
<td>{typeof row.value === 'string' ? row.value : <>{row.value}</>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||
<div data-testid="section-box">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
StatusLabel: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../lib/SealedSecretCRD', () => ({
|
||||
SealedSecret: {
|
||||
useGet: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
import { SecretDetailsSection } from './SecretDetailsSection';
|
||||
|
||||
const mockUseGet = vi.mocked(SealedSecret.useGet);
|
||||
|
||||
describe('SecretDetailsSection', () => {
|
||||
it('should return null when Secret has no SealedSecret owner', () => {
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
ownerReferences: [
|
||||
{ kind: 'Deployment', apiVersion: 'apps/v1', name: 'my-deploy', uid: '123' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<SecretDetailsSection resource={resource} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should return null when Secret has no owner references', () => {
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<SecretDetailsSection resource={resource} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should show loading text when SealedSecret is still loading', () => {
|
||||
mockUseGet.mockReturnValue([null, null] as never);
|
||||
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
ownerReferences: [
|
||||
{
|
||||
kind: 'SealedSecret',
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
name: 'my-sealed-secret',
|
||||
uid: '456',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
render(<SecretDetailsSection resource={resource} />);
|
||||
|
||||
expect(screen.getByText('Sealed Secret')).toBeDefined();
|
||||
expect(screen.getByText('Loading SealedSecret information...')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display SealedSecret info when loaded', () => {
|
||||
const mockSealedSecret = {
|
||||
metadata: {
|
||||
name: 'my-sealed-secret',
|
||||
namespace: 'default',
|
||||
},
|
||||
scope: 'strict',
|
||||
isSynced: true,
|
||||
};
|
||||
mockUseGet.mockReturnValue([mockSealedSecret, null] as never);
|
||||
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
ownerReferences: [
|
||||
{
|
||||
kind: 'SealedSecret',
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
name: 'my-sealed-secret',
|
||||
uid: '789',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
render(<SecretDetailsSection resource={resource} />);
|
||||
|
||||
expect(screen.getByText('Sealed Secret')).toBeDefined();
|
||||
expect(screen.getByText('my-sealed-secret')).toBeDefined();
|
||||
expect(screen.getByText('Synced')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show Not Synced status for unsynced SealedSecret', () => {
|
||||
const mockSealedSecret = {
|
||||
metadata: { name: 'ss', namespace: 'default' },
|
||||
scope: 'namespace-wide',
|
||||
isSynced: false,
|
||||
};
|
||||
mockUseGet.mockReturnValue([mockSealedSecret, null] as never);
|
||||
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
ownerReferences: [
|
||||
{
|
||||
kind: 'SealedSecret',
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
name: 'ss',
|
||||
uid: '111',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
render(<SecretDetailsSection resource={resource} />);
|
||||
|
||||
expect(screen.getByText('Not Synced')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should filter by correct apiVersion', () => {
|
||||
const resource = {
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name: 'my-secret',
|
||||
namespace: 'default',
|
||||
ownerReferences: [
|
||||
{
|
||||
kind: 'SealedSecret',
|
||||
apiVersion: 'wrong-api/v1',
|
||||
name: 'wrong-ss',
|
||||
uid: '222',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = render(<SecretDetailsSection resource={resource} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,24 @@ import {
|
||||
import React from 'react';
|
||||
import { SealedSecret } from '../lib/SealedSecretCRD';
|
||||
|
||||
interface OwnerReference {
|
||||
kind: string;
|
||||
apiVersion: string;
|
||||
name: string;
|
||||
uid: string;
|
||||
}
|
||||
|
||||
interface SecretResource {
|
||||
kind?: string;
|
||||
metadata?: {
|
||||
name?: string;
|
||||
namespace?: string;
|
||||
ownerReferences?: OwnerReference[];
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretDetailsSectionProps {
|
||||
resource: any; // The Secret resource
|
||||
resource: SecretResource;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +40,7 @@ interface SecretDetailsSectionProps {
|
||||
export function SecretDetailsSection({ resource }: SecretDetailsSectionProps) {
|
||||
// Check if this Secret is owned by a SealedSecret
|
||||
const ownerRef = resource.metadata?.ownerReferences?.find(
|
||||
(ref: any) => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
|
||||
ref => ref.kind === 'SealedSecret' && ref.apiVersion === 'bitnami.com/v1alpha1'
|
||||
);
|
||||
|
||||
if (!ownerRef) {
|
||||
|
||||
@@ -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 result = await checkControllerHealth(config);
|
||||
|
||||
if (result.ok) {
|
||||
setHealth(result.value);
|
||||
} else if (result.ok === false) {
|
||||
// Even on error, checkControllerHealth returns a status
|
||||
// This shouldn't happen, but handle gracefully
|
||||
if (result.ok === false) {
|
||||
setHealth({
|
||||
healthy: false,
|
||||
reachable: false,
|
||||
error: result.error,
|
||||
});
|
||||
} else {
|
||||
setHealth(result.value);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the SealedSecret manifest constructed for API submission
|
||||
*/
|
||||
interface SealedSecretManifest {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
annotations: Record<string, string>;
|
||||
};
|
||||
spec: {
|
||||
encryptedData: Record<string, string>;
|
||||
template: {
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of successful encryption
|
||||
*/
|
||||
export interface EncryptionResult {
|
||||
/** The complete SealedSecret object ready to apply */
|
||||
sealedSecretData: any;
|
||||
sealedSecretData: SealedSecretManifest;
|
||||
/** Information about the certificate used */
|
||||
certificateInfo?: CertificateInfo;
|
||||
}
|
||||
@@ -158,7 +177,7 @@ export function useSealedSecretEncryption() {
|
||||
}
|
||||
|
||||
// Step 6: Construct the SealedSecret object
|
||||
const sealedSecretData: any = {
|
||||
const sealedSecretData: SealedSecretManifest = {
|
||||
apiVersion: 'bitnami.com/v1alpha1',
|
||||
kind: 'SealedSecret',
|
||||
metadata: {
|
||||
@@ -186,8 +205,8 @@ export function useSealedSecretEncryption() {
|
||||
sealedSecretData,
|
||||
certificateInfo: certInfo,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Unknown encryption error';
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
enqueueSnackbar(errorMsg, { variant: 'error' });
|
||||
return Err(errorMsg);
|
||||
} finally {
|
||||
|
||||
@@ -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,
|
||||
} from '../types';
|
||||
|
||||
interface CRDVersion {
|
||||
name: string;
|
||||
storage?: boolean;
|
||||
served?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SealedSecret CRD class
|
||||
* Represents a Bitnami Sealed Secret resource in the cluster
|
||||
@@ -128,7 +134,7 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
|
||||
);
|
||||
|
||||
// Find the storage version (the version used for persistence)
|
||||
const storageVersion = crd.spec?.versions?.find((v: any) => v.storage === true);
|
||||
const storageVersion = crd.spec?.versions?.find((v: CRDVersion) => v.storage === true);
|
||||
|
||||
if (storageVersion) {
|
||||
const version = `${crd.spec.group}/${storageVersion.name}`;
|
||||
@@ -137,7 +143,7 @@ export class SealedSecret extends KubeObject<SealedSecretInterface> {
|
||||
}
|
||||
|
||||
// Fallback to first served version if no storage version found
|
||||
const servedVersion = crd.spec?.versions?.find((v: any) => v.served === true);
|
||||
const servedVersion = crd.spec?.versions?.find((v: CRDVersion) => v.served === true);
|
||||
if (servedVersion) {
|
||||
const version = `${crd.spec.group}/${servedVersion.name}`;
|
||||
this.detectedVersion = version;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
export function getControllerProxyURL(config: PluginConfig, path: string): string {
|
||||
function getControllerProxyURL(config: PluginConfig, path: string): string {
|
||||
const { controllerNamespace, controllerName, controllerPort } = config;
|
||||
return `/api/v1/namespaces/${controllerNamespace}/services/http:${controllerName}:${controllerPort}/proxy${path}`;
|
||||
}
|
||||
@@ -77,38 +77,6 @@ export async function fetchPublicCertificate(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a SealedSecret can be decrypted by the controller
|
||||
*
|
||||
* @param config Plugin configuration
|
||||
* @param sealedSecretYaml YAML or JSON of the SealedSecret
|
||||
* @returns Result containing verification status or error message
|
||||
*/
|
||||
export async function verifySealedSecret(
|
||||
config: PluginConfig,
|
||||
sealedSecretYaml: string
|
||||
): AsyncResult<boolean, string> {
|
||||
const url = getControllerProxyURL(config, '/v1/verify');
|
||||
|
||||
const result = await tryCatchAsync(async () => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: sealedSecretYaml,
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
});
|
||||
|
||||
if (result.ok === false) {
|
||||
return Err(`Verification failed: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate (re-encrypt) a SealedSecret with the current active key
|
||||
*
|
||||
@@ -218,14 +186,14 @@ export async function checkControllerHealth(
|
||||
version,
|
||||
latencyMs,
|
||||
});
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
// Determine error type
|
||||
let errorMessage = 'Controller unreachable';
|
||||
if (error.name === 'AbortError') {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
errorMessage = 'Request timed out after 5 seconds';
|
||||
} else if (error.message) {
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* @returns Result containing base64-encoded encrypted value or error message
|
||||
*/
|
||||
export function encryptValue(
|
||||
function encryptValue(
|
||||
publicKey: forge.pki.rsa.PublicKey,
|
||||
value: PlaintextValue,
|
||||
namespace: string,
|
||||
@@ -98,7 +98,7 @@ export function encryptValue(
|
||||
const tag = (cipher.mode as any).tag.getBytes();
|
||||
|
||||
// Construct the sealed secret format:
|
||||
// [2-byte length of encrypted session key][encrypted session key][IV][encrypted value][auth tag]
|
||||
// [2-byte key length][encrypted key][IV][ciphertext][auth tag]
|
||||
const sessionKeyLength = encryptedSessionKey.length;
|
||||
const lengthBytes =
|
||||
String.fromCharCode((sessionKeyLength >> 8) & 0xff) +
|
||||
@@ -145,17 +145,6 @@ export function encryptKeyValues(
|
||||
return Ok(encryptedData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a PEM certificate
|
||||
*
|
||||
* @param pemCert PEM-encoded certificate string (branded type)
|
||||
* @returns true if certificate is valid, false otherwise
|
||||
*/
|
||||
export function validateCertificate(pemCert: PEMCertificate): boolean {
|
||||
const result = parsePublicKeyFromCert(pemCert);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse certificate and extract metadata
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
canList,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return Err(`Failed to check SealedSecret permissions: ${error.message}`);
|
||||
} catch (error: unknown) {
|
||||
return Err(
|
||||
`Failed to check SealedSecret permissions: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,20 +74,6 @@ export async function canDecryptSecrets(namespace: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view sealing keys (requires get permission on Secrets in controller namespace)
|
||||
*
|
||||
* @param controllerNamespace Namespace where sealed-secrets controller is running
|
||||
* @returns true if user has permission to get Secrets in controller namespace
|
||||
*/
|
||||
export async function canViewSealingKeys(controllerNamespace: string): Promise<boolean> {
|
||||
try {
|
||||
return await checkPermission('get', 'secrets', '', controllerNamespace);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a specific permission using SelfSubjectAccessReview
|
||||
*
|
||||
@@ -130,36 +120,3 @@ async function checkPermission(
|
||||
// Return false on error (assume no permission)
|
||||
return result.ok ? result.value : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check permissions for multiple namespaces
|
||||
*
|
||||
* Useful for multi-namespace views to determine which namespaces the user
|
||||
* can interact with.
|
||||
*
|
||||
* @param namespaces Array of namespace names to check
|
||||
* @returns Map of namespace to permissions
|
||||
*/
|
||||
export async function checkMultiNamespacePermissions(
|
||||
namespaces: string[]
|
||||
): AsyncResult<Record<string, ResourcePermissions>, string> {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
namespaces.map(async ns => {
|
||||
const perms = await checkSealedSecretPermissions(ns);
|
||||
return { namespace: ns, permissions: perms };
|
||||
})
|
||||
);
|
||||
|
||||
const permissionsMap: Record<string, ResourcePermissions> = {};
|
||||
for (const { namespace, permissions } of results) {
|
||||
if (permissions.ok) {
|
||||
permissionsMap[namespace] = permissions.value;
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(permissionsMap);
|
||||
} catch (error: any) {
|
||||
return Err(`Failed to check multi-namespace permissions: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,54 +133,3 @@ export async function retryWithBackoff<T, E>(
|
||||
// Should never reach here, but TypeScript needs it
|
||||
return Err(`Operation failed after ${opts.maxAttempts} attempts:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate to check if error is a network error (retryable)
|
||||
*
|
||||
* @param error Error to check
|
||||
* @returns true if error is network-related
|
||||
*/
|
||||
export function isNetworkError(error: Error): boolean {
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes('network') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('fetch') ||
|
||||
message.includes('connection') ||
|
||||
message.includes('econnrefused') ||
|
||||
message.includes('enotfound')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate to check if HTTP error is retryable (5xx, 429, 408)
|
||||
*
|
||||
* @param error Error to check
|
||||
* @returns true if HTTP status is retryable
|
||||
*/
|
||||
export function isRetryableHttpError(error: Error): boolean {
|
||||
const message = error.message;
|
||||
|
||||
// Check for 5xx server errors
|
||||
if (/5\d{2}/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific retryable status codes
|
||||
return (
|
||||
message.includes('429') || // Too Many Requests
|
||||
message.includes('408') || // Request Timeout
|
||||
message.includes('503') || // Service Unavailable
|
||||
message.includes('504')
|
||||
); // Gateway Timeout
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined predicate for network and HTTP errors
|
||||
*
|
||||
* @param error Error to check
|
||||
* @returns true if error is retryable
|
||||
*/
|
||||
export function isRetryableError(error: Error): boolean {
|
||||
return isNetworkError(error) || isRetryableHttpError(error);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ const localStorageMock = {
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isValidNamespace,
|
||||
validatePEMCertificate,
|
||||
validateSecretKey,
|
||||
validateSecretName,
|
||||
@@ -80,27 +79,6 @@ describe('validators', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateNamespace', () => {
|
||||
it('should accept valid namespace names', () => {
|
||||
expect(isValidNamespace('default')).toBe(true);
|
||||
expect(isValidNamespace('kube-system')).toBe(true);
|
||||
expect(isValidNamespace('my-namespace')).toBe(true);
|
||||
expect(isValidNamespace('ns-123')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid namespace names', () => {
|
||||
expect(isValidNamespace('')).toBe(false);
|
||||
expect(isValidNamespace('My-Namespace')).toBe(false);
|
||||
expect(isValidNamespace('-namespace')).toBe(false);
|
||||
expect(isValidNamespace('namespace-')).toBe(false);
|
||||
expect(isValidNamespace('namespace_name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject namespaces exceeding 253 characters', () => {
|
||||
expect(isValidNamespace('x'.repeat(254))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSecretKey', () => {
|
||||
it('should accept valid secret keys', () => {
|
||||
expect(validateSecretKey('password').valid).toBe(true);
|
||||
|
||||
+3
-96
@@ -5,51 +5,6 @@
|
||||
* and runtime type checking for SealedSecret objects.
|
||||
*/
|
||||
|
||||
import { SealedSecretInterface, SealedSecretScope } from '../types';
|
||||
import { SealedSecret } from './SealedSecretCRD';
|
||||
|
||||
/**
|
||||
* Runtime type guard for SealedSecret
|
||||
*
|
||||
* @param obj Object to check
|
||||
* @returns true if obj is a SealedSecret instance
|
||||
*/
|
||||
export function isSealedSecret(obj: any): obj is SealedSecret {
|
||||
return (
|
||||
obj instanceof SealedSecret &&
|
||||
obj.jsonData &&
|
||||
'spec' in obj.jsonData &&
|
||||
'encryptedData' in obj.jsonData.spec
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SealedSecret structure
|
||||
*
|
||||
* @param obj Object to validate
|
||||
* @returns true if obj has valid SealedSecret structure
|
||||
*/
|
||||
export function validateSealedSecretInterface(obj: any): obj is SealedSecretInterface {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
'spec' in obj &&
|
||||
typeof obj.spec === 'object' &&
|
||||
'encryptedData' in obj.spec &&
|
||||
typeof obj.spec.encryptedData === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate scope value
|
||||
*
|
||||
* @param value Value to check
|
||||
* @returns true if value is a valid SealedSecretScope
|
||||
*/
|
||||
export function isSealedSecretScope(value: any): value is SealedSecretScope {
|
||||
return ['strict', 'namespace-wide', 'cluster-wide'].includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Kubernetes resource name
|
||||
*
|
||||
@@ -61,7 +16,7 @@ export function isSealedSecretScope(value: any): value is SealedSecretScope {
|
||||
* @param name Name to validate
|
||||
* @returns true if valid Kubernetes resource name
|
||||
*/
|
||||
export function isValidK8sName(name: string): boolean {
|
||||
function isValidK8sName(name: string): boolean {
|
||||
if (!name || name.length === 0 || name.length > 253) {
|
||||
return false;
|
||||
}
|
||||
@@ -76,7 +31,7 @@ export function isValidK8sName(name: string): boolean {
|
||||
* @param key Key to validate
|
||||
* @returns true if valid Kubernetes key
|
||||
*/
|
||||
export function isValidK8sKey(key: string): boolean {
|
||||
function isValidK8sKey(key: string): boolean {
|
||||
if (!key || key.length === 0 || key.length > 253) {
|
||||
return false;
|
||||
}
|
||||
@@ -93,7 +48,7 @@ export function isValidK8sKey(key: string): boolean {
|
||||
* @param value String to validate
|
||||
* @returns true if valid PEM format
|
||||
*/
|
||||
export function isValidPEM(value: string): boolean {
|
||||
function isValidPEM(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
@@ -103,28 +58,6 @@ export function isValidPEM(value: string): boolean {
|
||||
return pemRegex.test(value.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a value is not empty
|
||||
*
|
||||
* @param value Value to check
|
||||
* @returns true if value is non-empty string
|
||||
*/
|
||||
export function isNonEmpty(value: string): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate namespace name
|
||||
*
|
||||
* Same rules as resource names
|
||||
*
|
||||
* @param namespace Namespace to validate
|
||||
* @returns true if valid namespace name
|
||||
*/
|
||||
export function isValidNamespace(namespace: string): boolean {
|
||||
return isValidK8sName(namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result with error message
|
||||
*/
|
||||
@@ -223,29 +156,3 @@ export function validatePEMCertificate(pem: string): ValidationResult {
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate plugin configuration
|
||||
*
|
||||
* @param config Configuration to validate
|
||||
* @returns Validation result with error message if invalid
|
||||
*/
|
||||
export function validatePluginConfig(config: {
|
||||
controllerName?: string;
|
||||
controllerNamespace?: string;
|
||||
controllerPort?: number;
|
||||
}): ValidationResult {
|
||||
if (!config.controllerName || !isValidK8sName(config.controllerName)) {
|
||||
return { valid: false, error: 'Invalid controller name' };
|
||||
}
|
||||
|
||||
if (!config.controllerNamespace || !isValidNamespace(config.controllerNamespace)) {
|
||||
return { valid: false, error: 'Invalid controller namespace' };
|
||||
}
|
||||
|
||||
if (!config.controllerPort || config.controllerPort < 1 || config.controllerPort > 65535) {
|
||||
return { valid: false, error: 'Invalid controller port (must be 1-65535)' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
+1
-42
@@ -75,17 +75,6 @@ export function PlaintextValue(value: string): PlaintextValue {
|
||||
return value as PlaintextValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded encrypted value
|
||||
* This is typically used by encryption functions
|
||||
*
|
||||
* @example
|
||||
* return Ok(EncryptedValue(encryptedString));
|
||||
*/
|
||||
export function EncryptedValue(value: string): EncryptedValue {
|
||||
return value as EncryptedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a branded base64 string
|
||||
*
|
||||
@@ -106,17 +95,6 @@ export function PEMCertificate(value: string): PEMCertificate {
|
||||
return value as PEMCertificate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a branded type to get the raw string
|
||||
* Use sparingly - only when you need the raw value
|
||||
*
|
||||
* @example
|
||||
* const rawValue = unwrap(plaintextValue);
|
||||
*/
|
||||
export function unwrap<T extends string>(value: T): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a success result
|
||||
*
|
||||
@@ -196,7 +174,7 @@ export interface SealedSecretSpec {
|
||||
/**
|
||||
* SealedSecret status condition
|
||||
*/
|
||||
export interface SealedSecretCondition {
|
||||
interface SealedSecretCondition {
|
||||
type: string;
|
||||
status: 'True' | 'False' | 'Unknown';
|
||||
lastTransitionTime?: string;
|
||||
@@ -233,15 +211,6 @@ export interface PluginConfig {
|
||||
controllerPort: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default plugin configuration
|
||||
*/
|
||||
export const DEFAULT_CONFIG: PluginConfig = {
|
||||
controllerName: 'sealed-secrets-controller',
|
||||
controllerNamespace: 'kube-system',
|
||||
controllerPort: 8080,
|
||||
};
|
||||
|
||||
/**
|
||||
* Key-value pair for encryption dialog
|
||||
*/
|
||||
@@ -250,16 +219,6 @@ export interface SecretKeyValue {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encryption request parameters
|
||||
*/
|
||||
export interface EncryptionRequest {
|
||||
name: string;
|
||||
namespace: string;
|
||||
scope: SealedSecretScope;
|
||||
keyValues: SecretKeyValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificate information extracted from PEM certificate
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@kinvolk/headlamp-plugin/config/plugins-tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "vite-plugin-svgr/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"test"',
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user