Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 89 additions & 45 deletions src/config/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,28 +87,87 @@ export async function loadProjectConfigById(id: string): Promise<ProjectWithConf
}

// ============================================================================
// Internal: 3-step env/worker/DB resolution helper
// CredentialResolver interface and implementations
// ============================================================================

/**
* Resolve a credential value using the standard 3-step pattern:
* 1. Check process.env (populated at worker startup from router-supplied credentials)
* 2. If in worker context (CASCADE_CREDENTIAL_KEYS set), credential is absent → return notFoundValue
* 3. Otherwise resolve from DB via the provided async lookup
* Abstraction over credential resolution. Allows tests to inject a mock
* resolver instead of manipulating process.env or the DB.
*/
async function resolveFromEnvOrDb<T>(
envKey: string | undefined,
notFoundValue: T,
dbLookup: () => Promise<T>,
): Promise<T> {
// 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<string | null>;

/**
* Resolve all credentials for a given project as a flat env-var-key → value map.
*/
resolveAll(projectId: string): Promise<Record<string, string>>;
}

/**
* Production resolver: reads from the project_credentials DB table.
*/
export class DbCredentialResolver implements CredentialResolver {
async resolve(projectId: string, key: string): Promise<string | null> {
return resolveProjectCredential(projectId, key);
}

async resolveAll(projectId: string): Promise<Record<string, string>> {
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<string | null> {
return process.env[key] ?? null;
}

async resolveAll(_projectId: string): Promise<Record<string, string>> {
const keyList = process.env.CASCADE_CREDENTIAL_KEYS ?? '';
const result: Record<string, string> = {};
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;
}

// ============================================================================
Expand All @@ -117,7 +176,7 @@ async function resolveFromEnvOrDb<T>(

/**
* 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(
Expand All @@ -126,10 +185,12 @@ export async function getIntegrationCredential(
role: string,
): Promise<string> {
const envKey = roleToEnvVarKey(category, role);
const value = await resolveFromEnvOrDb<string | null>(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(
Expand All @@ -139,18 +200,16 @@ 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,
category: string,
role: string,
): Promise<string | null> {
const envKey = roleToEnvVarKey(category, role);
return resolveFromEnvOrDb<string | null>(envKey, null, () => {
if (!envKey) return Promise.resolve(null);
return resolveProjectCredential(projectId, envKey);
});
if (!envKey) return null;
return getResolver().resolve(projectId, envKey);
}

// ============================================================================
Expand All @@ -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<string | null> {
return resolveFromEnvOrDb<string | null>(envVarKey, null, () =>
resolveProjectCredential(projectId, envVarKey),
);
return getResolver().resolve(projectId, envVarKey);
}

// ============================================================================
Expand All @@ -179,20 +236,7 @@ export async function getOrgCredential(
* Single query against project_credentials filtered by project_id.
*/
export async function getAllProjectCredentials(projectId: string): Promise<Record<string, string>> {
// Worker context: reconstruct from individual env vars set by the router
const keyList = process.env.CASCADE_CREDENTIAL_KEYS;
if (keyList) {
const result: Record<string, string> = {};
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 {
Expand All @@ -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
Expand Down
170 changes: 170 additions & 0 deletions tests/unit/config/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ vi.mock('../../../src/config/configCache.js', () => ({

import { configCache } from '../../../src/config/configCache.js';
import {
DbCredentialResolver,
EnvCredentialResolver,
findProjectByBoardId,
findProjectById,
findProjectByJiraProjectKey,
Expand All @@ -43,6 +45,7 @@ import {
getOrgCredential,
invalidateConfigCache,
loadConfig,
setCredentialResolver,
} from '../../../src/config/provider.js';
import {
findProjectByBoardIdFromDb,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' });
});
});
});
Loading