diff --git a/apps/fiona-slack/scripts/setup-cosmos-emulator.js b/apps/fiona-slack/scripts/setup-cosmos-emulator.js index 3122855d..f5f97e3c 100644 --- a/apps/fiona-slack/scripts/setup-cosmos-emulator.js +++ b/apps/fiona-slack/scripts/setup-cosmos-emulator.js @@ -15,7 +15,7 @@ */ import { readFileSync } from 'node:fs'; -import { CosmosClient } from '@azure/cosmos'; +import { getCosmosConfig, createCosmosClient } from '../src/agent/cosmos-utils.js'; // Load .env if present so COSMOS_CONNECTION_STRING etc. are available try { @@ -35,24 +35,27 @@ const CONVERSATIONS_CONTAINER = process.env.COSMOS_CONVERSATIONS_CONTAINER || 'c // Build CosmosClient from connection string if available, otherwise fall back // to the well-known emulator endpoint + key. -let client; - -if (process.env.COSMOS_CONNECTION_STRING) { - client = new CosmosClient({ - connectionString: process.env.COSMOS_CONNECTION_STRING, - }); - console.log('Using COSMOS_CONNECTION_STRING from environment.'); -} else { - const endpoint = process.env.COSMOS_ENDPOINT || 'https://localhost:8081'; +const config = getCosmosConfig({ + endpoint: process.env.COSMOS_ENDPOINT || 'https://localhost:8081', // Well-known fixed key used by every default Cosmos DB Emulator install. // If your emulator was reset or reconfigured, copy the key from the emulator's // system tray icon → "Copy Connection String", then set COSMOS_CONNECTION_STRING // in your .env. - const key = + key: process.env.COSMOS_KEY || - 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b5n5MBLzPU1z+OhS8OyX8+tU9J1A=='; - client = new CosmosClient({ endpoint, key }); - console.log(`Using endpoint ${endpoint} (set COSMOS_CONNECTION_STRING in .env to override).`); + 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b5n5MBLzPU1z+OhS8OyX8+tU9J1A==', +}); + +if (config.connectionString) { + console.log('Using COSMOS_CONNECTION_STRING from environment.'); +} else { + console.log(`Using endpoint ${config.endpoint} (set COSMOS_CONNECTION_STRING in .env to override).`); +} + +const client = createCosmosClient(config); +if (!client) { + console.error('Failed to create Cosmos DB client. Set COSMOS_CONNECTION_STRING or COSMOS_ENDPOINT.'); + process.exit(1); } async function main() { diff --git a/apps/fiona-slack/src/agent/conversation-capture-store.js b/apps/fiona-slack/src/agent/conversation-capture-store.js index 52745509..d027dd1b 100644 --- a/apps/fiona-slack/src/agent/conversation-capture-store.js +++ b/apps/fiona-slack/src/agent/conversation-capture-store.js @@ -3,14 +3,9 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. -import { CosmosClient } from '@azure/cosmos'; -import { DefaultAzureCredential } from '@azure/identity'; +import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js'; const CAPTURE_ALL_CONVERSATIONS = process.env.CAPTURE_ALL_CONVERSATIONS === 'true'; -const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; -const COSMOS_KEY = process.env.COSMOS_KEY; -const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING; -const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot'; const COSMOS_CONVERSATIONS_CONTAINER = process.env.COSMOS_CONVERSATIONS_CONTAINER || 'conversations'; const DEPLOYMENT_TYPE = process.env.DEPLOYMENT_TYPE || 'local'; @@ -21,7 +16,6 @@ const CONVERSATION_TTL_SECONDS = 15_552_000; let container = null; let warnedMissingConfig = false; -let warnedInsecureProductionCosmosKey = false; const RETRYABLE_CODES = new Set([410, 429, 449, 500, 503]); const RECONNECT_CODES = new Set([410, 503]); @@ -64,16 +58,12 @@ function toNumericCode(error) { return null; } -function isEmulatorTarget() { - const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase(); - return target.includes('localhost') || target.includes('127.0.0.1'); -} - function getRetryPolicy() { if (process.env.NODE_ENV === 'test') { return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 }; } - if (isEmulatorTarget()) { + const config = getCosmosConfig(); + if (isEmulatorTarget(config.connectionString, config.endpoint)) { return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 }; } return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 }; @@ -92,26 +82,8 @@ function getDelayMs(policy, attempt) { async function getContainer(logger) { if (container) return container; - let client; - if (COSMOS_CONNECTION_STRING) { - client = new CosmosClient(COSMOS_CONNECTION_STRING); - } else if (COSMOS_ENDPOINT && COSMOS_KEY) { - if (DEPLOYMENT_TYPE === 'production') { - if (!warnedInsecureProductionCosmosKey) { - warnedInsecureProductionCosmosKey = true; - logger?.warn?.( - 'Conversation capture does not support COSMOS_KEY auth in production. Use COSMOS_CONNECTION_STRING or managed identity (COSMOS_ENDPOINT only).', - ); - } - return null; - } - client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); - } else if (COSMOS_ENDPOINT) { - client = new CosmosClient({ - endpoint: COSMOS_ENDPOINT, - aadCredentials: new DefaultAzureCredential(), - }); - } else { + const config = getCosmosConfig(); + if (!config.connectionString && !config.endpoint) { if (!warnedMissingConfig) { warnedMissingConfig = true; logger?.warn?.( @@ -121,7 +93,10 @@ async function getContainer(logger) { return null; } - container = client.database(COSMOS_DATABASE).container(COSMOS_CONVERSATIONS_CONTAINER); + const client = createCosmosClient(config, logger); + if (!client) return null; + + container = client.database(config.database).container(COSMOS_CONVERSATIONS_CONTAINER); return container; } diff --git a/apps/fiona-slack/src/agent/cosmos-utils.js b/apps/fiona-slack/src/agent/cosmos-utils.js index 68035ae8..240d1031 100644 --- a/apps/fiona-slack/src/agent/cosmos-utils.js +++ b/apps/fiona-slack/src/agent/cosmos-utils.js @@ -3,6 +3,9 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. +import { CosmosClient } from '@azure/cosmos'; +import { DefaultAzureCredential } from '@azure/identity'; + /** * Detect if the target Cosmos endpoint is a local emulator. * @param {string} [connectionString] @@ -13,3 +16,79 @@ export function isEmulatorTarget(connectionString, endpoint) { const target = `${connectionString ?? ''} ${endpoint ?? ''}`.toLowerCase(); return target.includes('localhost') || target.includes('127.0.0.1'); } + +/** + * @typedef {Object} CosmosConfig + * @property {string | undefined} connectionString - Full Cosmos DB connection string (highest priority) + * @property {string | undefined} endpoint - Cosmos DB account endpoint URL + * @property {string | undefined} key - Cosmos DB account key (not supported in production) + * @property {string} database - Database name (defaults to 'chatbot') + */ + +/** + * Read and normalize Cosmos DB connection configuration from environment variables. + * + * Pass `overrides` to substitute specific values — for example, to supply default + * emulator credentials when the env vars are absent, or to force a different database. + * + * @param {Partial} [overrides] + * @returns {CosmosConfig} + */ +export function getCosmosConfig(overrides = {}) { + return { + connectionString: process.env.COSMOS_CONNECTION_STRING, + endpoint: process.env.COSMOS_ENDPOINT, + key: process.env.COSMOS_KEY, + database: process.env.COSMOS_DATABASE || 'chatbot', + ...overrides, + }; +} + +let warnedInsecureProductionCosmosKey = false; + +/** + * Instantiate a {@link CosmosClient} from the provided configuration using the + * appropriate authentication strategy. + * + * **Authentication precedence:** + * 1. **Connection string** — used when `config.connectionString` is set. + * 2. **Endpoint + key** — used when both `config.endpoint` and `config.key` are set. + * Blocked in production deployments (`DEPLOYMENT_TYPE=production`): logs a warning + * and returns `null`. + * 3. **Managed identity** — used when only `config.endpoint` is set (no `config.key`), + * authenticating via `DefaultAzureCredential`. + * + * Returns `null` without a warning when neither `connectionString` nor `endpoint` is + * present. Callers are responsible for logging a "not configured" warning in that case. + * + * @param {CosmosConfig} config + * @param {{ warn?: (msg: string) => void }} [logger] + * @returns {import('@azure/cosmos').CosmosClient | null} + */ +export function createCosmosClient(config, logger) { + const { connectionString, endpoint, key } = config; + + if (connectionString) { + return new CosmosClient(connectionString); + } + + if (endpoint && key) { + const deploymentType = process.env.DEPLOYMENT_TYPE || 'local'; + if (deploymentType === 'production') { + if (!warnedInsecureProductionCosmosKey) { + warnedInsecureProductionCosmosKey = true; + logger?.warn?.( + 'COSMOS_KEY auth is not supported in production. Use COSMOS_CONNECTION_STRING or managed identity (COSMOS_ENDPOINT only).', + ); + } + return null; + } + return new CosmosClient({ endpoint, key }); + } + + if (endpoint) { + return new CosmosClient({ endpoint, aadCredentials: new DefaultAzureCredential() }); + } + + return null; +} diff --git a/apps/fiona-slack/src/agent/feedback-store.js b/apps/fiona-slack/src/agent/feedback-store.js index a6e62fed..34969b1c 100644 --- a/apps/fiona-slack/src/agent/feedback-store.js +++ b/apps/fiona-slack/src/agent/feedback-store.js @@ -3,13 +3,8 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. -import { CosmosClient } from '@azure/cosmos'; -import { DefaultAzureCredential } from '@azure/identity'; +import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js'; -const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; -const COSMOS_KEY = process.env.COSMOS_KEY; -const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING; -const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot'; const COSMOS_CONTAINER = process.env.COSMOS_CONTAINER || 'feedback'; let warnedMissingConfig = false; @@ -58,16 +53,12 @@ function toNumericCode(error) { return null; } -function isEmulatorTarget() { - const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase(); - return target.includes('localhost') || target.includes('127.0.0.1'); -} - function getRetryPolicy() { if (process.env.NODE_ENV === 'test') { return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 }; } - if (isEmulatorTarget()) { + const config = getCosmosConfig(); + if (isEmulatorTarget(config.connectionString, config.endpoint)) { return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 }; } return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 }; @@ -85,19 +76,8 @@ function getDelayMs(policy, attempt) { async function getContainer(logger) { if (container) return container; - const database = COSMOS_DATABASE; - const cosmosContainer = COSMOS_CONTAINER; - let client; - if (COSMOS_CONNECTION_STRING) { - client = new CosmosClient(COSMOS_CONNECTION_STRING); - } else if (COSMOS_ENDPOINT && COSMOS_KEY) { - client = new CosmosClient({ endpoint: COSMOS_ENDPOINT, key: COSMOS_KEY }); - } else if (COSMOS_ENDPOINT) { - client = new CosmosClient({ - endpoint: COSMOS_ENDPOINT, - aadCredentials: new DefaultAzureCredential(), - }); - } else { + const config = getCosmosConfig(); + if (!config.connectionString && !config.endpoint) { if (!warnedMissingConfig) { warnedMissingConfig = true; logger?.warn?.( @@ -107,10 +87,13 @@ async function getContainer(logger) { return null; } + const client = createCosmosClient(config, logger); + if (!client) return null; + try { - const { database: db } = await client.databases.createIfNotExists({ id: database }); + const { database: db } = await client.databases.createIfNotExists({ id: config.database }); const { container: c } = await db.containers.createIfNotExists({ - id: cosmosContainer, + id: COSMOS_CONTAINER, partitionKey: { paths: ['/deploymentType', '/feedbackId'], kind: 'MultiHash', version: 2 }, }); container = c; diff --git a/apps/fiona-slack/src/agent/interaction-store.js b/apps/fiona-slack/src/agent/interaction-store.js index 63b00f06..ea695d5f 100644 --- a/apps/fiona-slack/src/agent/interaction-store.js +++ b/apps/fiona-slack/src/agent/interaction-store.js @@ -3,13 +3,8 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. -import { CosmosClient } from '@azure/cosmos'; -import { DefaultAzureCredential } from '@azure/identity'; +import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js'; -const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT; -const COSMOS_KEY = process.env.COSMOS_KEY; -const COSMOS_CONNECTION_STRING = process.env.COSMOS_CONNECTION_STRING; -const COSMOS_DATABASE = process.env.COSMOS_DATABASE || 'chatbot'; const COSMOS_CONTAINER = process.env.COSMOS_INTERACTIONS_CONTAINER || 'interactions'; let warnedMissingConfig = false; @@ -58,16 +53,12 @@ function toNumericCode(error) { return null; } -function isEmulatorTarget() { - const target = `${COSMOS_CONNECTION_STRING ?? ''} ${COSMOS_ENDPOINT ?? ''}`.toLowerCase(); - return target.includes('localhost') || target.includes('127.0.0.1'); -} - function getRetryPolicy() { if (process.env.NODE_ENV === 'test') { return { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 5 }; } - if (isEmulatorTarget()) { + const config = getCosmosConfig(); + if (isEmulatorTarget(config.connectionString, config.endpoint)) { return { maxAttempts: 2, baseDelayMs: 200, maxDelayMs: 1000 }; } return { maxAttempts: 3, baseDelayMs: 150, maxDelayMs: 800 }; @@ -87,23 +78,8 @@ function getDelayMs(policy, attempt) { export async function getContainer(logger) { if (container) return container; - const connectionString = COSMOS_CONNECTION_STRING; - const endpoint = COSMOS_ENDPOINT; - const key = COSMOS_KEY; - const database = COSMOS_DATABASE; - const cosmosContainer = COSMOS_CONTAINER; - - let client; - if (connectionString) { - client = new CosmosClient({ connectionString }); - } else if (endpoint && key) { - client = new CosmosClient({ endpoint, key }); - } else if (endpoint) { - client = new CosmosClient({ - endpoint, - aadCredentials: new DefaultAzureCredential(), - }); - } else { + const config = getCosmosConfig(); + if (!config.connectionString && !config.endpoint) { if (!warnedMissingConfig) { warnedMissingConfig = true; logger?.warn?.( @@ -113,10 +89,13 @@ export async function getContainer(logger) { return null; } + const client = createCosmosClient(config, logger); + if (!client) return null; + try { - const { database: db } = await client.databases.createIfNotExists({ id: database }); + const { database: db } = await client.databases.createIfNotExists({ id: config.database }); const { container: c } = await db.containers.createIfNotExists({ - id: cosmosContainer, + id: COSMOS_CONTAINER, partitionKey: { paths: ['/deploymentType', '/userId'], kind: 'MultiHash', version: 2 }, }); container = c; diff --git a/apps/fiona-slack/src/agent/slack-users-store.js b/apps/fiona-slack/src/agent/slack-users-store.js index 7b47503a..0885ed38 100644 --- a/apps/fiona-slack/src/agent/slack-users-store.js +++ b/apps/fiona-slack/src/agent/slack-users-store.js @@ -3,9 +3,7 @@ // The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. // See the LICENSE and NOTICES files in the project root for more information. -import { CosmosClient } from '@azure/cosmos'; -import { DefaultAzureCredential } from '@azure/identity'; -import { isEmulatorTarget } from './cosmos-utils.js'; +import { createCosmosClient, getCosmosConfig, isEmulatorTarget } from './cosmos-utils.js'; let warnedMissingConfig = false; @@ -15,16 +13,6 @@ let cosmosClient = null; /** @type {Promise | null} */ let containerPromise = null; -function getCosmosConfig() { - return { - endpoint: process.env.COSMOS_ENDPOINT, - key: process.env.COSMOS_KEY, - connectionString: process.env.COSMOS_CONNECTION_STRING, - database: process.env.COSMOS_DATABASE || 'chatbot', - usersContainer: process.env.COSMOS_USERS_CONTAINER || 'slack-users', - }; -} - function resetContainerCache() { containerPromise = null; cosmosClient = null; @@ -32,14 +20,9 @@ function resetContainerCache() { async function _buildContainer(logger) { const config = getCosmosConfig(); + const usersContainer = process.env.COSMOS_USERS_CONTAINER || 'slack-users'; if (!cosmosClient) { - if (config.connectionString) { - cosmosClient = new CosmosClient(config.connectionString); - } else if (config.endpoint && config.key) { - cosmosClient = new CosmosClient({ endpoint: config.endpoint, key: config.key }); - } else if (config.endpoint) { - cosmosClient = new CosmosClient({ endpoint: config.endpoint, aadCredentials: new DefaultAzureCredential() }); - } else { + if (!config.connectionString && !config.endpoint) { if (!warnedMissingConfig) { warnedMissingConfig = true; logger?.warn?.( @@ -48,8 +31,10 @@ async function _buildContainer(logger) { } return null; } + cosmosClient = createCosmosClient(config, logger); + if (!cosmosClient) return null; } - return cosmosClient.database(config.database).container(config.usersContainer); + return cosmosClient.database(config.database).container(usersContainer); } /** diff --git a/apps/fiona-slack/tests/agent/conversation-capture-store.test.js b/apps/fiona-slack/tests/agent/conversation-capture-store.test.js index bec49010..e230d2dd 100644 --- a/apps/fiona-slack/tests/agent/conversation-capture-store.test.js +++ b/apps/fiona-slack/tests/agent/conversation-capture-store.test.js @@ -253,7 +253,7 @@ describe('conversation-capture-store - production auth guard', () => { expect(mockUpsert).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( - expect.stringContaining('does not support COSMOS_KEY auth in production'), + expect.stringContaining('COSMOS_KEY auth is not supported in production'), ); }); }); diff --git a/apps/fiona-slack/tests/agent/cosmos-utils.test.js b/apps/fiona-slack/tests/agent/cosmos-utils.test.js new file mode 100644 index 00000000..3f5d5773 --- /dev/null +++ b/apps/fiona-slack/tests/agent/cosmos-utils.test.js @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: Apache-2.0 +// Licensed to the Ed-Fi Alliance under one or more agreements. +// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0. +// See the LICENSE and NOTICES files in the project root for more information. + +import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals'; + +const MockCosmosClient = jest.fn().mockImplementation(() => ({})); +const MockDefaultAzureCredential = jest.fn().mockImplementation(() => ({})); + +jest.unstable_mockModule('@azure/cosmos', () => ({ + CosmosClient: MockCosmosClient, +})); + +jest.unstable_mockModule('@azure/identity', () => ({ + DefaultAzureCredential: MockDefaultAzureCredential, +})); + +let isEmulatorTarget, getCosmosConfig, createCosmosClient; + +beforeAll(async () => { + ({ isEmulatorTarget, getCosmosConfig, createCosmosClient } = await import('../../src/agent/cosmos-utils.js')); +}); + +afterEach(() => { + MockCosmosClient.mockClear(); + MockDefaultAzureCredential.mockClear(); +}); + +// --------------------------------------------------------------------------- +// isEmulatorTarget +// --------------------------------------------------------------------------- + +describe('isEmulatorTarget', () => { + it('returns true for localhost in connection string', () => { + expect(isEmulatorTarget('AccountEndpoint=https://localhost:8081/;AccountKey=xyz', undefined)).toBe(true); + }); + + it('returns true for 127.0.0.1 in connection string', () => { + expect(isEmulatorTarget('AccountEndpoint=https://127.0.0.1:8081/;AccountKey=xyz', undefined)).toBe(true); + }); + + it('returns true for localhost in endpoint', () => { + expect(isEmulatorTarget(undefined, 'https://localhost:8081')).toBe(true); + }); + + it('returns true for 127.0.0.1 in endpoint', () => { + expect(isEmulatorTarget(undefined, 'https://127.0.0.1:8081')).toBe(true); + }); + + it('returns false for a cloud endpoint', () => { + expect(isEmulatorTarget(undefined, 'https://myaccount.documents.azure.com:443')).toBe(false); + }); + + it('returns false when both arguments are undefined', () => { + expect(isEmulatorTarget(undefined, undefined)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// getCosmosConfig +// --------------------------------------------------------------------------- + +describe('getCosmosConfig', () => { + afterEach(() => { + delete process.env.COSMOS_CONNECTION_STRING; + delete process.env.COSMOS_ENDPOINT; + delete process.env.COSMOS_KEY; + delete process.env.COSMOS_DATABASE; + }); + + it('returns undefined connection fields when env vars are absent', () => { + const config = getCosmosConfig(); + expect(config.connectionString).toBeUndefined(); + expect(config.endpoint).toBeUndefined(); + expect(config.key).toBeUndefined(); + }); + + it('uses chatbot as the default database', () => { + const config = getCosmosConfig(); + expect(config.database).toBe('chatbot'); + }); + + it('reads COSMOS_CONNECTION_STRING from env', () => { + process.env.COSMOS_CONNECTION_STRING = 'AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=abc'; + const config = getCosmosConfig(); + expect(config.connectionString).toBe(process.env.COSMOS_CONNECTION_STRING); + }); + + it('reads COSMOS_ENDPOINT and COSMOS_KEY from env', () => { + process.env.COSMOS_ENDPOINT = 'https://test.documents.azure.com:443/'; + process.env.COSMOS_KEY = 'secret-key'; + const config = getCosmosConfig(); + expect(config.endpoint).toBe('https://test.documents.azure.com:443/'); + expect(config.key).toBe('secret-key'); + }); + + it('reads COSMOS_DATABASE from env', () => { + process.env.COSMOS_DATABASE = 'mydb'; + const config = getCosmosConfig(); + expect(config.database).toBe('mydb'); + }); + + it('applies overrides over env vars', () => { + process.env.COSMOS_ENDPOINT = 'https://env-endpoint.documents.azure.com/'; + const config = getCosmosConfig({ endpoint: 'https://localhost:8081', key: 'emulator-key' }); + expect(config.endpoint).toBe('https://localhost:8081'); + expect(config.key).toBe('emulator-key'); + }); + + it('override can supply a connection string when env var is absent', () => { + const config = getCosmosConfig({ connectionString: 'AccountEndpoint=https://localhost:8081/;AccountKey=xyz' }); + expect(config.connectionString).toBe('AccountEndpoint=https://localhost:8081/;AccountKey=xyz'); + }); +}); + +// --------------------------------------------------------------------------- +// createCosmosClient +// --------------------------------------------------------------------------- + +describe('createCosmosClient - connection string', () => { + it('returns a CosmosClient constructed with the connection string', () => { + const config = { connectionString: 'AccountEndpoint=https://test.documents.azure.com:443/;AccountKey=abc', endpoint: undefined, key: undefined, database: 'chatbot' }; + const client = createCosmosClient(config); + expect(MockCosmosClient).toHaveBeenCalledTimes(1); + expect(MockCosmosClient).toHaveBeenCalledWith(config.connectionString); + expect(client).not.toBeNull(); + }); +}); + +describe('createCosmosClient - endpoint + key (non-production)', () => { + afterEach(() => { + delete process.env.DEPLOYMENT_TYPE; + }); + + it('returns a CosmosClient with endpoint and key in dev', () => { + process.env.DEPLOYMENT_TYPE = 'local'; + const config = { connectionString: undefined, endpoint: 'https://test.documents.azure.com:443/', key: 'secret', database: 'chatbot' }; + const client = createCosmosClient(config); + expect(MockCosmosClient).toHaveBeenCalledWith({ endpoint: config.endpoint, key: config.key }); + expect(client).not.toBeNull(); + }); + + it('returns a CosmosClient with endpoint and key when DEPLOYMENT_TYPE is not set', () => { + delete process.env.DEPLOYMENT_TYPE; + const config = { connectionString: undefined, endpoint: 'https://test.documents.azure.com:443/', key: 'secret', database: 'chatbot' }; + const client = createCosmosClient(config); + expect(client).not.toBeNull(); + }); +}); + +describe('createCosmosClient - managed identity (endpoint only)', () => { + it('returns a CosmosClient with DefaultAzureCredential when only endpoint is set', () => { + const config = { connectionString: undefined, endpoint: 'https://test.documents.azure.com:443/', key: undefined, database: 'chatbot' }; + const client = createCosmosClient(config); + expect(MockDefaultAzureCredential).toHaveBeenCalledTimes(1); + expect(MockCosmosClient).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: config.endpoint, aadCredentials: expect.anything() }), + ); + expect(client).not.toBeNull(); + }); +}); + +describe('createCosmosClient - no config', () => { + it('returns null when neither connectionString nor endpoint is set', () => { + const config = { connectionString: undefined, endpoint: undefined, key: undefined, database: 'chatbot' }; + const client = createCosmosClient(config); + expect(client).toBeNull(); + expect(MockCosmosClient).not.toHaveBeenCalled(); + }); + + it('does not log a warning when config is absent', () => { + const logger = { warn: jest.fn() }; + const config = { connectionString: undefined, endpoint: undefined, key: undefined, database: 'chatbot' }; + createCosmosClient(config, logger); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); + +describe('createCosmosClient - production auth guard', () => { + let captureConversation; + + beforeAll(async () => { + // Load a fresh cosmos-utils module with production env vars set so the + // warnedInsecureProductionCosmosKey flag starts at false. + process.env.DEPLOYMENT_TYPE = 'production'; + jest.resetModules(); + jest.unstable_mockModule('@azure/cosmos', () => ({ CosmosClient: MockCosmosClient })); + jest.unstable_mockModule('@azure/identity', () => ({ DefaultAzureCredential: MockDefaultAzureCredential })); + ({ createCosmosClient } = await import('../../src/agent/cosmos-utils.js')); + void captureConversation; // suppress unused variable warning + }); + + afterEach(() => { + delete process.env.DEPLOYMENT_TYPE; + }); + + it('returns null and logs warning when endpoint + key auth is used in production', () => { + process.env.DEPLOYMENT_TYPE = 'production'; + const logger = { warn: jest.fn() }; + const config = { connectionString: undefined, endpoint: 'https://prod.documents.azure.com:443/', key: 'secret', database: 'chatbot' }; + const client = createCosmosClient(config, logger); + expect(client).toBeNull(); + expect(MockCosmosClient).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('COSMOS_KEY auth is not supported in production'), + ); + }); + + it('still allows connection string auth in production', () => { + process.env.DEPLOYMENT_TYPE = 'production'; + MockCosmosClient.mockClear(); + const config = { connectionString: 'AccountEndpoint=https://prod.documents.azure.com:443/;AccountKey=abc', endpoint: undefined, key: undefined, database: 'chatbot' }; + const client = createCosmosClient(config); + expect(client).not.toBeNull(); + expect(MockCosmosClient).toHaveBeenCalledTimes(1); + }); + + it('still allows managed identity (endpoint only) in production', () => { + process.env.DEPLOYMENT_TYPE = 'production'; + MockCosmosClient.mockClear(); + MockDefaultAzureCredential.mockClear(); + const config = { connectionString: undefined, endpoint: 'https://prod.documents.azure.com:443/', key: undefined, database: 'chatbot' }; + const client = createCosmosClient(config); + expect(client).not.toBeNull(); + expect(MockDefaultAzureCredential).toHaveBeenCalledTimes(1); + }); +});