Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
289 lines
11 KiB
JavaScript
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];
|
|
});
|
|
}
|
|
});
|