diff --git a/.gitignore b/.gitignore index 40b878d..ccb2c80 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules/ \ No newline at end of file +node_modules/ +package-lock.json \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index c59e5db..835164e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -9,12 +9,7 @@ SECRETS_FILE=$(mktemp) node /app/src/index.js > "$SECRETS_FILE" # 3. Securely process the secrets -# Parse lines in format: ENV:VAR_NAME='value', OUT:VAR_NAME='value', or VAR_NAME='value' -# # SECURITY NOTE: All secrets are written to /harness/outputs/ and /harness/secrets/ -# These directories are mounted volumes scoped ONLY to the current pipeline execution. -# Harness CI automatically cleans up these volumes after pipeline completion. -# Secrets are NOT accessible outside the pipeline execution context. mkdir -p /harness/outputs /harness/secrets while IFS= read -r line; do @@ -53,9 +48,6 @@ while IFS= read -r line; do export "$name=$value" # Write to Harness CI Plugin Output (for output variables) - # These are scoped to the pipeline execution and accessible via: - # <+step.output.outputVariables.VAR_NAME> in subsequent steps - # Format: KEY=VALUE (one per line) printf "%s=%s\n" "$name" "$value" >> /harness/outputs/outputs.txt # For environment variables, also write to env_vars.txt for Harness to pick up @@ -65,28 +57,13 @@ while IFS= read -r line; do fi # Write to file for direct access (bypasses Harness truncation) - # SECURITY: Files in /harness/secrets/ are scoped to pipeline execution only - # Harness CI automatically cleans up these files after pipeline completion echo -n "$value" > "/harness/secrets/${name}" chmod 600 "/harness/secrets/${name}" # Restrict permissions to owner only - # Debug: Log (removed to reduce log noise) - # value_length=${#value} - # if [ "$type" = "env" ]; then - # echo "INFO: Set environment variable: $name (length: $value_length)" - # else - # echo "INFO: Set output variable: $name (length: $value_length)" - # fi - # echo "INFO: Secret also written to /harness/secrets/${name} for direct file access" - done < "$SECRETS_FILE" # 4. Secure Clean up -# Remove the temporary file to ensure no sensitive data remains on disk -# Note: /harness/outputs/ and /harness/secrets/ are cleaned up by Harness CI -# after pipeline execution completes - they are scoped to the pipeline only rm -f "$SECRETS_FILE" # 5. Hand over control to the Docker command (if any) -# This allows the container to be used as a wrapper for other commands exec "$@" \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..2dbe9f9 --- /dev/null +++ b/jest.config.js @@ -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, +}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fbf4a80..0000000 --- a/package-lock.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "harness_keeper_plugin", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "harness_keeper_plugin", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@keeper-security/secrets-manager-core": "^17.3.0" - } - }, - "node_modules/@keeper-security/secrets-manager-core": { - "version": "17.3.0", - "resolved": "https://registry.npmjs.org/@keeper-security/secrets-manager-core/-/secrets-manager-core-17.3.0.tgz", - "integrity": "sha512-9Wm5s4qbJJlOFmB1L1p2+TqWLdMg/IQMcKEXKw2ZTOQlHjGzECrX2HoeCEdCG8datt/n7uo7f84uby4GvJyOXA==", - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index c40ccaf..bae7134 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jest --coverage", + "test:watch": "jest --watch" }, "keywords": [], "author": "", @@ -13,5 +14,8 @@ "type": "commonjs", "dependencies": { "@keeper-security/secrets-manager-core": "^17.3.0" + }, + "devDependencies": { + "jest": "^29.7.0" } } diff --git a/src/index.js b/src/index.js index 4284310..3af32c4 100644 --- a/src/index.js +++ b/src/index.js @@ -106,6 +106,7 @@ const setupStorage = async (token, isConfigJson, config) => { 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); @@ -130,8 +131,9 @@ const runPlugin = async () => { if (isFileReference) { try { const fileData = await downloadFile(secret); - data = fileData instanceof Uint8Array ? Buffer.from(fileData) : - Buffer.isBuffer(fileData) ? fileData : Buffer.from(fileData); + 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; @@ -153,7 +155,7 @@ const runPlugin = async () => { fs.chmodSync(secretFilePath, 0o600); if (input.destinationType === 'environment') { - const outputValue = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + const outputValue = data.toString('utf8'); console.log(`ENV:${input.destination}='${outputValue}'`); } } diff --git a/src/test/index.test.js b/src/test/index.test.js new file mode 100644 index 0000000..f6fc89c --- /dev/null +++ b/src/test/index.test.js @@ -0,0 +1,929 @@ +// 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 test console.log in base64 decode catch block', () => { + // This test ensures line 49 (console.log(e)) is covered + // Buffer.from doesn't throw for invalid base64, so we need to mock it to throw + 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'); + // The console.log in catch block should have been called + expect(mockConsoleLog).toHaveBeenCalled(); + + // Restore Buffer.from + Buffer.from = originalBufferFrom; + }); + }); + + describe('parseSecretMappings', () => { + test('should parse env: destination type', 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(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='secret-value'"); + }); + + 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 handle file destination type', 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(path.resolve).toHaveBeenCalledWith('/path/to/file.txt'); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + test('should create directory for file 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('/path/to', { 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 environment destination type', 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(mockConsoleLog).toHaveBeenCalledWith("ENV:VAR_NAME='env-value'"); + expect(fs.mkdirSync).toHaveBeenCalledWith('/harness/secrets', { recursive: true }); + expect(fs.writeFileSync).toHaveBeenCalled(); + expect(fs.chmodSync).toHaveBeenCalled(); + }); + + test('should handle environment 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(); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); + }); + + test('should handle environment destination with non-Buffer data (line 157 branch)', async () => { + process.env.KSM_CONFIG = 'US:test'; + process.env.PLUGIN_SECRETS = 'notation>env:VAR_NAME'; + localConfigStorage.mockReturnValue({}); + initializeStorage.mockResolvedValue(); + getSecrets.mockResolvedValue({}); + // Return a number to test the String(data) branch + getValue.mockReturnValue(12345); + + require('../index'); + await waitForAsync(); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("ENV:VAR_NAME")); + expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining("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 not output ENV: for non-environment 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(mockConsoleLog).not.toHaveBeenCalledWith(expect.stringContaining('ENV:')); + }); + }); + + 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); + }); + }); +});