From 8e20b2679a4db5305a6cb93c530a5eadbdac5d84 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 20:09:23 +0000 Subject: [PATCH] feat(config): introduce CredentialResolver interface for test-friendly DI --- src/config/provider.ts | 134 +++++++++++++++-------- tests/unit/config/provider.test.ts | 170 +++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 45 deletions(-) diff --git a/src/config/provider.ts b/src/config/provider.ts index 05bdf39b..d26f9a7d 100644 --- a/src/config/provider.ts +++ b/src/config/provider.ts @@ -87,28 +87,87 @@ export async function loadProjectConfigById(id: string): Promise( - envKey: string | undefined, - notFoundValue: T, - dbLookup: () => Promise, -): Promise { - // Worker context: credentials are pre-loaded into env vars by the router. - // Only use env vars here; never fall through to the DB. - if (process.env.CASCADE_CREDENTIAL_KEYS) { - return envKey && process.env[envKey] ? (process.env[envKey] as T) : notFoundValue; +export interface CredentialResolver { + /** + * Resolve a single credential by env var key for a given project. + * Returns null if not found. + */ + resolve(projectId: string, key: string): Promise; + + /** + * Resolve all credentials for a given project as a flat env-var-key → value map. + */ + resolveAll(projectId: string): Promise>; +} + +/** + * Production resolver: reads from the project_credentials DB table. + */ +export class DbCredentialResolver implements CredentialResolver { + async resolve(projectId: string, key: string): Promise { + return resolveProjectCredential(projectId, key); + } + + async resolveAll(projectId: string): Promise> { + return resolveAllProjectCredentials(projectId); + } +} + +/** + * Worker-context resolver: reads pre-loaded credentials from process.env. + * Credentials are populated at worker startup from router-supplied env vars listed + * in CASCADE_CREDENTIAL_KEYS. Never falls through to the DB. + */ +export class EnvCredentialResolver implements CredentialResolver { + async resolve(_projectId: string, key: string): Promise { + return process.env[key] ?? null; + } + + async resolveAll(_projectId: string): Promise> { + const keyList = process.env.CASCADE_CREDENTIAL_KEYS ?? ''; + const result: Record = {}; + for (const key of keyList.split(',')) { + if (key && process.env[key]) { + result[key] = process.env[key]; + } + } + return result; } +} + +// Module-level resolver instance — auto-selected based on context, injectable for tests. +let _resolver: CredentialResolver | null = null; + +/** + * Get the active CredentialResolver instance. + * Auto-selects based on CASCADE_CREDENTIAL_KEYS presence: + * - Set → EnvCredentialResolver (worker context) + * - Unset → DbCredentialResolver (router/dashboard/test context) + * + * Call setCredentialResolver() before this to override for tests. + */ +function getResolver(): CredentialResolver { + if (_resolver) return _resolver; + return process.env.CASCADE_CREDENTIAL_KEYS + ? new EnvCredentialResolver() + : new DbCredentialResolver(); +} - // All other contexts (router, dashboard, tests): always resolve from DB. - return dbLookup(); +/** + * Override the active CredentialResolver. Use in tests to inject a mock + * resolver instead of manipulating process.env. + * + * Pass null to revert to auto-selection. + */ +export function setCredentialResolver(resolver: CredentialResolver | null): void { + _resolver = resolver; } // ============================================================================ @@ -117,7 +176,7 @@ async function resolveFromEnvOrDb( /** * Resolve an integration credential for a project by category and role. - * Resolves via project_credentials using the envVarKey mapping. + * Resolves via the active CredentialResolver using the envVarKey mapping. * Throws if the credential is not found. */ export async function getIntegrationCredential( @@ -126,10 +185,12 @@ export async function getIntegrationCredential( role: string, ): Promise { const envKey = roleToEnvVarKey(category, role); - const value = await resolveFromEnvOrDb(envKey, null, () => { - if (!envKey) return Promise.resolve(null); - return resolveProjectCredential(projectId, envKey); - }); + if (!envKey) { + throw new Error( + `Integration credential '${category}/${role}' not found for project '${projectId}'`, + ); + } + const value = await getResolver().resolve(projectId, envKey); if (value) return value; throw new Error( @@ -139,7 +200,7 @@ export async function getIntegrationCredential( /** * Resolve an integration credential for a project, returning null if not found. - * Resolves via project_credentials using the envVarKey mapping. + * Resolves via the active CredentialResolver using the envVarKey mapping. */ export async function getIntegrationCredentialOrNull( projectId: string, @@ -147,10 +208,8 @@ export async function getIntegrationCredentialOrNull( role: string, ): Promise { const envKey = roleToEnvVarKey(category, role); - return resolveFromEnvOrDb(envKey, null, () => { - if (!envKey) return Promise.resolve(null); - return resolveProjectCredential(projectId, envKey); - }); + if (!envKey) return null; + return getResolver().resolve(projectId, envKey); } // ============================================================================ @@ -159,15 +218,13 @@ export async function getIntegrationCredentialOrNull( /** * Resolve a non-integration credential by env var key. - * Reads from project_credentials table — no org_id lookup needed. + * Reads from the active CredentialResolver — no org_id lookup needed. */ export async function getOrgCredential( projectId: string, envVarKey: string, ): Promise { - return resolveFromEnvOrDb(envVarKey, null, () => - resolveProjectCredential(projectId, envVarKey), - ); + return getResolver().resolve(projectId, envVarKey); } // ============================================================================ @@ -179,20 +236,7 @@ export async function getOrgCredential( * Single query against project_credentials filtered by project_id. */ export async function getAllProjectCredentials(projectId: string): Promise> { - // Worker context: reconstruct from individual env vars set by the router - const keyList = process.env.CASCADE_CREDENTIAL_KEYS; - if (keyList) { - const result: Record = {}; - for (const key of keyList.split(',')) { - if (key && process.env[key]) { - result[key] = process.env[key]; - } - } - return result; - } - - // Router/dashboard context: single query against project_credentials - return resolveAllProjectCredentials(projectId); + return getResolver().resolveAll(projectId); } export function invalidateConfigCache(): void { @@ -205,7 +249,7 @@ export function invalidateConfigCache(): void { /** * Map a category+role pair to the corresponding env var key. - * Used for process.env lookups in worker environments. + * Used for env-var and DB lookups in resolver implementations. */ function roleToEnvVarKey(category: string, role: string): string | undefined { // Look through all providers in the category to find the role diff --git a/tests/unit/config/provider.test.ts b/tests/unit/config/provider.test.ts index ef287557..40b60011 100644 --- a/tests/unit/config/provider.test.ts +++ b/tests/unit/config/provider.test.ts @@ -33,6 +33,8 @@ vi.mock('../../../src/config/configCache.js', () => ({ import { configCache } from '../../../src/config/configCache.js'; import { + DbCredentialResolver, + EnvCredentialResolver, findProjectByBoardId, findProjectById, findProjectByJiraProjectKey, @@ -43,6 +45,7 @@ import { getOrgCredential, invalidateConfigCache, loadConfig, + setCredentialResolver, } from '../../../src/config/provider.js'; import { findProjectByBoardIdFromDb, @@ -108,6 +111,8 @@ describe('config/provider', () => { delete process.env[key]; } envKeysToClean.length = 0; + // Reset injected resolver so tests don't leak state + setCredentialResolver(null); }); describe('loadConfig', () => { @@ -435,4 +440,169 @@ describe('config/provider', () => { expect(configCache.invalidate).toHaveBeenCalledTimes(1); }); }); + + // --------------------------------------------------------------------------- + // CredentialResolver DI + // --------------------------------------------------------------------------- + + describe('setCredentialResolver / getIntegrationCredential (DI)', () => { + it('uses injected resolver instead of DB for getIntegrationCredential', async () => { + const mockResolver = { + resolve: vi.fn().mockResolvedValue('injected-value'), + resolveAll: vi.fn().mockResolvedValue({}), + }; + setCredentialResolver(mockResolver); + + const result = await getIntegrationCredential('proj1', 'pm', 'api_key'); + + expect(result).toBe('injected-value'); + expect(mockResolver.resolve).toHaveBeenCalledWith('proj1', 'TRELLO_API_KEY'); + expect(resolveProjectCredential).not.toHaveBeenCalled(); + }); + + it('uses injected resolver for getOrgCredential', async () => { + const mockResolver = { + resolve: vi.fn().mockResolvedValue('org-cred'), + resolveAll: vi.fn().mockResolvedValue({}), + }; + setCredentialResolver(mockResolver); + + const result = await getOrgCredential('proj1', 'OPENROUTER_API_KEY'); + + expect(result).toBe('org-cred'); + expect(mockResolver.resolve).toHaveBeenCalledWith('proj1', 'OPENROUTER_API_KEY'); + expect(resolveProjectCredential).not.toHaveBeenCalled(); + }); + + it('uses injected resolver for getAllProjectCredentials', async () => { + const creds = { TRELLO_API_KEY: 'tk', OPENROUTER_API_KEY: 'ork' }; + const mockResolver = { + resolve: vi.fn().mockResolvedValue(null), + resolveAll: vi.fn().mockResolvedValue(creds), + }; + setCredentialResolver(mockResolver); + + const result = await getAllProjectCredentials('proj1'); + + expect(result).toEqual(creds); + expect(mockResolver.resolveAll).toHaveBeenCalledWith('proj1'); + expect(resolveAllProjectCredentials).not.toHaveBeenCalled(); + }); + + it('reverts to auto-selection when setCredentialResolver(null) is called', async () => { + const mockResolver = { + resolve: vi.fn().mockResolvedValue('injected'), + resolveAll: vi.fn().mockResolvedValue({}), + }; + setCredentialResolver(mockResolver); + setCredentialResolver(null); + + vi.mocked(resolveProjectCredential).mockResolvedValue('db-value'); + + const result = await getOrgCredential('proj1', 'OPENROUTER_API_KEY'); + + expect(result).toBe('db-value'); + expect(mockResolver.resolve).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------------------- + // DbCredentialResolver + // --------------------------------------------------------------------------- + + describe('DbCredentialResolver', () => { + it('resolve() delegates to resolveProjectCredential', async () => { + vi.mocked(resolveProjectCredential).mockResolvedValue('db-result'); + const resolver = new DbCredentialResolver(); + + const result = await resolver.resolve('proj1', 'MY_KEY'); + + expect(result).toBe('db-result'); + expect(resolveProjectCredential).toHaveBeenCalledWith('proj1', 'MY_KEY'); + }); + + it('resolve() returns null when credential not found', async () => { + vi.mocked(resolveProjectCredential).mockResolvedValue(null); + const resolver = new DbCredentialResolver(); + + const result = await resolver.resolve('proj1', 'MISSING_KEY'); + + expect(result).toBeNull(); + }); + + it('resolveAll() delegates to resolveAllProjectCredentials', async () => { + const creds = { A: '1', B: '2' }; + vi.mocked(resolveAllProjectCredentials).mockResolvedValue(creds); + const resolver = new DbCredentialResolver(); + + const result = await resolver.resolveAll('proj1'); + + expect(result).toEqual(creds); + expect(resolveAllProjectCredentials).toHaveBeenCalledWith('proj1'); + }); + }); + + // --------------------------------------------------------------------------- + // EnvCredentialResolver + // --------------------------------------------------------------------------- + + describe('EnvCredentialResolver', () => { + it('resolve() returns value from process.env when present', async () => { + setEnvCredential('MY_SECRET', 'env-secret'); + const resolver = new EnvCredentialResolver(); + + const result = await resolver.resolve('proj1', 'MY_SECRET'); + + expect(result).toBe('env-secret'); + }); + + it('resolve() returns null when key is absent from process.env', async () => { + const resolver = new EnvCredentialResolver(); + + const result = await resolver.resolve('proj1', 'NONEXISTENT_KEY'); + + expect(result).toBeNull(); + }); + + it('resolve() ignores projectId (env is global)', async () => { + setEnvCredential('GLOBAL_KEY', 'global-val'); + const resolver = new EnvCredentialResolver(); + + // projectId is irrelevant for env-based resolution + const result = await resolver.resolve('any-project', 'GLOBAL_KEY'); + + expect(result).toBe('global-val'); + }); + + it('resolveAll() reconstructs map from CASCADE_CREDENTIAL_KEYS list', async () => { + setEnvCredential('CASCADE_CREDENTIAL_KEYS', 'KEY_A,KEY_B,KEY_C'); + setEnvCredential('KEY_A', 'val-a'); + setEnvCredential('KEY_B', 'val-b'); + // KEY_C intentionally absent + const resolver = new EnvCredentialResolver(); + + const result = await resolver.resolveAll('proj1'); + + expect(result).toEqual({ KEY_A: 'val-a', KEY_B: 'val-b' }); + }); + + it('resolveAll() returns empty object when CASCADE_CREDENTIAL_KEYS is not set', async () => { + const resolver = new EnvCredentialResolver(); + + const result = await resolver.resolveAll('proj1'); + + expect(result).toEqual({}); + }); + + it('resolveAll() skips empty key entries in CASCADE_CREDENTIAL_KEYS', async () => { + setEnvCredential('CASCADE_CREDENTIAL_KEYS', 'KEY_A,,KEY_B'); + setEnvCredential('KEY_A', 'val-a'); + setEnvCredential('KEY_B', 'val-b'); + const resolver = new EnvCredentialResolver(); + + const result = await resolver.resolveAll('proj1'); + + expect(result).toEqual({ KEY_A: 'val-a', KEY_B: 'val-b' }); + }); + }); });