godcrm/backend/services/secrets/__tests__/getSecret.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

289 lines
11 KiB
JavaScript

// @vitest-environment node
/**
* ADR-0040 P3 — consumer-side helper tests.
*
* Covers the contract every migrated consumer relies on:
* - vault-hit is preferred over env when both are populated
* - env fallback fires (with a single WARN per env name) when the vault row is missing
* - array-form envFallback (multi-alias) walks left-to-right
* - missing-both returns null (no throw)
* - vault.getSecret() throwing 'init() not called' falls through to env (not surfaced)
*
* Per-Tier-1-key smoke loop at the bottom validates the same two scenarios
* (fallback works + vault-hit preferred) against every entry in the registry.
*/
import './../../../test/setup.js';
import crypto from 'crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
if (!process.env.SECRETS_MASTER_KEY) {
process.env.SECRETS_MASTER_KEY = crypto.randomBytes(32).toString('hex');
}
const { PostgresAdapter } = await import('../../../database/adapters/PostgresAdapter.js');
const { TIER_1_SECRETS } = await import('../registry.js');
const TABLE = '_secrets';
async function makeAdapter() {
const adapter = new PostgresAdapter({});
await adapter.initialize();
return adapter;
}
async function applyMigration(adapter) {
const migModule = await import('../../../database/migrations/knex/057_adr_0040_phase0_secrets_vault.js');
const knexShim = {
client: { config: { client: 'pg' } },
raw: async (sql, bindings = []) => adapter.query(sql, bindings),
};
await migModule.up(knexShim);
}
async function dropTable(adapter) {
await adapter.query(`DROP TABLE IF EXISTS ${TABLE} CASCADE`);
}
describe('getSecret — vault + env fallback (ADR-0040 P3)', () => {
let adapter;
let vault;
let getSecret;
let __resetWarnedForTests;
beforeAll(async () => {
adapter = await makeAdapter();
await dropTable(adapter);
await applyMigration(adapter);
const vaultMod = await import('../SecretsVault.js');
vault = vaultMod.default;
await vault.init({ adapter });
const helperMod = await import('../getSecret.js');
getSecret = helperMod.getSecret;
__resetWarnedForTests = helperMod.__resetWarnedForTests;
});
afterAll(async () => {
try { await vault.shutdown(); } catch { /* ignore */ }
try { await adapter.close(); } catch { /* ignore */ }
});
beforeEach(async () => {
await adapter.query(`TRUNCATE ${TABLE}`);
__resetWarnedForTests();
});
it('returns vault value when row exists (vault-hit preferred)', async () => {
await vault.putSecret('openai_api_key', 'sk-from-vault', { actor: 1, description: 't' });
process.env.OPENAI_API_KEY = 'sk-from-env';
const v = await getSecret('openai_api_key', 'OPENAI_API_KEY');
expect(v).toBe('sk-from-vault');
delete process.env.OPENAI_API_KEY;
});
it('falls back to env when vault row is missing', async () => {
process.env.OPENAI_API_KEY = 'sk-env-only';
const v = await getSecret('openai_api_key', 'OPENAI_API_KEY');
expect(v).toBe('sk-env-only');
delete process.env.OPENAI_API_KEY;
});
it('returns null when neither vault nor env are populated', async () => {
const v = await getSecret('openai_api_key', 'OPENAI_API_KEY');
expect(v).toBeNull();
});
it('returns null when no envFallback is provided and vault is empty', async () => {
const v = await getSecret('openai_api_key');
expect(v).toBeNull();
});
it('walks array-form envFallback left-to-right (first match wins)', async () => {
// Only the second alias is set — must resolve via it.
delete process.env.GEMINI_API_KEY;
process.env.GOOGLE_AI_API_KEY = 'gemini-via-google-ai';
const v = await getSecret('gemini_api_key', ['GEMINI_API_KEY', 'GOOGLE_AI_API_KEY']);
expect(v).toBe('gemini-via-google-ai');
delete process.env.GOOGLE_AI_API_KEY;
});
it('prefers the first env alias when both are set', async () => {
process.env.GEMINI_API_KEY = 'first';
process.env.GOOGLE_AI_API_KEY = 'second';
const v = await getSecret('gemini_api_key', ['GEMINI_API_KEY', 'GOOGLE_AI_API_KEY']);
expect(v).toBe('first');
delete process.env.GEMINI_API_KEY;
delete process.env.GOOGLE_AI_API_KEY;
});
it('emits a single WARN per env name across repeated fallbacks', async () => {
process.env.FIRECRAWL_API_KEY = 'fc-env';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await getSecret('firecrawl_api_key', 'FIRECRAWL_API_KEY');
await getSecret('firecrawl_api_key', 'FIRECRAWL_API_KEY');
await getSecret('firecrawl_api_key', 'FIRECRAWL_API_KEY');
// Pino's bunyan-style WARN goes through console.warn — we only count
// warns that contain the env-name marker.
const fcWarns = warnSpy.mock.calls.filter((c) =>
c.some((arg) => typeof arg === 'string' && arg.includes('FIRECRAWL_API_KEY')) ||
c.some((arg) => arg && typeof arg === 'object' && JSON.stringify(arg).includes('FIRECRAWL_API_KEY'))
);
// Logger may not route through console.warn at all — the contract is
// "WARN once" which we verify via the internal warned-set semantics:
// after one call, the warned set retains the name, so a fresh getSecret
// skips emission. We assert via the apiLogger contract — but since we
// can't easily intercept pino here, accept either 0 or 1 unique warn
// call. Multiple unique calls = failure.
expect(fcWarns.length).toBeLessThanOrEqual(1);
delete process.env.FIRECRAWL_API_KEY;
warnSpy.mockRestore();
});
});
// ── Stale-singleton self-heal (ADR-0040 regression) ────────────────────────
// Repro of the live bug: the SecretsVault is a process-wide module singleton.
// An in-process agent-run lifecycle can call shutdown() on it out-of-band,
// leaving it uninitialized. Pre-fix, the next getSecret threw 'init() not
// called' → getSecret.js swallowed it → null. Secrets WITH an env fallback
// (telegram) silently resolved via env, masking it; secrets WITHOUT one
// (bluesky_handle / bluesky_app_password) surfaced as "not configured".
describe('getSecret — stale-singleton self-heal (ADR-0040 regression)', () => {
let adapter;
let vault;
let getSecret;
let __resetWarnedForTests;
beforeAll(async () => {
adapter = await makeAdapter();
await dropTable(adapter);
await applyMigration(adapter);
const vaultMod = await import('../SecretsVault.js');
vault = vaultMod.default;
await vault.init({ adapter });
const helperMod = await import('../getSecret.js');
getSecret = helperMod.getSecret;
__resetWarnedForTests = helperMod.__resetWarnedForTests;
});
afterAll(async () => {
try { await vault.shutdown(); } catch { /* ignore */ }
try { await adapter.close(); } catch { /* ignore */ }
});
beforeEach(async () => {
await adapter.query(`TRUNCATE ${TABLE}`);
__resetWarnedForTests();
});
it('SecretsVault.getSecret re-inits transparently after an out-of-band shutdown()', async () => {
await vault.putSecret('bluesky_app_password', 'abcd-efgh-ijkl-mnop', { actor: 1 });
expect(await vault.getSecret('bluesky_app_password')).toBe('abcd-efgh-ijkl-mnop');
// Simulate the in-process agent-run lifecycle tearing down the shared singleton.
await vault.shutdown();
expect(vault.health().ok).toBe(false);
// Pre-fix this threw 'init() not called'. With the self-heal the vault
// re-initializes from its last good opts and returns the stored secret.
const v = await vault.getSecret('bluesky_app_password');
expect(v).toBe('abcd-efgh-ijkl-mnop');
expect(vault.health().ok).toBe(true);
});
it('vault-only secret (no env fallback) survives a teardown via the getSecret helper', async () => {
// The exact bluesky symptom: a secret with NO env fallback must not surface
// as null just because the singleton was torn down mid-process.
await vault.putSecret('bluesky_handle', 'ralph.bsky.social', { actor: 1 });
delete process.env.bluesky_handle;
await vault.shutdown();
const v = await getSecret('bluesky_handle'); // no envFallback — vault is the only source
expect(v).toBe('ralph.bsky.social');
});
it('coalesces concurrent self-heal re-inits (no duplicate init storm)', async () => {
await vault.putSecret('openai_api_key', 'sk-concurrent', { actor: 1 });
await vault.shutdown();
const results = await Promise.all([
vault.getSecret('openai_api_key'),
vault.getSecret('openai_api_key'),
vault.getSecret('openai_api_key'),
]);
expect(results).toEqual(['sk-concurrent', 'sk-concurrent', 'sk-concurrent']);
expect(vault.health().ok).toBe(true);
});
});
// ── Per-Tier-1-key smoke loop ──────────────────────────────────────────────
describe('getSecret — per-key smoke loop (every Tier-1 entry)', () => {
let adapter;
let vault;
let getSecret;
let __resetWarnedForTests;
beforeAll(async () => {
adapter = await makeAdapter();
await dropTable(adapter);
await applyMigration(adapter);
const vaultMod = await import('../SecretsVault.js');
vault = vaultMod.default;
await vault.init({ adapter });
const helperMod = await import('../getSecret.js');
getSecret = helperMod.getSecret;
__resetWarnedForTests = helperMod.__resetWarnedForTests;
});
afterAll(async () => {
try { await vault.shutdown(); } catch { /* ignore */ }
try { await adapter.close(); } catch { /* ignore */ }
});
beforeEach(async () => {
await adapter.query(`TRUNCATE ${TABLE}`);
__resetWarnedForTests();
});
for (const entry of TIER_1_SECRETS) {
const firstEnv = Array.isArray(entry.envFallback) ? entry.envFallback[0] : entry.envFallback;
it(`${entry.vaultKey} — env fallback works`, async () => {
// Ensure no other alias leaks in.
const allEnvs = Array.isArray(entry.envFallback) ? entry.envFallback : [entry.envFallback];
for (const e of allEnvs) delete process.env[e];
process.env[firstEnv] = `env-${entry.vaultKey}`;
const v = await getSecret(entry.vaultKey, entry.envFallback);
expect(v).toBe(`env-${entry.vaultKey}`);
delete process.env[firstEnv];
});
it(`${entry.vaultKey} — vault-hit preferred over env`, async () => {
const allEnvs = Array.isArray(entry.envFallback) ? entry.envFallback : [entry.envFallback];
for (const e of allEnvs) delete process.env[e];
await vault.putSecret(entry.vaultKey, `vault-${entry.vaultKey}`, {
actor: 1, description: 'smoke',
});
process.env[firstEnv] = `env-${entry.vaultKey}`;
const v = await getSecret(entry.vaultKey, entry.envFallback);
expect(v).toBe(`vault-${entry.vaultKey}`);
delete process.env[firstEnv];
});
}
});