From a07e4af2287cd41b9ce671c1ec757e0e5f4fc005 Mon Sep 17 00:00:00 2001 From: Jacob Wolf Date: Sun, 23 Mar 2025 13:42:14 -0500 Subject: [PATCH 1/2] feat: adds no-fs version where file system is not supported --- .secrets/.secrets.enc.js | 3 + __mocks__/.secrets.enc.js | 3 + __mocks__/fs.cjs | 2 + __mocks__/fs/promises.cjs | 2 + __mocks__/secrets.js | 5 + biome.json | 2 +- package.json | 1 + pnpm-lock.yaml | 79 +++++++++++++ src/index.ts | 14 ++- src/no-fs.ts | 14 +++ src/secrets-files.ts | 101 ++++++++++++++++ src/secrets.ts | 19 ++- tests/providers/doppler.test.ts | 203 ++++++++++++++++---------------- tests/secrets-files.test.ts | 162 +++++++++++++++++++++++++ 14 files changed, 503 insertions(+), 107 deletions(-) create mode 100644 .secrets/.secrets.enc.js create mode 100644 __mocks__/.secrets.enc.js create mode 100644 __mocks__/fs.cjs create mode 100644 __mocks__/fs/promises.cjs create mode 100644 __mocks__/secrets.js create mode 100644 src/no-fs.ts create mode 100644 src/secrets-files.ts create mode 100644 tests/secrets-files.test.ts diff --git a/.secrets/.secrets.enc.js b/.secrets/.secrets.enc.js new file mode 100644 index 0000000..1ca7ca6 --- /dev/null +++ b/.secrets/.secrets.enc.js @@ -0,0 +1,3 @@ +This file was auto-generated by @jacobwolf/gitops-secrets +const CIPHER_TEXT = undefined; +module.exports = { CIPHER_TEXT }; \ No newline at end of file diff --git a/__mocks__/.secrets.enc.js b/__mocks__/.secrets.enc.js new file mode 100644 index 0000000..6e836e6 --- /dev/null +++ b/__mocks__/.secrets.enc.js @@ -0,0 +1,3 @@ +module.exports = { + loadSecrets: () => ({ key: 'loaded-value' }), +}; diff --git a/__mocks__/fs.cjs b/__mocks__/fs.cjs new file mode 100644 index 0000000..722adc3 --- /dev/null +++ b/__mocks__/fs.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs'); +module.exports = { ...fs, default: fs }; diff --git a/__mocks__/fs/promises.cjs b/__mocks__/fs/promises.cjs new file mode 100644 index 0000000..5ae90be --- /dev/null +++ b/__mocks__/fs/promises.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs'); +module.exports = fs.promises; diff --git a/__mocks__/secrets.js b/__mocks__/secrets.js new file mode 100644 index 0000000..8b6e14a --- /dev/null +++ b/__mocks__/secrets.js @@ -0,0 +1,5 @@ +module.exports = { + encrypt: vi.fn().mockResolvedValue('encrypted-content'), + decrypt: vi.fn().mockResolvedValue('{"key":"decrypted-value"}'), + mergeSecrets: vi.fn(), +}; diff --git a/biome.json b/biome.json index 380fd9e..c00d736 100644 --- a/biome.json +++ b/biome.json @@ -8,7 +8,7 @@ }, "files": { "ignoreUnknown": false, - "include": ["src/**/*"] + "include": ["src/**/*", "tests/**/*", "__mocks__/**/*"] }, "formatter": { "enabled": true, diff --git a/package.json b/package.json index 1ac0be9..f88b912 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/node": "^22.13.11", "husky": "^9.1.7", "lint-staged": "^15.5.0", + "memfs": "^4.17.0", "msw": "^2.7.3", "tsup": "^8.4.0", "typescript": "^5.8.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce82019..b59bcc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: lint-staged: specifier: ^15.5.0 version: 15.5.0 + memfs: + specifier: ^4.17.0 + version: 4.17.0 msw: specifier: ^2.7.3 version: 2.7.3(@types/node@22.13.11)(typescript@5.8.2) @@ -300,6 +303,24 @@ packages: '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.2.0': + resolution: {integrity: sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.5.0': + resolution: {integrity: sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@mswjs/interceptors@0.37.6': resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} engines: {node: '>=18'} @@ -677,6 +698,10 @@ packages: engines: {node: '>=18'} hasBin: true + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -746,6 +771,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + memfs@4.17.0: + resolution: {integrity: sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==} + engines: {node: '>= 4.0.0'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1001,6 +1030,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@1.21.0: + resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1034,6 +1069,12 @@ packages: tr46@1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + tree-dump@1.0.2: + resolution: {integrity: sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1041,6 +1082,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.4.0: resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} engines: {node: '>=18'} @@ -1386,6 +1430,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.2.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.5.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 1.21.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.5.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + '@mswjs/interceptors@0.37.6': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -1718,6 +1778,8 @@ snapshots: husky@9.1.7: {} + hyperdyperid@1.2.0: {} + is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@4.0.0: {} @@ -1790,6 +1852,13 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + memfs@4.17.0: + dependencies: + '@jsonjoy.com/json-pack': 1.2.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.5.0(tslib@2.8.1) + tree-dump: 1.0.2(tslib@2.8.1) + tslib: 2.8.1 + merge-stream@2.0.0: {} micromatch@4.0.8: @@ -2030,6 +2099,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@1.21.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -2060,10 +2133,16 @@ snapshots: dependencies: punycode: 2.3.1 + tree-dump@1.0.2(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} ts-interface-checker@0.1.13: {} + tslib@2.8.1: {} + tsup@8.4.0(postcss@8.5.3)(typescript@5.8.2)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) diff --git a/src/index.ts b/src/index.ts index 15dd2e2..75eee0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,11 @@ -async function main() { - console.log('Hello, world!'); -} +import * as doppler from './providers/doppler'; +import * as secrets from './secrets'; +import * as secretsFiles from './secrets-files'; -main(); +export const providers = { doppler }; + +export default { + ...secrets, + ...secretsFiles, + providers, +}; diff --git a/src/no-fs.ts b/src/no-fs.ts new file mode 100644 index 0000000..4f6a8d1 --- /dev/null +++ b/src/no-fs.ts @@ -0,0 +1,14 @@ +import * as doppler from './providers/doppler'; +import * as secrets from './secrets'; + +const noFs = { + ...secrets, + providers: { + doppler, + }, +}; + +export default noFs; + +export * from './secrets'; +export const providers = { doppler }; diff --git a/src/secrets-files.ts b/src/secrets-files.ts new file mode 100644 index 0000000..19e4c78 --- /dev/null +++ b/src/secrets-files.ts @@ -0,0 +1,101 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as secrets from './secrets'; +import { EnvTarget } from './types'; + +const SECRETS_FOLDER = path.join(__dirname, '../.secrets'); +const DEFAULT_JS_PATH = path.join(SECRETS_FOLDER, '.secrets.enc.js'); +const DEFAULT_JSON_PATH = path.join(SECRETS_FOLDER, '.secrets.enc.json'); + +if (!fs.existsSync(SECRETS_FOLDER)) { + fs.mkdirSync(SECRETS_FOLDER, { recursive: true }); +} + +/** + * Encapsulate encrypted secrets in a JS module for easy runtime access. + * Use {options.path} to output module locally for when package level storage or non-literal imports are disallowed. + * Use {options.cipherTextOnly} to limit the JS file to only exporting `CIPHER_TEXT`. + * @param {>} payload + * @param {{path: string | null, cipherTextOnly: boolean}} options + */ +// biome-ignore lint/suspicious/noExplicitAny: Want to keep this flexible +async function build(payload: Record, options = { path: null as string | null, cipherTextOnly: false }) { + const cipherText = await secrets.encrypt(JSON.stringify(payload)); + const filePath = options.path ? path.resolve(options.path) : DEFAULT_JS_PATH; + const packageType = process.env.npm_package_type === 'module' ? 'esm' : 'cjs'; + const format = filePath === DEFAULT_JS_PATH ? 'cjs' : packageType; + const lines = ['This file was auto-generated by @jacobwolf/gitops-secrets']; + + if (format === 'esm') { + if (!options.cipherTextOnly) { + lines.push(`import secrets from '@jacobwolf/gitops-secrets/no-fs';`); + lines.push(`const CIPHER_TEXT = ${JSON.stringify(cipherText)};`); + lines.push('const loadSecrets = () => secrets.loadSecretsFromCipher(CIPHER_TEXT);'); + lines.push('export { CIPHER_TEXT, loadSecrets };'); + } else { + lines.push(`const CIPHER_TEXT = ${JSON.stringify(cipherText)};`); + lines.push('export { CIPHER_TEXT };'); + } + } + + if (format === 'cjs') { + if (!options.cipherTextOnly) { + lines.push(`const secrets = require('@jacobwolf/gitops-secrets/no-fs');`); + lines.push(`const CIPHER_TEXT = ${JSON.stringify(cipherText)};`); + lines.push('const loadSecrets = () => secrets.loadSecretsFromCipher(CIPHER_TEXT);'); + lines.push('module.exports = { CIPHER_TEXT, loadSecrets };'); + } else { + lines.push(`const CIPHER_TEXT = ${JSON.stringify(cipherText)};`); + lines.push('module.exports = { CIPHER_TEXT };'); + } + } + + writeFile(filePath, lines.join('\n')); +} + +/** + * Encrypt JSON-serializable payload to a static file. + * @param {>} payload + * @param {{path: string | null}} [options={path: null}] + */ +// biome-ignore lint/suspicious/noExplicitAny: Want to keep this flexible +async function encryptToFile(payload: Record, options = { path: null as string | null }) { + const cipherText = await secrets.encrypt(JSON.stringify(payload)); + const filePath = options.path ? path.resolve(options.path) : DEFAULT_JSON_PATH; + writeFile(filePath, cipherText); +} + +/** + * Decrypt JSON payload to object with option to merge with process.env. + * @param {string} filePath + * @returns + */ +async function decryptFromFile(filePath: string) { + const newFilePath = filePath ? path.resolve(filePath) : DEFAULT_JSON_PATH; + + try { + const cipherText = fs.readFileSync(newFilePath, { encoding: 'utf-8' }); + const decryptedText = await secrets.decrypt(cipherText); + const payload = JSON.parse(decryptedText); + return { + ...payload, + mergeSecrets: () => secrets.mergeSecrets(payload, EnvTarget.PROCESS), + }; + } catch (error) { + throw new Error(`Failed to decrypt file ${newFilePath}: ${error}`); + } +} + +function writeFile(filePath: string, fileContents: string) { + try { + fs.writeFileSync(filePath, fileContents, { encoding: 'utf-8' }); + } catch (error) { + throw new Error(`Failed to write file ${filePath}: ${error}`); + } +} + +function loadSecrets() { + return require(DEFAULT_JS_PATH).loadSecrets(); +} + +export { build, encryptToFile, decryptFromFile, loadSecrets }; diff --git a/src/secrets.ts b/src/secrets.ts index 9d26741..e325342 100644 --- a/src/secrets.ts +++ b/src/secrets.ts @@ -123,6 +123,13 @@ async function decrypt(ciphertext: string): Promise { * @returns {EnvObject} - The environment object */ function getEnvObject(target: EnvTarget): EnvObject { + if (typeof import.meta === 'undefined') { + if (typeof process !== 'undefined' && process.env) { + return process.env; + } + throw new Error('process.env is not available in this environment'); + } + switch (target) { case EnvTarget.PROCESS: if (typeof process !== 'undefined' && process.env) { @@ -134,7 +141,6 @@ function getEnvObject(target: EnvTarget): EnvObject { return import.meta.env; } throw new Error('import.meta.env is not available in this environment'); - default: throw new Error(`Unsupported environment target: ${target}`); } @@ -150,10 +156,19 @@ function getEnvObject(target: EnvTarget): EnvObject { function mergeSecrets(payload: Record, target: EnvTarget): EnvObject { const envObject = getEnvObject(target); + if (typeof import.meta === 'undefined') { + if (typeof process !== 'undefined' && process.env) { + for (const [key, value] of Object.entries(payload)) { + process.env[key] = value; + } + } + return envObject; + } + for (const [key, value] of Object.entries(payload)) { if (target === EnvTarget.PROCESS && typeof process !== 'undefined') { process.env[key] = value; - } else if (target === EnvTarget.IMPORT_META) { + } else if (target === EnvTarget.IMPORT_META && typeof import.meta !== 'undefined') { import.meta.env[key] = value; } } diff --git a/tests/providers/doppler.test.ts b/tests/providers/doppler.test.ts index 4821d5c..824f1c7 100644 --- a/tests/providers/doppler.test.ts +++ b/tests/providers/doppler.test.ts @@ -1,12 +1,12 @@ -import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; import { fetchSecrets } from '../../src/providers/doppler'; const server = setupServer( - http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { - return HttpResponse.json({ MY_SECRET: 'test-value' }); - }) + http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { + return HttpResponse.json({ MY_SECRET: 'test-value' }); + }), ); beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); @@ -16,98 +16,101 @@ afterEach(() => server.resetHandlers()); afterAll(() => server.close()); describe('Doppler Provider', () => { - it('should fetch secrets successfully', async () => { - const secrets = await fetchSecrets({ - dopplerToken: 'test-token' - }); - - expect(secrets).toEqual({ MY_SECRET: 'test-value' }); - }); - - it('should include project and config when provided', async () => { - let projectParam: string | null = null; - let configParam: string | null = null; - let formatParam: string | null = null; - - server.use( - http.get('https://api.doppler.com/v3/configs/config/secrets/download', ({ request }) => { - const url = new URL(request.url); - projectParam = url.searchParams.get('project'); - configParam = url.searchParams.get('config'); - formatParam = url.searchParams.get('format'); - return HttpResponse.json({ PROJECT_SECRET: 'project-value' }); - }) - ); - - await fetchSecrets({ - dopplerToken: 'test-token', - dopplerProject: 'test-project', - dopplerConfig: 'test-config' - }); - - expect(projectParam).toBe('test-project'); - expect(configParam).toBe('test-config'); - expect(formatParam).toBe('json'); - }); - - it('should throw error when no token is provided', async () => { - await expect(fetchSecrets({ - dopplerToken: undefined - })).rejects.toThrow("Doppler API Error: The 'DOPPLER_TOKEN' environment variable is required"); - }); - - it('should handle API errors correctly', async () => { - server.use( - http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { - return new HttpResponse( - JSON.stringify({ - messages: ['Invalid authentication credentials'] - }), - { - status: 401, - headers: { 'Content-Type': 'application/json' } - } - ); - }) - ); - - await expect(fetchSecrets({ - dopplerToken: 'invalid-token' - })).rejects.toThrow('Doppler API Error: Doppler API Error: 401 Unauthorized'); - }); - - it('should handle API errors without messages', async () => { - server.use( - http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { - return new HttpResponse( - JSON.stringify({}), - { - status: 500, - headers: { 'Content-Type': 'application/json' } - } - ); - }) - ); - - await expect(fetchSecrets({ - dopplerToken: 'test-token' - })).rejects.toThrow('Doppler API Error: Doppler API Error: 500 Internal Server Error'); - }); - - it('should send correct authorization header', async () => { - let authHeader: string | null = null; - - server.use( - http.get('https://api.doppler.com/v3/configs/config/secrets/download', ({ request }) => { - authHeader = request.headers.get('authorization'); - return HttpResponse.json({ AUTH_TEST: 'success' }); - }) - ); - - await fetchSecrets({ - dopplerToken: 'test-token-123' - }); - - expect(authHeader).toBe('Bearer test-token-123'); - }); -}); \ No newline at end of file + it('should fetch secrets successfully', async () => { + const secrets = await fetchSecrets({ + dopplerToken: 'test-token', + }); + + expect(secrets).toEqual({ MY_SECRET: 'test-value' }); + }); + + it('should include project and config when provided', async () => { + let projectParam: string | null = null; + let configParam: string | null = null; + let formatParam: string | null = null; + + server.use( + http.get('https://api.doppler.com/v3/configs/config/secrets/download', ({ request }) => { + const url = new URL(request.url); + projectParam = url.searchParams.get('project'); + configParam = url.searchParams.get('config'); + formatParam = url.searchParams.get('format'); + return HttpResponse.json({ PROJECT_SECRET: 'project-value' }); + }), + ); + + await fetchSecrets({ + dopplerToken: 'test-token', + dopplerProject: 'test-project', + dopplerConfig: 'test-config', + }); + + expect(projectParam).toBe('test-project'); + expect(configParam).toBe('test-config'); + expect(formatParam).toBe('json'); + }); + + it('should throw error when no token is provided', async () => { + await expect( + fetchSecrets({ + dopplerToken: undefined, + }), + ).rejects.toThrow("Doppler API Error: The 'DOPPLER_TOKEN' environment variable is required"); + }); + + it('should handle API errors correctly', async () => { + server.use( + http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { + return new HttpResponse( + JSON.stringify({ + messages: ['Invalid authentication credentials'], + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }), + ); + + await expect( + fetchSecrets({ + dopplerToken: 'invalid-token', + }), + ).rejects.toThrow('Doppler API Error: Doppler API Error: 401 Unauthorized'); + }); + + it('should handle API errors without messages', async () => { + server.use( + http.get('https://api.doppler.com/v3/configs/config/secrets/download', () => { + return new HttpResponse(JSON.stringify({}), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + }), + ); + + await expect( + fetchSecrets({ + dopplerToken: 'test-token', + }), + ).rejects.toThrow('Doppler API Error: Doppler API Error: 500 Internal Server Error'); + }); + + it('should send correct authorization header', async () => { + let authHeader: string | null = null; + + server.use( + http.get('https://api.doppler.com/v3/configs/config/secrets/download', ({ request }) => { + authHeader = request.headers.get('authorization'); + return HttpResponse.json({ AUTH_TEST: 'success' }); + }), + ); + + await fetchSecrets({ + dopplerToken: 'test-token-123', + }); + + expect(authHeader).toBe('Bearer test-token-123'); + }); +}); diff --git a/tests/secrets-files.test.ts b/tests/secrets-files.test.ts new file mode 100644 index 0000000..55b8857 --- /dev/null +++ b/tests/secrets-files.test.ts @@ -0,0 +1,162 @@ +import path from 'node:path'; +import { fs as memfs, vol } from 'memfs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as secretsModule from '../src/secrets'; +import * as secretsFilesModule from '../src/secrets-files'; + +vi.mock('../src/secrets', () => ({ + encrypt: vi.fn(), + decrypt: vi.fn(), + mergeSecrets: vi.fn(), +})); + +vi.mock('node:fs', () => { + return { + ...memfs, + default: memfs, + __esModule: true, + }; +}); + +vi.mock('../.secrets/.secrets.enc.js', () => ({ + loadSecrets: vi.fn().mockReturnValue({ + API_KEY: 'test-api-key', + SECRET_TOKEN: 'test-secret-token', + }), +})); + +describe('secrets-files', () => { + const SECRETS_FOLDER = path.join(__dirname, '../.secrets'); + const DEFAULT_JS_PATH = path.join(SECRETS_FOLDER, '.secrets.enc.js'); + const DEFAULT_JSON_PATH = path.join(SECRETS_FOLDER, '.secrets.enc.json'); + + const testPayload = { + API_KEY: 'test-api-key', + SECRET_TOKEN: 'test-secret-token', + }; + + const mockCipherText = 'encrypted-data-mock'; + + beforeEach(() => { + vol.reset(); + vol.mkdirSync(SECRETS_FOLDER, { recursive: true }); + + vi.mocked(secretsModule.encrypt).mockResolvedValue(mockCipherText); + vi.mocked(secretsModule.decrypt).mockResolvedValue(JSON.stringify(testPayload)); + + vol.writeFileSync( + DEFAULT_JS_PATH, + ` + module.exports = { + CIPHER_TEXT: "${mockCipherText}", + loadSecrets: () => ({ API_KEY: 'test-api-key', SECRET_TOKEN: 'test-secret-token' }) + } + `, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('build', () => { + it('should create a CJS module with full exports by default', async () => { + await secretsFilesModule.build(testPayload); + + expect(vol.existsSync(DEFAULT_JS_PATH)).toBe(true); + + const fileContent = vol.readFileSync(DEFAULT_JS_PATH, 'utf-8'); + expect(fileContent).toContain('const secrets = require'); + expect(fileContent).toContain(`const CIPHER_TEXT = "${mockCipherText}"`); + expect(fileContent).toContain('const loadSecrets = () =>'); + expect(fileContent).toContain('module.exports = { CIPHER_TEXT, loadSecrets }'); + }); + + it('should create a CJS module with cipher text only when specified', async () => { + await secretsFilesModule.build(testPayload, { path: null, cipherTextOnly: true }); + + const fileContent = vol.readFileSync(DEFAULT_JS_PATH, 'utf-8'); + expect(fileContent).toContain(`const CIPHER_TEXT = "${mockCipherText}"`); + expect(fileContent).toContain('module.exports = { CIPHER_TEXT }'); + expect(fileContent).not.toContain('const loadSecrets'); + }); + + it('should create a file at custom path when specified', async () => { + const customPath = path.join(SECRETS_FOLDER, 'custom.js'); + await secretsFilesModule.build(testPayload, { path: customPath, cipherTextOnly: false }); + + expect(vol.existsSync(customPath)).toBe(true); + }); + + it('should format as ESM when file extension is .js and package type is module', async () => { + const originalEnv = process.env.npm_package_type; + process.env.npm_package_type = 'module'; + + const customPath = path.join(SECRETS_FOLDER, 'module.js'); + await secretsFilesModule.build(testPayload, { path: customPath, cipherTextOnly: false }); + + const fileContent = vol.readFileSync(customPath, 'utf-8'); + expect(fileContent).toContain('import secrets from'); + expect(fileContent).toContain('export { CIPHER_TEXT, loadSecrets }'); + + process.env.npm_package_type = originalEnv; + }); + }); + + describe('encryptToFile', () => { + it('should encrypt payload and write to default file path', async () => { + await secretsFilesModule.encryptToFile(testPayload); + + expect(vol.existsSync(DEFAULT_JSON_PATH)).toBe(true); + expect(vol.readFileSync(DEFAULT_JSON_PATH, 'utf-8')).toBe(mockCipherText); + expect(secretsModule.encrypt).toHaveBeenCalledWith(JSON.stringify(testPayload)); + }); + + it('should write to custom path when specified', async () => { + const customPath = path.join(SECRETS_FOLDER, 'custom.json'); + await secretsFilesModule.encryptToFile(testPayload, { path: customPath }); + + expect(vol.existsSync(customPath)).toBe(true); + expect(vol.readFileSync(customPath, 'utf-8')).toBe(mockCipherText); + }); + }); + + describe('decryptFromFile', () => { + it('should read, decrypt and parse file contents from default path', async () => { + vol.writeFileSync(DEFAULT_JSON_PATH, mockCipherText); + + const result = await secretsFilesModule.decryptFromFile(DEFAULT_JSON_PATH); + + expect(result).toEqual({ + ...testPayload, + mergeSecrets: expect.any(Function), + }); + expect(secretsModule.decrypt).toHaveBeenCalledWith(mockCipherText); + }); + + it('should throw error when file cannot be read or decrypted', async () => { + await expect(secretsFilesModule.decryptFromFile('non-existent-path')).rejects.toThrow(); + }); + + it('should call mergeSecrets when mergeSecrets() is called', async () => { + vol.writeFileSync(DEFAULT_JSON_PATH, mockCipherText); + + const result = await secretsFilesModule.decryptFromFile(DEFAULT_JSON_PATH); + result.mergeSecrets(); + + expect(secretsModule.mergeSecrets).toHaveBeenCalledWith(testPayload, expect.anything()); + }); + }); + + describe('loadSecrets', () => { + it('should call loadSecrets from imported module', () => { + const loadSecretsSpy = vi.spyOn(secretsFilesModule, 'loadSecrets').mockImplementation(() => testPayload); + + const result = secretsFilesModule.loadSecrets(); + expect(result).toEqual(testPayload); + expect(loadSecretsSpy).toHaveBeenCalled(); + + loadSecretsSpy.mockRestore(); + }); + }); +}); From 6bb681ec7bf490da18e8244628ee6748ad8952a1 Mon Sep 17 00:00:00 2001 From: Jacob Wolf Date: Sun, 23 Mar 2025 13:42:37 -0500 Subject: [PATCH 2/2] chore: formatted secrets.test.ts --- tests/secrets.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/secrets.test.ts b/tests/secrets.test.ts index d2629ee..683066c 100644 --- a/tests/secrets.test.ts +++ b/tests/secrets.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { encrypt, decrypt, mergeSecrets, loadSecrets } from '../src/secrets'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { decrypt, encrypt, loadSecrets, mergeSecrets } from '../src/secrets'; import { EnvTarget } from '../src/types'; process.env.GITOPS_SECRETS_MASTER_KEY = 'test-master-key-16chars+';