Merge pull request #2 from Keeper-Security/release/v1.0.0

Release/v1.0.0
This commit is contained in:
pkamble-ks
2026-02-13 22:39:31 +05:30
committed by GitHub
11 changed files with 1747 additions and 1 deletions
+32
View File
@@ -0,0 +1,32 @@
# Dependencies - we install fresh in Docker
node_modules
npm-debug.log
yarn-error.log
# Git files - not needed in image
.git
.gitignore
.gitattributes
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
# Environment files
.env
.env.local
.env.*.local
# Build artifacts
dist/
build/
*.log
# Temporary files
tmp/
temp/
*.tmp
+296
View File
@@ -0,0 +1,296 @@
# Harness CI Keeper Plugin - Release Workflow
#
# HOW TO PUBLISH A NEW RELEASE:
# 1. Update the version in package.json (e.g., from 1.0.0 to 1.0.1)
# 2. Commit your changes: git commit -am "Bump version to 1.0.1"
# 3. Create a git tag matching the version: git tag v1.0.1
# 4. Push the tag to GitHub: git push origin v1.0.1
# 5. The workflow will automatically:
# - Run linting
# - Run tests
# - Build the Docker image
# - Generate SBOM
# - Create a GitHub release
#
# Note: This workflow triggers on version tags (v*.*.*) or manual workflow dispatch
name: Publish Harness CI Keeper Plugin
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*' # Triggers on any version tag (v1.0.0, v1.0.1, v2.0.0, etc.) — no manual update per release
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run tests
run: npm run test
build:
runs-on: ubuntu-latest
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}"
- name: Check for Docker Hub credentials
id: docker_creds
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
if [ -n "${DOCKERHUB_USERNAME}" ]; then
echo "HAS_CREDENTIALS=true" >> $GITHUB_OUTPUT
else
echo "HAS_CREDENTIALS=false" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub (if credentials provided)
if: steps.docker_creds.outputs.HAS_CREDENTIALS == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set Docker image tags
id: docker_tags
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
if [ -n "${DOCKERHUB_USERNAME}" ]; then
IMAGE_TAG="${DOCKERHUB_USERNAME}/harness-keeper-plugin:${{ steps.get_version.outputs.version }}"
LATEST_TAG="${DOCKERHUB_USERNAME}/harness-keeper-plugin:latest"
else
IMAGE_TAG="harness-keeper-plugin:${{ steps.get_version.outputs.version }}"
LATEST_TAG="harness-keeper-plugin:latest"
fi
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_OUTPUT
echo "LATEST_TAG=${LATEST_TAG}" >> $GITHUB_OUTPUT
echo "Image tag: ${IMAGE_TAG}"
echo "Latest tag: ${LATEST_TAG}"
- name: Build Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/') }}
tags: |
${{ steps.docker_tags.outputs.IMAGE_TAG }}
${{ steps.docker_tags.outputs.LATEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: harness-keeper-plugin-build
path: |
Dockerfile
package.json
retention-days: 30
generate-sbom:
runs-on: ubuntu-latest
needs: build
steps:
- name: Get the source code
uses: actions/checkout@v4
- name: Install Syft
run: |
echo "Installing Syft v1.18.1..."
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /tmp/bin v1.18.1
echo "/tmp/bin" >> $GITHUB_PATH
- name: Install Manifest CLI
run: |
echo "Installing Manifest CLI v0.18.3..."
curl -sSfL https://raw.githubusercontent.com/manifest-cyber/cli/main/install.sh | sh -s -- -b /tmp/bin v0.18.3
- name: Create Syft configuration
run: |
cat > syft-config.yaml << 'EOF'
package:
search:
scope: all-layers
cataloger:
enabled: true
java:
enabled: false
python:
enabled: false
nodejs:
enabled: true
EOF
- name: Generate and upload SBOM
env:
MANIFEST_API_KEY: ${{ secrets.MANIFEST_TOKEN }}
run: |
# Get version from package.json
echo "Detecting Harness CI Keeper Plugin version..."
if [ -f "package.json" ]; then
VERSION=$(grep -o '"version": "[^"]*"' "package.json" | cut -d'"' -f4)
echo "Detected version: ${VERSION}"
else
VERSION="1.0.0"
echo "Could not detect version, using default: ${VERSION}"
fi
echo "Generating SBOM with Manifest CLI..."
/tmp/bin/manifest sbom "." \
--generator=syft \
--name=harness-keeper-plugin \
--version=${VERSION} \
--output=spdx-json \
--file=harness-keeper-plugin-sbom.json \
--api-key=${MANIFEST_API_KEY} \
--publish=true \
--asset-label=application,sbom-generated,nodejs,harness-plugin,docker \
--generator-config=syft-config.yaml
echo "SBOM generated and uploaded successfully: harness-keeper-plugin-sbom.json"
echo "---------- SBOM Preview (first 20 lines) ----------"
head -n 20 harness-keeper-plugin-sbom.json
# Docker registry publish job - reserved for future use
# publish-docker-registry:
# runs-on: ubuntu-latest
# environment: prod
# needs: [test, build, generate-sbom]
#
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
#
# - name: Get version
# id: get_version
# run: |
# VERSION=$(node -p "require('./package.json').version")
# echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
#
# - name: Log in to Docker Registry
# uses: docker/login-action@v3
# with:
# registry: ${{ secrets.DOCKER_REGISTRY }}
# username: ${{ secrets.DOCKER_USERNAME }}
# password: ${{ secrets.DOCKER_PASSWORD }}
#
# - name: Build and push Docker image
# uses: docker/build-push-action@v5
# with:
# context: .
# push: true
# tags: |
# ${{ secrets.DOCKER_REGISTRY }}/harness-keeper-plugin:${{ steps.get_version.outputs.version }}
# ${{ secrets.DOCKER_REGISTRY }}/harness-keeper-plugin:latest
create-release:
runs-on: ubuntu-latest
needs: [test, build, generate-sbom]
# Only run when triggered by a tag push
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Version: ${VERSION}"
- name: Set Docker image name for release
id: docker_image
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
run: |
if [ -n "${DOCKERHUB_USERNAME}" ]; then
IMAGE_NAME="${DOCKERHUB_USERNAME}/harness-keeper-plugin:${{ steps.get_version.outputs.version }}"
else
IMAGE_NAME="harness-keeper-plugin:${{ steps.get_version.outputs.version }}"
fi
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
body: |
## Harness CI Keeper Plugin ${{ github.ref_name }}
### Installation
#### Docker Image:
The Docker image for this release is available at:
```
${{ steps.docker_image.outputs.IMAGE_NAME }}
```
#### Usage in Harness CI:
```yaml
- step:
type: Plugin
name: Fetch_Keeper_Secrets
identifier: Fetch_Keeper_Secrets
spec:
image: ${{ steps.docker_image.outputs.IMAGE_NAME }}
settings:
secrets: |
VeYTRo-PHElAwfQT6f0TIA/field/password > DB_PASSWORD
VeYTRo-PHElAwfQT6f0TIA/field/login > DB_USERNAME
envVariables:
KSM_CONFIG: <+secrets.getValue("Keeper_Config_Secret")>
```
### What's Changed
See the [full changelog](https://github.com/${{ github.repository }}/compare/previous-tag...${{ github.ref_name }})
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+23
View File
@@ -0,0 +1,23 @@
# 1. Use a slim Node.js image to keep the plugin fast and small
# Note: As of January, 2026, when building this image, it uses Node.js version 25.2.1
# The 'node:slim' tag will automatically use the latest Node.js version every time the image is built
FROM node:slim
# 2. Set the working directory inside the container
WORKDIR /app
# 3. Copy package files first (to leverage Docker caching for faster builds)
COPY package*.json ./
# 4. Install production dependencies only
RUN npm install --omit=dev
# 5. Copy the rest of your source code
COPY . .
# 6. Ensure the entrypoint script is executable (important for Linux runners)
RUN chmod +x /app/entrypoint.sh
# 7. Set the Entrypoint
# This tells Docker to run the shell script first, which then calls your Node logic
ENTRYPOINT ["/app/entrypoint.sh"]
+216 -1
View File
@@ -1 +1,216 @@
# harness-integration
# Harness CI Keeper Plugin
Keeper Secrets Manager integration into Harness CI for dynamic secrets retrieval
## Overview
This plugin securely retrieves secrets from Keeper Security Secrets Manager and makes them available to subsequent steps in your Harness pipeline. The plugin implements a zero-knowledge architecture where secrets are retrieved directly from Keeper Vault at runtime and never pass through Harness systems in decrypted form.
## Features
- Retrieve secrets from the Keeper Vault within the Harness CI pipeline
- Support for standard fields, custom fields, and file attachments
- Zero-knowledge architecture - secrets retrieved directly from Keeper at runtime
All secret flow happens strictly between the plugin container and Keeper, while Harness provides orchestration, governance, and secure secret injection across the pipeline.
## Prerequisites
- Keeper Secrets Manager access with Application configured
- Harness CI account with project setup
- KSM configuration (one-time access token `US:...` or Base64-encoded token or JSON config)
## About
The plugin retrieves secrets from Keeper Secrets Manager and stores them in **Harness pipeline volume storage** at `/harness/secrets/` directory for reliable file-based access by subsequent steps.
**Important:** Secrets are stored in `/harness/secrets/` directory (Harness shared workspace volume), not as Harness environment variables.
All secrets are:
- **Pipeline-scoped**: Only accessible during the current pipeline execution
- **Automatically cleaned up**: Harness CI removes all secrets after pipeline completion
- **Securely stored**: Files have restricted permissions (600 - owner read/write only)
## Quick Start
```yaml
pipeline:
name: harness_keeper_plugin
identifier: harness_keeper_plugin
projectIdentifier: default_project
orgIdentifier: default
stages:
- stage:
name: HkpCI
identifier: HkpCI
type: CI
spec:
cloneCodebase: false
platform:
os: Linux
arch: Amd64
runtime:
type: Cloud
spec: {}
execution:
steps:
- step:
type: Plugin
name: Fetch_Keeper_Secrets
identifier: Fetch_Keeper_Secrets
spec:
image: dhborse/keeper-harness-plugin
settings:
secrets: |
RECORD_UID/field/password > PASSWORD
RECORD_UID/field/login > USERNAME
envVariables:
KSM_CONFIG: <+secrets.getValue("keeper_base64_secret")>
- step:
type: Run
name: Use_Secrets
identifier: Use_Secrets
spec:
image: alpine:3.20
shell: Sh
command: |
if [ -f /harness/secrets/USERNAME ] && [ -f /harness/secrets/PASSWORD ]; then
USERNAME=$(cat /harness/secrets/USERNAME)
PASSWORD=$(cat /harness/secrets/PASSWORD)
echo "Username: $USERNAME"
echo "Password retrieved successfully"
else
echo "Error: Secret files not found"
exit 1
fi
```
## Inputs
### KSM_CONFIG
Keeper Secrets Manager configuration for authentication. Store in Harness secrets and reference:
```yaml
envVariables:
KSM_CONFIG: <+secrets.getValue("Keeper_Config_Secret")>
```
**Supported Formats:**
- One-time access token: `US:xxxxx`
- JSON configuration: `{"hostname": "...", "clientId": "...", "privateKey": "..."}`
- Base64-encoded token or JSON config
### Secrets
Keeper Notation queries mapping secrets to destinations:
**Format:** `<keeper-notation> > <destination>`
**Example:**
```yaml
secrets: |
RECORD_UID/field/password > PASSWORD
RECORD_UID/field/login > USERNAME
```
Replace `RECORD_UID` with the actual Record UID from your Keeper Vault.
## Keeper Notation Format
The plugin supports three types of Keeper Notation queries:
| Type | Format | Example |
|------|--------|---------|
| **Standard Fields** | `<record-uid>/field/<field-type>` | `VeYTRo.../field/password` |
| **Custom Fields** | `<record-uid>/custom_field/<field-label>` | `VeYTRo.../custom_field/API_Key` |
| **File Attachments** | `<record-uid>/file/<file-name>` | `VeYTRo.../file/credentials.txt` |
**Note:** Record UID is the unique identifier for a secret record in Keeper. Get it from Keeper Vault → Record details → Record UID.
## Destination Format
The destination defines where the secret is stored:
| Format | Description | Output Location |
|--------|-------------|----------------|
| `VARIABLE_NAME` | Default output (recommended) | `/harness/secrets/VARIABLE_NAME` |
**Example:**
```yaml
# Saves to /harness/secrets/PASSWORD and /harness/secrets/USERNAME
RECORD_UID/field/password > PASSWORD
RECORD_UID/field/login > USERNAME
```
## Accessing Secrets
Secrets are stored in `/harness/secrets/` directory. Read them in subsequent steps:
```yaml
- step:
type: Run
name: Use_Secrets
spec:
image: alpine:3.20
shell: Sh
command: |
USERNAME=$(cat /harness/secrets/USERNAME)
PASSWORD=$(cat /harness/secrets/PASSWORD)
# Use secrets in your build/deploy process
```
**Benefits:**
- Most reliable method
- No truncation issues
- Works with all secret types including binary files
- Supports long values and special characters
## Secret Storage
- **Location:** `/harness/secrets/` directory (Harness shared workspace volume)
- **Permissions:** `600` (owner read/write only)
- **Scope:** Pipeline execution only - automatically cleaned up after completion
- **Access:** Read files directly using `cat` or file operations
The `envVariables` section is **only** used to pass `KSM_CONFIG` to the plugin. Actual secrets are written to files in `/harness/secrets/` directory.
## Security
- **Zero-Knowledge**: Secrets retrieved directly from Keeper Vault at runtime
- **No Decrypted Storage**: Secrets never pass through Harness systems in decrypted form
- **Pipeline-Scoped**: All secrets scoped to current pipeline execution
- **Automatic Cleanup**: Harness CI removes secrets after pipeline completion
- **File Permissions**: Files have `600` permissions (owner read/write only)
### Best Practices
1. Use one-time access tokens instead of permanent credentials
2. Base64 encode tokens before storing in Harness secrets
3. Clean up secret files after use in pipeline steps
4. Use appropriate secret scope (project/org/account)
5. Verify Record UIDs before configuring pipelines
## Troubleshooting
| Error | Solution |
|-------|----------|
| `KSM config is required` | Verify secret exists and expression `<+secrets.getValue("SECRET_NAME")>` is correct |
| `Invalid token format` | Check token starts with `US:` or `{` for JSON config |
| `Value not found for notation` | Verify Record UID, field name (case-sensitive), and Application permissions |
| `Failed to download file` | Check file exists in record, name matches exactly, and Application has file access permissions |
| `Harness expression not resolved` | Verify secret reference expression and secret scope (project/org/account) |
## Version History
- **1.0.0** - Initial release
- Zero-knowledge architecture implementation
- Support for one-time tokens and JSON config
- Keeper Notation support (field, custom_field, file)
- File-based secret access
- Base64 token decoding
- Secure file permissions and cleanup
## References
- [Keeper Notation Documentation](https://docs.keeper.io/en/keeperpam/secrets-manager/about/keeper-notation)
- [Keeper Secrets Manager Quick Start Guide](https://docs.keeper.io/en/keeperpam/secrets-manager/quick-start-guide)
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -e
# Run Node.js script (writes directly to /harness/secrets/ files; no stdout — safe even if entrypoint is bypassed)
node /app/src/index.js
# Hand over control to the Docker command (if any)
exec "$@"
+43
View File
@@ -0,0 +1,43 @@
const js = require('@eslint/js');
const globals = require('globals');
module.exports = [
js.configs.recommended,
{
languageOptions: {
ecmaVersion: 2021,
sourceType: 'script',
globals: {
...globals.node
}
},
rules: {
'no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'no-console': 'off',
'indent': ['error', 4],
'quotes': ['error', 'single'],
'semi': ['error', 'always']
}
},
{
// Jest test files configuration
files: ['**/*.test.js', '**/__tests__/**/*.js'],
languageOptions: {
globals: {
...globals.jest
}
}
},
{
ignores: [
'node_modules/**',
'*.vsix',
'dist/**',
'build/**',
'.git/**'
]
}
];
+20
View File
@@ -0,0 +1,20 @@
module.exports = {
testEnvironment: 'node',
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js',
],
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
testMatch: [
'**/__tests__/**/*.js',
'**/*.test.js',
],
verbose: true,
};
+26
View File
@@ -0,0 +1,26 @@
{
"name": "harness_keeper_plugin",
"version": "1.0.0",
"private": true,
"description": "",
"main": "index.js",
"scripts": {
"test": "jest --coverage",
"test:watch": "jest --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@keeper-security/secrets-manager-core": "^17.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"globals": "^17.0.0",
"jest": "^29.7.0"
}
}
+155
View File
@@ -0,0 +1,155 @@
const {
getSecrets,
getValue,
localConfigStorage,
downloadFile,
initializeStorage
} = require('@keeper-security/secrets-manager-core');
const fs = require('fs');
const path = require('path');
const KSM_CONFIG_PATH = '/app/ksm-config.json';
const REQUIRED_FIELDS = ['hostname', 'clientId', 'privateKey'];
const core = {
getMultilineInput: (name) => {
const val = process.env[`PLUGIN_${name.toUpperCase()}`] || '';
return val.split('\n').filter(line => line.trim() !== '');
},
info: (msg) => process.stderr.write(`INFO: ${msg}\n`),
error: (msg) => process.stderr.write(`::error::${msg}\n`),
warning: (msg) => process.stderr.write(`::warning::${msg}\n`)
};
const splitInput = (text) => {
const n = text.lastIndexOf('>');
return n < 0 ? [text, ''] : [text.substring(0, n).trim(), text.substring(n + 1).trim()];
};
const processToken = (rawToken) => {
if (!rawToken) {
core.error('KSM config is required');
core.error('Set KSM_CONFIG environment variable');
process.exit(1);
}
let token = rawToken.trim();
if (token.includes('${') || token.includes('<+') || token.includes('ngSecretManager')) {
core.error('Harness expression not resolved');
process.exit(1);
}
// Decode base64 if needed
if (!token.startsWith('US:') && !token.startsWith('{')) {
try {
token = Buffer.from(token, 'base64').toString('utf-8').trim();
} catch (e) {
// Not base64, use as-is
core.warning('Base64 decode failed, using raw value');
}
}
token = token.trim();
if (!token) {
core.error('Token is null or empty');
process.exit(1);
}
const isConfigJson = token.startsWith('{');
if (isConfigJson) {
try {
const config = JSON.parse(token);
const missing = REQUIRED_FIELDS.filter(f => !config[f]);
if (missing.length) {
core.error(`Missing required fields: ${missing.join(', ')}`);
process.exit(1);
}
return { token, isConfigJson: true, config };
} catch (e) {
core.error(`Invalid JSON config: ${e.message}`);
process.exit(1);
}
}
if (!token.startsWith('US:')) {
core.error('Invalid token format - expected US:xxxxx or JSON config');
process.exit(1);
}
return { token, isConfigJson: false, config: null };
};
const parseSecretMappings = () => {
return core.getMultilineInput('secrets').map(line => {
const [notation, destRaw] = splitInput(line);
return { notation, destination: destRaw, destinationType: 'output' };
});
};
const setupStorage = async (token, isConfigJson, config) => {
if (isConfigJson) {
fs.writeFileSync(KSM_CONFIG_PATH, JSON.stringify(config), 'utf8');
return localConfigStorage(KSM_CONFIG_PATH);
}
const storage = localConfigStorage(KSM_CONFIG_PATH);
await initializeStorage(storage, token);
return storage;
};
const runPlugin = async () => {
try {
core.info('Starting Keeper Secrets Manager plugin');
fs.mkdirSync('/app', { recursive: true });
const { token, isConfigJson, config } = processToken(process.env.KSM_CONFIG);
const inputs = parseSecretMappings();
const storage = await setupStorage(token, isConfigJson, config);
const secrets = await getSecrets({ storage });
for (const input of inputs) {
const secret = getValue(secrets, input.notation);
if (!secret) {
core.warning(`Value not found for notation: ${input.notation}`);
continue;
}
const notationIsFile = input.notation.includes('/file/');
const isFileReference = typeof secret === 'object' &&
secret !== null &&
(secret.fileId || secret.fileUid || secret.url || notationIsFile);
let data;
if (isFileReference) {
try {
const fileData = await downloadFile(secret);
data = Buffer.isBuffer(fileData) ? fileData :
fileData instanceof Uint8Array ? Buffer.from(fileData) :
Buffer.from(fileData);
} catch (downloadError) {
core.error(`Failed to download file for notation ${input.notation}: ${downloadError.message}`);
continue;
}
} else {
data = Buffer.isBuffer(secret) ? secret :
typeof secret === 'string' ? Buffer.from(secret, 'utf8') :
Buffer.from(String(secret), 'utf8');
}
fs.mkdirSync('/harness/secrets', { recursive: true });
const secretFilePath = path.join('/harness/secrets', input.destination);
fs.writeFileSync(secretFilePath, data);
fs.chmodSync(secretFilePath, 0o600);
}
} catch (error) {
core.error(`Failed: ${error.message}`);
if (error.message.includes('token') || error.message.includes('invalid') || error.message.includes('expired')) {
core.error('One-time access tokens are single-use - generate a new token');
}
process.exit(1);
}
};
runPlugin();
+926
View File
@@ -0,0 +1,926 @@
// Mock all external dependencies before requiring the module
jest.mock('@keeper-security/secrets-manager-core');
jest.mock('fs');
jest.mock('path');
const fs = require('fs');
const path = require('path');
describe('index.js - Complete Test Suite', () => {
let getSecrets, getValue, localConfigStorage, downloadFile, initializeStorage;
let mockExit, mockStderrWrite, mockConsoleLog;
let originalEnv;
// Helper to wait for async operations to complete
const waitForAsync = async () => {
// Wait multiple ticks to ensure all async operations complete
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setImmediate(resolve));
}
// Also wait a small timeout to ensure all promises resolve
await new Promise(resolve => setTimeout(resolve, 10));
};
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
jest.resetModules();
// Save original environment
originalEnv = { ...process.env };
// Mock process methods
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
mockStderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => {});
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
// Setup mocks for Keeper Secrets Manager
getSecrets = jest.fn();
getValue = jest.fn();
localConfigStorage = jest.fn();
downloadFile = jest.fn();
initializeStorage = jest.fn();
const secretsManagerCore = require('@keeper-security/secrets-manager-core');
secretsManagerCore.getSecrets = getSecrets;
secretsManagerCore.getValue = getValue;
secretsManagerCore.localConfigStorage = localConfigStorage;
secretsManagerCore.downloadFile = downloadFile;
secretsManagerCore.initializeStorage = initializeStorage;
// Setup fs mocks - need to get fresh references after resetModules
const fsModule = require('fs');
fs.mkdirSync = jest.fn();
fs.writeFileSync = jest.fn();
fs.chmodSync = jest.fn();
// Also set on the module itself
fsModule.mkdirSync = fs.mkdirSync;
fsModule.writeFileSync = fs.writeFileSync;
fsModule.chmodSync = fs.chmodSync;
// Setup path mocks
const pathModule = require('path');
path.resolve = jest.fn((p) => `/resolved/${p}`);
path.dirname = jest.fn((p) => {
const parts = p.split('/');
parts.pop();
return parts.join('/') || '/';
});
path.join = jest.fn((...args) => args.join('/'));
pathModule.resolve = path.resolve;
pathModule.dirname = path.dirname;
pathModule.join = path.join;
// Clear environment variables
delete process.env.KSM_CONFIG;
delete process.env.PLUGIN_SECRETS;
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
});
afterAll(() => {
mockExit?.mockRestore();
mockStderrWrite?.mockRestore();
mockConsoleLog?.mockRestore();
});
describe('processToken - Error Cases', () => {
test('should exit when rawToken is null', () => {
delete process.env.KSM_CONFIG;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('KSM config is required'));
});
test('should exit when rawToken is undefined', () => {
delete process.env.KSM_CONFIG;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when rawToken is empty string', () => {
process.env.KSM_CONFIG = '';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when token contains ${ Harness expression', () => {
process.env.KSM_CONFIG = '${harness.expression}';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Harness expression not resolved'));
});
test('should exit when token contains <+ Harness expression', () => {
process.env.KSM_CONFIG = '<+expression>';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when token contains ngSecretManager', () => {
process.env.KSM_CONFIG = 'ngSecretManager.token';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when token is empty after trim', () => {
process.env.KSM_CONFIG = ' ';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Token is null or empty'));
});
test('should exit when JSON config is missing hostname field', () => {
const jsonConfig = JSON.stringify({ clientId: 'client', privateKey: 'key' });
process.env.KSM_CONFIG = jsonConfig;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Missing required fields'));
});
test('should exit when JSON config is missing clientId field', () => {
const jsonConfig = JSON.stringify({ hostname: 'test.com', privateKey: 'key' });
process.env.KSM_CONFIG = jsonConfig;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when JSON config is missing privateKey field', () => {
const jsonConfig = JSON.stringify({ hostname: 'test.com', clientId: 'client' });
process.env.KSM_CONFIG = jsonConfig;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when JSON config is missing multiple fields', () => {
const jsonConfig = JSON.stringify({ hostname: 'test.com' });
process.env.KSM_CONFIG = jsonConfig;
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should exit when JSON config is invalid', () => {
process.env.KSM_CONFIG = '{invalid json}';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Invalid JSON config'));
});
test('should exit when token format is invalid (not US: or JSON)', () => {
process.env.KSM_CONFIG = 'INVALID:token';
require('../index');
expect(mockExit).toHaveBeenCalledWith(1);
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Invalid token format'));
});
});
describe('processToken - Success Cases', () => {
test('should decode base64 token successfully', async () => {
const base64Token = Buffer.from('US:decoded-token').toString('base64');
process.env.KSM_CONFIG = base64Token;
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(initializeStorage).toHaveBeenCalled();
});
test('should handle base64 decode error gracefully', async () => {
process.env.KSM_CONFIG = 'invalid-base64!@#';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
// Should continue execution despite decode error
});
test('should parse valid JSON config', async () => {
const jsonConfig = JSON.stringify({ hostname: 'test.com', clientId: 'client', privateKey: 'key' });
process.env.KSM_CONFIG = jsonConfig;
process.env.PLUGIN_SECRETS = 'notation>output';
const mockStorage = {};
localConfigStorage.mockReturnValue(mockStorage);
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalledWith('/app/ksm-config.json', jsonConfig, 'utf8');
expect(localConfigStorage).toHaveBeenCalledWith('/app/ksm-config.json');
});
test('should accept US: token format', async () => {
process.env.KSM_CONFIG = 'US:test-token';
process.env.PLUGIN_SECRETS = 'notation>output';
const mockStorage = {};
localConfigStorage.mockReturnValue(mockStorage);
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(initializeStorage).toHaveBeenCalledWith(mockStorage, 'US:test-token');
});
test('should handle token with whitespace', async () => {
process.env.KSM_CONFIG = ' US:test-token ';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(initializeStorage).toHaveBeenCalledWith(expect.anything(), 'US:test-token');
});
test('should call info function on plugin start', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
// Verify info was called with the startup message
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('INFO: Starting Keeper Secrets Manager plugin'));
});
test('should log base64 decode skip to stderr (no secret on stdout)', () => {
// Base64 decode failure is logged to stderr only, never stdout — avoids leaking to Docker logs
const originalBufferFrom = Buffer.from;
Buffer.from = jest.fn(() => {
throw new Error('Base64 decode error');
});
process.env.KSM_CONFIG = 'not-us-format-token';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Base64 decode failed, using raw value'));
Buffer.from = originalBufferFrom;
});
});
describe('parseSecretMappings', () => {
test('should treat env: prefix as literal destination name (no special parsing)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalledWith('/harness/secrets/env:VAR_NAME', expect.any(Buffer));
});
test('should parse file: destination type', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should parse output destination type (default)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle input without > separator', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle multiple > characters', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>part1>destination';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
});
test('should handle multiple secret mappings', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation1>env:VAR1\nnotation2>file:/path/file\nnotation3>output1';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(getValue).toHaveBeenCalledTimes(3);
});
test('should filter empty lines from multiline input', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation1>output1\n\nnotation2>output2\n \nnotation3>output3';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(getValue).toHaveBeenCalledTimes(3);
});
test('should handle empty secrets input', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = '';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
require('../index');
await waitForAsync();
expect(getValue).not.toHaveBeenCalled();
});
});
describe('runPlugin - File Handling', () => {
test('should create /app directory', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
const mockStorage = {};
localConfigStorage.mockReturnValue(mockStorage);
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
// Verify the async chain executed
expect(initializeStorage).toHaveBeenCalled();
expect(getSecrets).toHaveBeenCalled();
// Check that mkdirSync was called for /app (first call in runPlugin)
expect(fs.mkdirSync).toHaveBeenCalledWith('/app', { recursive: true });
});
test('should treat file: prefix as literal destination (writes to /harness/secrets/)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('file-content');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalledWith('/harness/secrets/file:/path/to/file.txt', expect.any(Buffer));
});
test('should create /harness/secrets for any destination', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('file-content');
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true });
});
test('should handle file secret with fileId', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
downloadFile.mockResolvedValue(Buffer.from('file-data'));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalledWith({ fileId: 'file123' });
});
test('should handle file secret with fileUid', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileUid: 'file123' });
downloadFile.mockResolvedValue(new Uint8Array([1, 2, 3]));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
});
test('should handle file secret with url', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ url: 'https://example.com/file' });
downloadFile.mockResolvedValue(Buffer.from('file-data'));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
});
test('should handle file secret with /file/ notation', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'record/field/file/file.txt>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
// Return an object so isFileReference check passes
// The notation includes '/file/' which will make notationIsFile true
getValue.mockReturnValue({ someProperty: 'value' });
downloadFile.mockResolvedValue(Buffer.from('file-data'));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
});
test('should handle downloadFile error gracefully', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
downloadFile.mockRejectedValue(new Error('Download failed'));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed to download file'));
});
test('should handle Uint8Array file data', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
const uint8Array = new Uint8Array([1, 2, 3, 4]);
downloadFile.mockResolvedValue(uint8Array);
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle Buffer file data', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
downloadFile.mockResolvedValue(Buffer.from('buffer-data'));
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle other file data types', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
// Return something that's not Uint8Array or Buffer to test the third branch
downloadFile.mockResolvedValue('string-data');
path.dirname.mockReturnValue('/path/to');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle file data that is neither Uint8Array nor Buffer (line 135 branch)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
// Return something that's not Uint8Array or Buffer to test the third branch of ternary
// Use an array to ensure it's not Uint8Array (regular array)
downloadFile.mockResolvedValue([1, 2, 3]);
path.dirname.mockReturnValue('/path/to');
path.resolve.mockReturnValue('/path/to/file.txt');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle file data that is Buffer (line 134 branch)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
// Return a Buffer (not Uint8Array) to test the Buffer.isBuffer branch
// Note: In Node.js, Buffer IS a Uint8Array, so this tests the first branch
const bufferData = Buffer.from('buffer-content');
downloadFile.mockResolvedValue(bufferData);
path.dirname.mockReturnValue('/path/to');
path.resolve.mockReturnValue('/resolved/path/to/file.txt');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalledWith(expect.stringContaining('file.txt'), bufferData);
});
test('should handle file data that is plain Uint8Array (not Buffer)', async () => {
// Test the second branch: Buffer.isBuffer is false but instanceof Uint8Array is true
// Use a plain Uint8Array (not a Buffer) to test the middle branch of the ternary
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>file:/path/to/file.txt';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
// Create a plain Uint8Array (not a Buffer)
// Buffer.isBuffer will return false, but instanceof Uint8Array will be true
const uint8Array = new Uint8Array([1, 2, 3, 4, 5]);
downloadFile.mockResolvedValue(uint8Array);
path.dirname.mockReturnValue('/path/to');
path.resolve.mockReturnValue('/resolved/path/to/file.txt');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
// Should convert Uint8Array to Buffer
const writeCall = fs.writeFileSync.mock.calls.find(call => call[0].includes('file.txt'));
expect(writeCall).toBeDefined();
});
});
describe('runPlugin - Secret Value Handling', () => {
test('should handle string secret value', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('string-secret');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle Buffer secret value', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(Buffer.from('buffer-secret'));
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle non-string, non-buffer secret value', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(12345);
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should skip secret when value is not found', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(null);
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::'));
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Value not found'));
});
test('should skip secret when value is undefined', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(undefined);
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::'));
});
});
describe('runPlugin - Destination Types', () => {
test('should handle env-prefixed destination as literal (writes to /harness/secrets/)', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('env-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalledWith('/harness/secrets/env:VAR_NAME', expect.any(Buffer));
expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true });
expect(fs.chmodSync).toHaveBeenCalledWith('/harness/secrets/env:VAR_NAME', 0o600);
});
test('should handle env-prefixed destination with Buffer data', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(Buffer.from('buffer-env-value'));
require('../index');
await waitForAsync();
const call = fs.writeFileSync.mock.calls.find(c => c[0] === '/harness/secrets/env:VAR_NAME');
expect(call).toBeDefined();
expect(call[1].toString('utf8')).toBe('buffer-env-value');
});
test('should handle env-prefixed destination with non-Buffer data', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(12345);
require('../index');
await waitForAsync();
const call = fs.writeFileSync.mock.calls.find(c => c[0] === '/harness/secrets/env:VAR_NAME');
expect(call).toBeDefined();
expect(call[1].toString('utf8')).toBe('12345');
});
test('should create /harness/secrets directory for non-file destinations', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true });
});
test('should set file permissions to 0o600 for secret files', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.chmodSync).toHaveBeenCalledWith('/harness/secrets/output_var', 0o600);
});
test('should write only to /harness/secrets for output destinations', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output_var';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalledWith('/harness/secrets/output_var', expect.any(Buffer));
});
});
describe('runPlugin - Error Handling', () => {
test('should handle storage initialization error', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockRejectedValue(new Error('Storage initialization failed'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:'));
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should handle token-related error with specific message', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockRejectedValue(new Error('Invalid token'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens'));
});
test('should handle expired token error', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockRejectedValue(new Error('Token expired'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens'));
});
test('should handle invalid token error', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockRejectedValue(new Error('invalid'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('One-time access tokens'));
});
test('should handle getSecrets error', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockRejectedValue(new Error('Failed to get secrets'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:'));
expect(mockExit).toHaveBeenCalledWith(1);
});
test('should handle generic error without token message', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockRejectedValue(new Error('Generic error'));
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('Failed:'));
expect(mockStderrWrite).not.toHaveBeenCalledWith(expect.stringContaining('One-time access tokens'));
});
});
describe('Edge Cases and Integration', () => {
test('should handle null secret object', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue(null);
require('../index');
await waitForAsync();
expect(mockStderrWrite).toHaveBeenCalledWith(expect.stringContaining('::warning::'));
});
test('should handle object secret without file properties', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ someProperty: 'value' });
require('../index');
await waitForAsync();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle object secret without file properties and notation without /file/', async () => {
// Test the branch where typeof secret === 'object' && secret !== null is true
// but (secret.fileId || secret.fileUid || secret.url || notationIsFile) is false
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'regular/notation>output';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
// Return an object without fileId, fileUid, url, and notation doesn't include '/file/'
getValue.mockReturnValue({ regularProperty: 'value', anotherProp: 123 });
require('../index');
await waitForAsync();
// Should treat it as a regular secret, not a file reference
expect(downloadFile).not.toHaveBeenCalled();
expect(fs.writeFileSync).toHaveBeenCalled();
});
test('should handle complex multiline input with mixed types', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'notation1>env:VAR1\nnotation2>file:/path/file\nnotation3>output1\nnotation4>env:VAR2';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue('secret-value');
require('../index');
await waitForAsync();
expect(getValue).toHaveBeenCalledTimes(4);
});
test('should handle file notation with multiple file references', async () => {
process.env.KSM_CONFIG = 'US:test';
process.env.PLUGIN_SECRETS = 'record1/field/file/file1.txt>file:/path/file1\nrecord2/field/file/file2.txt>file:/path/file2';
localConfigStorage.mockReturnValue({});
initializeStorage.mockResolvedValue();
getSecrets.mockResolvedValue({});
getValue.mockReturnValue({ fileId: 'file123' });
downloadFile.mockResolvedValue(Buffer.from('file-data'));
path.dirname.mockReturnValue('/path');
require('../index');
await waitForAsync();
expect(downloadFile).toHaveBeenCalledTimes(2);
});
});
});