From 9eee5c6e3e574cc985751e3c5b3508d44e1781b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:58:19 +0000 Subject: [PATCH 1/2] refactor(secrets): consume @quantum-l9/infisical-config (delete inline loader) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pillar 4: swap the inline src/core/secrets.ts onto the shared, published @quantum-l9/infisical-config package — the same consolidation pattern used for @quantum-l9/llm-router (one place to change how every service loads secrets). - index.ts + migrate.ts import loadSecrets from '@quantum-l9/infisical-config' and pass the pino module logger. - Delete src/core/secrets.ts and tests/core/secrets.test.ts (now owned + tested in the package). - Drop the direct @infisical/sdk dependency (the package brings it transitively). Validated locally against the package build (file link): tsc=0, vitest 24/24. NOTE: do not merge until @quantum-l9/infisical-config@1.0.0 is published to GitHub Packages and the repo has read access — CI install depends on it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VqWfuXWTn5jo8f5fnkSozx --- package.json | 2 +- src/core/database/migrate.ts | 4 +- src/core/secrets.ts | 142 ----------------------------------- src/index.ts | 4 +- tests/core/secrets.test.ts | 128 ------------------------------- 5 files changed, 5 insertions(+), 275 deletions(-) delete mode 100644 src/core/secrets.ts delete mode 100644 tests/core/secrets.test.ts diff --git a/package.json b/package.json index 7031230..db85c1d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@quantum-l9/llm-router": "^1.0.0", - "@infisical/sdk": "^5.0.2", + "@quantum-l9/infisical-config": "^1.0.0", "@fastify/cors": "^9.0.0", "@fastify/helmet": "^11.1.1", "@fastify/formbody": "^7.4.0", diff --git a/src/core/database/migrate.ts b/src/core/database/migrate.ts index aa19178..55f6f7f 100644 --- a/src/core/database/migrate.ts +++ b/src/core/database/migrate.ts @@ -14,14 +14,14 @@ import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { getDb, closeDb } from './index.js'; import { createModuleLogger } from '../logger.js'; -import { loadSecrets } from '../secrets.js'; +import { loadSecrets } from '@quantum-l9/infisical-config'; const logger = createModuleLogger('migrate'); async function runMigrations() { // Hydrate process.env from Infisical so `npm run migrate` works on a VPS // with no committed .env (no-op when Infisical isn't configured). - await loadSecrets(); + await loadSecrets({ logger: createModuleLogger('secrets') }); logger.info('Starting database migrations...'); diff --git a/src/core/secrets.ts b/src/core/secrets.ts deleted file mode 100644 index f2085a5..0000000 --- a/src/core/secrets.ts +++ /dev/null @@ -1,142 +0,0 @@ -/* L9_META - * layer: module - * role: seo_bot_engine - * status: active - */ - -/** - * ═══════════════════════════════════════════════════════════════════════════════ - * L9 SEO Bot - Infisical Secret Loader - * - * Hydrates process.env from Infisical (https://infisical.com) so the bot can run - * autonomously on a VPS without a committed/synced .env file. Uses a machine - * identity (Universal Auth) — no human in the loop. - * - * Design goals: - * - OPTIONAL: if the bootstrap vars (INFISICAL_CLIENT_ID / _CLIENT_SECRET / - * _PROJECT_ID) are absent, this is a no-op and the bot falls back to - * .env / process.env exactly as before. Nothing breaks for local dev. - * - NON-DESTRUCTIVE: an Infisical secret never overwrites a variable that is - * already set in the environment, so an explicit shell/systemd export or a - * local .env always wins. Infisical only *backfills* what's missing. - * - FAIL-SOFT by default: a fetch/auth failure logs a warning and lets the bot - * continue on whatever env it already has. Set INFISICAL_REQUIRED=true to - * make Infisical a hard dependency that aborts boot on any failure. - * - * The @infisical/sdk dependency is imported lazily, so it is only resolved when - * Infisical is actually configured. - * - * Bootstrap env vars (see .env.example): - * INFISICAL_CLIENT_ID machine-identity client id (required to enable) - * INFISICAL_CLIENT_SECRET machine-identity client secret (required to enable) - * INFISICAL_PROJECT_ID project / workspace id (required to enable) - * INFISICAL_ENV environment slug (default: 'prod') - * INFISICAL_SECRET_PATH secret folder path (default: '/') - * INFISICAL_SITE_URL self-hosted instance URL (default: app.infisical.com) - * INFISICAL_RECURSIVE 'true' to pull nested folders too (default: false) - * INFISICAL_REQUIRED 'true' to abort boot if the load fails (default: false) - * ═══════════════════════════════════════════════════════════════════════════════ - */ - -import { createModuleLogger } from './logger.js'; - -const logger = createModuleLogger('secrets'); - -export interface LoadSecretsResult { - /** True only when secrets were successfully fetched from Infisical. */ - loaded: boolean; - /** Number of keys actually injected into process.env (missing keys only). */ - injected: number; - /** Where the effective config ultimately comes from. */ - source: 'infisical' | 'env'; -} - -/** Parse a loose boolean env var ('1' / 'true', case-insensitive). */ -function envFlag(value: string | undefined): boolean { - return value === '1' || value?.toLowerCase() === 'true'; -} - -/** - * Load secrets from Infisical into process.env. Safe to call exactly once, - * before configuration is validated (loadConfig / getConfig). - */ -export async function loadSecrets(): Promise { - const clientId = process.env.INFISICAL_CLIENT_ID; - const clientSecret = process.env.INFISICAL_CLIENT_SECRET; - const projectId = process.env.INFISICAL_PROJECT_ID; - const required = envFlag(process.env.INFISICAL_REQUIRED); - - // Not configured → no-op fallback to .env / process.env. - if (!clientId || !clientSecret || !projectId) { - if (required) { - throw new Error( - 'INFISICAL_REQUIRED=true but INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET ' + - 'and INFISICAL_PROJECT_ID are not all set.', - ); - } - // Surface a partial config: if SOME (but not all) bootstrap vars are set, - // Infisical is silently skipped — almost always a deploy misconfiguration, - // so warn rather than swallow it at debug level. - if (clientId || clientSecret || projectId) { - logger.warn( - { - hasClientId: Boolean(clientId), - hasClientSecret: Boolean(clientSecret), - hasProjectId: Boolean(projectId), - }, - 'Infisical partially configured — need INFISICAL_CLIENT_ID, ' + - 'INFISICAL_CLIENT_SECRET and INFISICAL_PROJECT_ID; skipping Infisical ' + - 'and using .env / process.env', - ); - } else { - logger.debug('Infisical not configured — using .env / process.env only'); - } - return { loaded: false, injected: 0, source: 'env' }; - } - - const environment = process.env.INFISICAL_ENV ?? 'prod'; - const secretPath = process.env.INFISICAL_SECRET_PATH ?? '/'; - const siteUrl = process.env.INFISICAL_SITE_URL; - const recursive = envFlag(process.env.INFISICAL_RECURSIVE); - - try { - // Lazy import: the SDK is only loaded when Infisical is configured. - const { InfisicalSDK } = await import('@infisical/sdk'); - const client = new InfisicalSDK(siteUrl ? { siteUrl } : {}); - - await client.auth().universalAuth.login({ clientId, clientSecret }); - - const { secrets } = await client.secrets().listSecrets({ - environment, - projectId, - secretPath, - recursive, - expandSecretReferences: true, - }); - - let injected = 0; - for (const secret of secrets) { - // Never clobber an already-set var: explicit env / .env wins. - if (process.env[secret.secretKey] === undefined) { - process.env[secret.secretKey] = secret.secretValue; - injected++; - } - } - - logger.info( - { environment, secretPath, fetched: secrets.length, injected }, - 'Loaded secrets from Infisical', - ); - return { loaded: true, injected, source: 'infisical' }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (required) { - throw new Error(`Infisical secret load failed (INFISICAL_REQUIRED=true): ${message}`); - } - logger.warn( - { error: message }, - 'Infisical secret load failed — continuing with .env / process.env', - ); - return { loaded: false, injected: 0, source: 'env' }; - } -} diff --git a/src/index.ts b/src/index.ts index 0420d7c..e1706c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ * ═══════════════════════════════════════════════════════════════════════════════ */ -import { loadSecrets } from './core/secrets.js'; +import { loadSecrets } from '@quantum-l9/infisical-config'; import { loadConfig } from './core/config.js'; import { createModuleLogger } from './core/logger.js'; import { closeDb } from './core/database/index.js'; @@ -29,7 +29,7 @@ const logger = createModuleLogger('main'); async function main() { // Hydrate process.env from Infisical before any config is read (no-op when // Infisical isn't configured; never overrides vars already set in the env). - await loadSecrets(); + await loadSecrets({ logger: createModuleLogger('secrets') }); const config = loadConfig(); logger.info('Configuration validated'); diff --git a/tests/core/secrets.test.ts b/tests/core/secrets.test.ts deleted file mode 100644 index 784a2c7..0000000 --- a/tests/core/secrets.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Chainable @infisical/sdk mock: -// new InfisicalSDK().auth().universalAuth.login(...) -// new InfisicalSDK().secrets().listSecrets(...) -const { ctorMock, loginMock, listSecretsMock } = vi.hoisted(() => { - const loginMock = vi.fn(); - const listSecretsMock = vi.fn(); - const ctorMock = vi.fn(() => ({ - auth: () => ({ universalAuth: { login: loginMock } }), - secrets: () => ({ listSecrets: listSecretsMock }), - })); - return { ctorMock, loginMock, listSecretsMock }; -}); - -vi.mock('@infisical/sdk', () => ({ InfisicalSDK: ctorMock })); - -vi.mock('../../src/core/logger.js', () => ({ - createModuleLogger: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }), -})); - -import { loadSecrets } from '../../src/core/secrets.js'; - -// Env keys this suite touches, cleared before each test for isolation. -const TOUCHED = [ - 'INFISICAL_CLIENT_ID', - 'INFISICAL_CLIENT_SECRET', - 'INFISICAL_PROJECT_ID', - 'INFISICAL_ENV', - 'INFISICAL_SECRET_PATH', - 'INFISICAL_REQUIRED', - 'INFISICAL_RECURSIVE', - 'INFISICAL_SITE_URL', - 'FETCHED_SECRET', - 'ALREADY_SET', -]; - -beforeEach(() => { - for (const k of TOUCHED) delete process.env[k]; - ctorMock.mockClear(); - loginMock.mockReset(); - listSecretsMock.mockReset(); -}); - -afterEach(() => { - for (const k of TOUCHED) delete process.env[k]; -}); - -function configure() { - process.env.INFISICAL_CLIENT_ID = 'cid'; - process.env.INFISICAL_CLIENT_SECRET = 'csecret'; - process.env.INFISICAL_PROJECT_ID = 'proj'; -} - -describe('loadSecrets', () => { - it('is a no-op when Infisical is not configured', async () => { - const result = await loadSecrets(); - expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); - expect(ctorMock).not.toHaveBeenCalled(); - expect(loginMock).not.toHaveBeenCalled(); - }); - - it('throws when INFISICAL_REQUIRED=true but bootstrap vars are missing', async () => { - process.env.INFISICAL_REQUIRED = 'true'; - await expect(loadSecrets()).rejects.toThrow(/INFISICAL_REQUIRED=true/); - expect(ctorMock).not.toHaveBeenCalled(); - }); - - it('no-ops (does not call the SDK) when only some bootstrap vars are set', async () => { - process.env.INFISICAL_CLIENT_ID = 'cid'; - // INFISICAL_CLIENT_SECRET and INFISICAL_PROJECT_ID intentionally missing - const result = await loadSecrets(); - expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); - expect(ctorMock).not.toHaveBeenCalled(); - expect(loginMock).not.toHaveBeenCalled(); - }); - - it('authenticates and backfills only missing keys into process.env', async () => { - configure(); - process.env.ALREADY_SET = 'from-env'; // must NOT be overwritten - listSecretsMock.mockResolvedValue({ - secrets: [ - { secretKey: 'FETCHED_SECRET', secretValue: 'from-infisical' }, - { secretKey: 'ALREADY_SET', secretValue: 'from-infisical' }, - ], - }); - - const result = await loadSecrets(); - - expect(loginMock).toHaveBeenCalledWith({ clientId: 'cid', clientSecret: 'csecret' }); - expect(listSecretsMock).toHaveBeenCalledWith( - expect.objectContaining({ environment: 'prod', projectId: 'proj', secretPath: '/' }), - ); - expect(process.env.FETCHED_SECRET).toBe('from-infisical'); // injected - expect(process.env.ALREADY_SET).toBe('from-env'); // preserved - expect(result).toEqual({ loaded: true, injected: 1, source: 'infisical' }); - }); - - it('honours INFISICAL_ENV / INFISICAL_SECRET_PATH overrides', async () => { - configure(); - process.env.INFISICAL_ENV = 'staging'; - process.env.INFISICAL_SECRET_PATH = '/seo-bot'; - listSecretsMock.mockResolvedValue({ secrets: [] }); - - await loadSecrets(); - - expect(listSecretsMock).toHaveBeenCalledWith( - expect.objectContaining({ environment: 'staging', secretPath: '/seo-bot' }), - ); - }); - - it('fails soft on fetch error when not required', async () => { - configure(); - listSecretsMock.mockRejectedValue(new Error('network down')); - - const result = await loadSecrets(); - - expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); - }); - - it('aborts on fetch error when INFISICAL_REQUIRED=true', async () => { - configure(); - process.env.INFISICAL_REQUIRED = 'true'; - listSecretsMock.mockRejectedValue(new Error('network down')); - - await expect(loadSecrets()).rejects.toThrow(/network down/); - }); -}); From 1eaa6de910d6373567703c901c42c8a2e94b20a1 Mon Sep 17 00:00:00 2001 From: Igor Beylin Date: Mon, 20 Jul 2026 22:53:23 +0000 Subject: [PATCH 2/2] Revert "refactor(secrets): consume @quantum-l9/infisical-config (delete inline loader)" @quantum-l9/infisical-config@1.0.0 is not published to GitHub Packages, so `npm install` 404s and CI stays red. Restore the local secrets loader path so the branch builds green again: - restore src/core/secrets.ts and tests/core/secrets.test.ts - src/index.ts and src/core/database/migrate.ts import loadSecrets locally again - package.json depends on @infisical/sdk again (drop @quantum-l9/infisical-config) Consuming the shared package is deferred until it is published; tracked in TODO. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N3wnMJdKGzHEdbR1nz5HNM --- package.json | 2 +- src/core/database/migrate.ts | 4 +- src/core/secrets.ts | 142 +++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- tests/core/secrets.test.ts | 128 +++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 5 deletions(-) create mode 100644 src/core/secrets.ts create mode 100644 tests/core/secrets.test.ts diff --git a/package.json b/package.json index db85c1d..7031230 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@quantum-l9/llm-router": "^1.0.0", - "@quantum-l9/infisical-config": "^1.0.0", + "@infisical/sdk": "^5.0.2", "@fastify/cors": "^9.0.0", "@fastify/helmet": "^11.1.1", "@fastify/formbody": "^7.4.0", diff --git a/src/core/database/migrate.ts b/src/core/database/migrate.ts index 55f6f7f..aa19178 100644 --- a/src/core/database/migrate.ts +++ b/src/core/database/migrate.ts @@ -14,14 +14,14 @@ import { migrate } from 'drizzle-orm/node-postgres/migrator'; import { getDb, closeDb } from './index.js'; import { createModuleLogger } from '../logger.js'; -import { loadSecrets } from '@quantum-l9/infisical-config'; +import { loadSecrets } from '../secrets.js'; const logger = createModuleLogger('migrate'); async function runMigrations() { // Hydrate process.env from Infisical so `npm run migrate` works on a VPS // with no committed .env (no-op when Infisical isn't configured). - await loadSecrets({ logger: createModuleLogger('secrets') }); + await loadSecrets(); logger.info('Starting database migrations...'); diff --git a/src/core/secrets.ts b/src/core/secrets.ts new file mode 100644 index 0000000..f2085a5 --- /dev/null +++ b/src/core/secrets.ts @@ -0,0 +1,142 @@ +/* L9_META + * layer: module + * role: seo_bot_engine + * status: active + */ + +/** + * ═══════════════════════════════════════════════════════════════════════════════ + * L9 SEO Bot - Infisical Secret Loader + * + * Hydrates process.env from Infisical (https://infisical.com) so the bot can run + * autonomously on a VPS without a committed/synced .env file. Uses a machine + * identity (Universal Auth) — no human in the loop. + * + * Design goals: + * - OPTIONAL: if the bootstrap vars (INFISICAL_CLIENT_ID / _CLIENT_SECRET / + * _PROJECT_ID) are absent, this is a no-op and the bot falls back to + * .env / process.env exactly as before. Nothing breaks for local dev. + * - NON-DESTRUCTIVE: an Infisical secret never overwrites a variable that is + * already set in the environment, so an explicit shell/systemd export or a + * local .env always wins. Infisical only *backfills* what's missing. + * - FAIL-SOFT by default: a fetch/auth failure logs a warning and lets the bot + * continue on whatever env it already has. Set INFISICAL_REQUIRED=true to + * make Infisical a hard dependency that aborts boot on any failure. + * + * The @infisical/sdk dependency is imported lazily, so it is only resolved when + * Infisical is actually configured. + * + * Bootstrap env vars (see .env.example): + * INFISICAL_CLIENT_ID machine-identity client id (required to enable) + * INFISICAL_CLIENT_SECRET machine-identity client secret (required to enable) + * INFISICAL_PROJECT_ID project / workspace id (required to enable) + * INFISICAL_ENV environment slug (default: 'prod') + * INFISICAL_SECRET_PATH secret folder path (default: '/') + * INFISICAL_SITE_URL self-hosted instance URL (default: app.infisical.com) + * INFISICAL_RECURSIVE 'true' to pull nested folders too (default: false) + * INFISICAL_REQUIRED 'true' to abort boot if the load fails (default: false) + * ═══════════════════════════════════════════════════════════════════════════════ + */ + +import { createModuleLogger } from './logger.js'; + +const logger = createModuleLogger('secrets'); + +export interface LoadSecretsResult { + /** True only when secrets were successfully fetched from Infisical. */ + loaded: boolean; + /** Number of keys actually injected into process.env (missing keys only). */ + injected: number; + /** Where the effective config ultimately comes from. */ + source: 'infisical' | 'env'; +} + +/** Parse a loose boolean env var ('1' / 'true', case-insensitive). */ +function envFlag(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} + +/** + * Load secrets from Infisical into process.env. Safe to call exactly once, + * before configuration is validated (loadConfig / getConfig). + */ +export async function loadSecrets(): Promise { + const clientId = process.env.INFISICAL_CLIENT_ID; + const clientSecret = process.env.INFISICAL_CLIENT_SECRET; + const projectId = process.env.INFISICAL_PROJECT_ID; + const required = envFlag(process.env.INFISICAL_REQUIRED); + + // Not configured → no-op fallback to .env / process.env. + if (!clientId || !clientSecret || !projectId) { + if (required) { + throw new Error( + 'INFISICAL_REQUIRED=true but INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET ' + + 'and INFISICAL_PROJECT_ID are not all set.', + ); + } + // Surface a partial config: if SOME (but not all) bootstrap vars are set, + // Infisical is silently skipped — almost always a deploy misconfiguration, + // so warn rather than swallow it at debug level. + if (clientId || clientSecret || projectId) { + logger.warn( + { + hasClientId: Boolean(clientId), + hasClientSecret: Boolean(clientSecret), + hasProjectId: Boolean(projectId), + }, + 'Infisical partially configured — need INFISICAL_CLIENT_ID, ' + + 'INFISICAL_CLIENT_SECRET and INFISICAL_PROJECT_ID; skipping Infisical ' + + 'and using .env / process.env', + ); + } else { + logger.debug('Infisical not configured — using .env / process.env only'); + } + return { loaded: false, injected: 0, source: 'env' }; + } + + const environment = process.env.INFISICAL_ENV ?? 'prod'; + const secretPath = process.env.INFISICAL_SECRET_PATH ?? '/'; + const siteUrl = process.env.INFISICAL_SITE_URL; + const recursive = envFlag(process.env.INFISICAL_RECURSIVE); + + try { + // Lazy import: the SDK is only loaded when Infisical is configured. + const { InfisicalSDK } = await import('@infisical/sdk'); + const client = new InfisicalSDK(siteUrl ? { siteUrl } : {}); + + await client.auth().universalAuth.login({ clientId, clientSecret }); + + const { secrets } = await client.secrets().listSecrets({ + environment, + projectId, + secretPath, + recursive, + expandSecretReferences: true, + }); + + let injected = 0; + for (const secret of secrets) { + // Never clobber an already-set var: explicit env / .env wins. + if (process.env[secret.secretKey] === undefined) { + process.env[secret.secretKey] = secret.secretValue; + injected++; + } + } + + logger.info( + { environment, secretPath, fetched: secrets.length, injected }, + 'Loaded secrets from Infisical', + ); + return { loaded: true, injected, source: 'infisical' }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (required) { + throw new Error(`Infisical secret load failed (INFISICAL_REQUIRED=true): ${message}`); + } + logger.warn( + { error: message }, + 'Infisical secret load failed — continuing with .env / process.env', + ); + return { loaded: false, injected: 0, source: 'env' }; + } +} diff --git a/src/index.ts b/src/index.ts index e1706c6..0420d7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ * ═══════════════════════════════════════════════════════════════════════════════ */ -import { loadSecrets } from '@quantum-l9/infisical-config'; +import { loadSecrets } from './core/secrets.js'; import { loadConfig } from './core/config.js'; import { createModuleLogger } from './core/logger.js'; import { closeDb } from './core/database/index.js'; @@ -29,7 +29,7 @@ const logger = createModuleLogger('main'); async function main() { // Hydrate process.env from Infisical before any config is read (no-op when // Infisical isn't configured; never overrides vars already set in the env). - await loadSecrets({ logger: createModuleLogger('secrets') }); + await loadSecrets(); const config = loadConfig(); logger.info('Configuration validated'); diff --git a/tests/core/secrets.test.ts b/tests/core/secrets.test.ts new file mode 100644 index 0000000..784a2c7 --- /dev/null +++ b/tests/core/secrets.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Chainable @infisical/sdk mock: +// new InfisicalSDK().auth().universalAuth.login(...) +// new InfisicalSDK().secrets().listSecrets(...) +const { ctorMock, loginMock, listSecretsMock } = vi.hoisted(() => { + const loginMock = vi.fn(); + const listSecretsMock = vi.fn(); + const ctorMock = vi.fn(() => ({ + auth: () => ({ universalAuth: { login: loginMock } }), + secrets: () => ({ listSecrets: listSecretsMock }), + })); + return { ctorMock, loginMock, listSecretsMock }; +}); + +vi.mock('@infisical/sdk', () => ({ InfisicalSDK: ctorMock })); + +vi.mock('../../src/core/logger.js', () => ({ + createModuleLogger: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }), +})); + +import { loadSecrets } from '../../src/core/secrets.js'; + +// Env keys this suite touches, cleared before each test for isolation. +const TOUCHED = [ + 'INFISICAL_CLIENT_ID', + 'INFISICAL_CLIENT_SECRET', + 'INFISICAL_PROJECT_ID', + 'INFISICAL_ENV', + 'INFISICAL_SECRET_PATH', + 'INFISICAL_REQUIRED', + 'INFISICAL_RECURSIVE', + 'INFISICAL_SITE_URL', + 'FETCHED_SECRET', + 'ALREADY_SET', +]; + +beforeEach(() => { + for (const k of TOUCHED) delete process.env[k]; + ctorMock.mockClear(); + loginMock.mockReset(); + listSecretsMock.mockReset(); +}); + +afterEach(() => { + for (const k of TOUCHED) delete process.env[k]; +}); + +function configure() { + process.env.INFISICAL_CLIENT_ID = 'cid'; + process.env.INFISICAL_CLIENT_SECRET = 'csecret'; + process.env.INFISICAL_PROJECT_ID = 'proj'; +} + +describe('loadSecrets', () => { + it('is a no-op when Infisical is not configured', async () => { + const result = await loadSecrets(); + expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); + expect(ctorMock).not.toHaveBeenCalled(); + expect(loginMock).not.toHaveBeenCalled(); + }); + + it('throws when INFISICAL_REQUIRED=true but bootstrap vars are missing', async () => { + process.env.INFISICAL_REQUIRED = 'true'; + await expect(loadSecrets()).rejects.toThrow(/INFISICAL_REQUIRED=true/); + expect(ctorMock).not.toHaveBeenCalled(); + }); + + it('no-ops (does not call the SDK) when only some bootstrap vars are set', async () => { + process.env.INFISICAL_CLIENT_ID = 'cid'; + // INFISICAL_CLIENT_SECRET and INFISICAL_PROJECT_ID intentionally missing + const result = await loadSecrets(); + expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); + expect(ctorMock).not.toHaveBeenCalled(); + expect(loginMock).not.toHaveBeenCalled(); + }); + + it('authenticates and backfills only missing keys into process.env', async () => { + configure(); + process.env.ALREADY_SET = 'from-env'; // must NOT be overwritten + listSecretsMock.mockResolvedValue({ + secrets: [ + { secretKey: 'FETCHED_SECRET', secretValue: 'from-infisical' }, + { secretKey: 'ALREADY_SET', secretValue: 'from-infisical' }, + ], + }); + + const result = await loadSecrets(); + + expect(loginMock).toHaveBeenCalledWith({ clientId: 'cid', clientSecret: 'csecret' }); + expect(listSecretsMock).toHaveBeenCalledWith( + expect.objectContaining({ environment: 'prod', projectId: 'proj', secretPath: '/' }), + ); + expect(process.env.FETCHED_SECRET).toBe('from-infisical'); // injected + expect(process.env.ALREADY_SET).toBe('from-env'); // preserved + expect(result).toEqual({ loaded: true, injected: 1, source: 'infisical' }); + }); + + it('honours INFISICAL_ENV / INFISICAL_SECRET_PATH overrides', async () => { + configure(); + process.env.INFISICAL_ENV = 'staging'; + process.env.INFISICAL_SECRET_PATH = '/seo-bot'; + listSecretsMock.mockResolvedValue({ secrets: [] }); + + await loadSecrets(); + + expect(listSecretsMock).toHaveBeenCalledWith( + expect.objectContaining({ environment: 'staging', secretPath: '/seo-bot' }), + ); + }); + + it('fails soft on fetch error when not required', async () => { + configure(); + listSecretsMock.mockRejectedValue(new Error('network down')); + + const result = await loadSecrets(); + + expect(result).toEqual({ loaded: false, injected: 0, source: 'env' }); + }); + + it('aborts on fetch error when INFISICAL_REQUIRED=true', async () => { + configure(); + process.env.INFISICAL_REQUIRED = 'true'; + listSecretsMock.mockRejectedValue(new Error('network down')); + + await expect(loadSecrets()).rejects.toThrow(/network down/); + }); +});