diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts index 32eb7d3d717..d831cd78a8f 100644 --- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Models/Workflow.ts @@ -122,6 +122,7 @@ export interface ConnectionAndAppSetting { connectionData: ServiceProviderConnectionModel | FunctionConnectionModel; settings: Record; pathLocation: string[]; + isUpdate?: boolean; } export interface ConnectionsData { diff --git a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts index 3c3b47c9220..1ae8f9c190c 100644 --- a/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts +++ b/apps/Standalone/src/designer/app/AzureLogicAppsDesigner/Utilities/Workflow.ts @@ -82,7 +82,7 @@ export class WorkflowUtility { } export function addConnectionInJson(connectionAndSetting: ConnectionAndAppSetting, connectionsJson: ConnectionsData): void { - const { connectionData, connectionKey, pathLocation } = connectionAndSetting; + const { connectionData, connectionKey, pathLocation, isUpdate } = connectionAndSetting; let pathToSetConnectionsData: any = connectionsJson; @@ -94,7 +94,7 @@ export function addConnectionInJson(connectionAndSetting: ConnectionAndAppSettin pathToSetConnectionsData = pathToSetConnectionsData[path]; } - if (pathToSetConnectionsData && pathToSetConnectionsData[connectionKey]) { + if (pathToSetConnectionsData && pathToSetConnectionsData[connectionKey] && !isUpdate) { // TODO: To show this in a notification of info bar on the blade. // const message = 'ConnectionKeyAlreadyExist - Connection key \'{0}\' already exists.'.format(connectionKey); return; diff --git a/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts b/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts index e34c04a69f4..98ba6735d94 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/__test__/connection.test.ts @@ -4,6 +4,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const mockUpdateConnectionReferenceWithMSIInLocalSettings = vi.fn(); const mockWriteLocalSettingsFile = vi.fn(); const mockGetLocalSettingsJson = vi.fn(); +const mockPathExists = vi.fn(); +const mockPathExistsSync = vi.fn(); +const mockReadFile = vi.fn(); +const mockWriteFormattedJson = vi.fn(); +const mockIsCSharpProject = vi.fn(); +const mockAddNewFileInCSharpProject = vi.fn(); +const mockShouldParameterizeConnections = vi.fn(); // Mock dependencies vi.mock('../../../appSettings/localSettings', () => ({ @@ -11,9 +18,110 @@ vi.mock('../../../appSettings/localSettings', () => ({ writeLocalSettingsFile: mockWriteLocalSettingsFile, })); +vi.mock('fs-extra', () => ({ + pathExists: mockPathExists, + pathExistsSync: mockPathExistsSync, + readFile: mockReadFile, +})); + +vi.mock('../../fs', () => ({ + writeFormattedJson: mockWriteFormattedJson, +})); + +vi.mock('../../detectProjectLanguage', () => ({ + isCSharpProject: mockIsCSharpProject, +})); + +vi.mock('../updateBuildFile', () => ({ + addNewFileInCSharpProject: mockAddNewFileInCSharpProject, +})); + +vi.mock('../../vsCodeConfig/settings', () => ({ + shouldParameterizeConnections: mockShouldParameterizeConnections, +})); + // Import the module after mocks are set up const mockModule = await import('../connection'); +describe('addConnectionDataInJson', () => { + const context = {} as any; + const projectPath = 'C:\\test\\project'; + const existingConnection = { + displayName: 'Old knowledge hub', + cosmosDB: { + endpoint: 'https://old.documents.azure.com', + authentication: { type: 'ManagedServiceIdentity' }, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPathExists.mockResolvedValue(true); + mockPathExistsSync.mockReturnValue(true); + mockReadFile.mockResolvedValue( + Buffer.from( + JSON.stringify({ + knowledgeHubConnections: { + HubConnection: existingConnection, + }, + }) + ) + ); + mockShouldParameterizeConnections.mockReturnValue(false); + }); + + it('preserves duplicate connections for create operations', async () => { + const connectionData = { displayName: 'New knowledge hub' }; + + await mockModule.addConnectionDataInJson( + context, + projectPath, + { + connectionKey: 'HubConnection', + connectionData, + settings: {}, + pathLocation: ['knowledgeHubConnections'], + }, + {} + ); + + expect(mockWriteFormattedJson).not.toHaveBeenCalled(); + }); + + it('overwrites an existing connection for update operations', async () => { + const connectionData = { + displayName: 'Updated knowledge hub', + cosmosDB: { + endpoint: 'https://new.documents.azure.com', + resourceId: '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/new', + authentication: { type: 'ManagedServiceIdentity' }, + }, + }; + + await mockModule.addConnectionDataInJson( + context, + projectPath, + { + connectionKey: 'HubConnection', + connectionData, + settings: {}, + pathLocation: ['knowledgeHubConnections'], + isUpdate: true, + }, + {} + ); + + expect(mockWriteFormattedJson).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + knowledgeHubConnections: { + HubConnection: connectionData, + }, + }) + ); + }); +}); + describe('updateConnectionReferencesLocalMSI - parallel processing', () => { const testProjectPath = '/test/project'; const testConnectionReferences = { diff --git a/apps/vs-code-designer/src/app/utils/codeless/connection.ts b/apps/vs-code-designer/src/app/utils/codeless/connection.ts index f2169198531..5198579b89e 100644 --- a/apps/vs-code-designer/src/app/utils/codeless/connection.ts +++ b/apps/vs-code-designer/src/app/utils/codeless/connection.ts @@ -111,7 +111,7 @@ export async function getLogicAppProjectRoot(context: IActionContext, workflowFi return projectRoot; } -async function addConnectionDataInJson( +export async function addConnectionDataInJson( context: IActionContext, functionAppPath: string, connectionAndAppSetting: ConnectionAndAppSetting, @@ -123,7 +123,7 @@ async function addConnectionDataInJson( const connectionsJsonString = await getConnectionsJson(functionAppPath); const connectionsJson = connectionsJsonString === '' ? {} : JSON.parse(connectionsJsonString); - const { connectionData, connectionKey, pathLocation, settings } = connectionAndAppSetting; + const { connectionData, connectionKey, pathLocation, settings, isUpdate } = connectionAndAppSetting; let pathToSetConnectionsData = connectionsJson; @@ -135,7 +135,7 @@ async function addConnectionDataInJson( pathToSetConnectionsData = pathToSetConnectionsData[path]; } - if (pathToSetConnectionsData && pathToSetConnectionsData[connectionKey]) { + if (pathToSetConnectionsData && pathToSetConnectionsData[connectionKey] && !isUpdate) { const message: string = localize('ConnectionKeyAlreadyExist', "Connection key '{0}' already exists.", connectionKey); await vscode.window.showErrorMessage(message, localize('OK', 'OK')); return; @@ -444,7 +444,11 @@ export async function getCustomCodeToUpdate( return { customCodeFiles: filteredCustomCodeMapping, appFiles }; } -export async function saveCustomCodeStandard(context: IActionContext, workflowFilePath: string, allCustomCodeFiles?: AllCustomCodeFiles): Promise { +export async function saveCustomCodeStandard( + context: IActionContext, + workflowFilePath: string, + allCustomCodeFiles?: AllCustomCodeFiles +): Promise { const { customCodeFiles: customCode, appFiles } = allCustomCodeFiles ?? {}; if (!customCode || Object.keys(customCode).length === 0) { return; @@ -456,15 +460,14 @@ export async function saveCustomCodeStandard(context: IActionContext, workflowFi const { isModified, isDeleted, fileData } = customCodeData; if (isDeleted) { return deleteCustomCode(workflowFolderPath, fileName); - } else if (isModified && fileData) { + } + if (isModified && fileData) { return uploadCustomCode(workflowFolderPath, fileName, fileData); } return Promise.resolve(); }); // upload the app files needed for powershell actions - const appFilePromises = Object.entries(appFiles ?? {}).map(([fileName, fileData]) => - uploadCustomCode(projectPath, fileName, fileData) - ); + const appFilePromises = Object.entries(appFiles ?? {}).map(([fileName, fileData]) => uploadCustomCode(projectPath, fileName, fileData)); await Promise.all([...customCodePromises, ...appFilePromises]); } catch (error) { const errorMessage = `Failed to save custom code: ${error}`; @@ -991,7 +994,7 @@ async function isMISettingEnabled(context: IActionContext, projectPath: string): try { const localSettings = await getLocalSettingsJson(context, projectPath); const authMethod = localSettings.Values?.[workflowAuthenticationMethodKey]; - return authMethod?.toLowerCase() === workflowAuthenticationMethodMIValue.toLowerCase() + return authMethod?.toLowerCase() === workflowAuthenticationMethodMIValue.toLowerCase(); } catch { return false; } diff --git a/apps/vs-code-react/src/app/designer/servicesHelper.ts b/apps/vs-code-react/src/app/designer/servicesHelper.ts index 8a34cd31e3a..ba88d41691c 100644 --- a/apps/vs-code-react/src/app/designer/servicesHelper.ts +++ b/apps/vs-code-react/src/app/designer/servicesHelper.ts @@ -434,7 +434,7 @@ const addConnectionInJson = ( connectionAndSetting: ConnectionAndAppSetting, connectionsJson: ConnectionsData ): ConnectionsData => { - const { connectionData, connectionKey, pathLocation } = connectionAndSetting; + const { connectionData, connectionKey, pathLocation, isUpdate } = connectionAndSetting; const pathToSetConnectionsData: any = clone(connectionsJson); for (const path of pathLocation) { @@ -442,7 +442,7 @@ const addConnectionInJson = ( pathToSetConnectionsData[path] = {}; } - if (pathToSetConnectionsData && pathToSetConnectionsData[path][connectionKey]) { + if (pathToSetConnectionsData && pathToSetConnectionsData[path][connectionKey] && !isUpdate) { break; } pathToSetConnectionsData[path][connectionKey] = connectionData; diff --git a/libs/designer/src/lib/core/knowledge/utils/__test__/connection.spec.ts b/libs/designer/src/lib/core/knowledge/utils/__test__/connection.spec.ts index 36dfa3b3dba..22af05cad37 100644 --- a/libs/designer/src/lib/core/knowledge/utils/__test__/connection.spec.ts +++ b/libs/designer/src/lib/core/knowledge/utils/__test__/connection.spec.ts @@ -114,6 +114,10 @@ describe('knowledge connection utils', () => { expect(result.values[0].parameters).toHaveProperty('cosmosDBKey'); expect(result.values[0].parameters).toHaveProperty('cosmosDbServiceAccountId'); expect(result.values[0].parameters).toHaveProperty('cosmosDBEndpoint'); + expect(result.values[0].parameters.cosmosDbServiceAccountId.uiDefinition.constraints.serializationPath).toEqual([ + 'cosmosDB', + 'resourceId', + ]); }); it('excludes cosmosDBKey parameter from ManagedServiceIdentity authentication', () => { @@ -179,9 +183,18 @@ describe('knowledge connection utils', () => { expect(connector).toEqual({ id: '/dummy/knowledgehub' }); expect(connectionInfo.displayName).toBe('My Connection'); expect(connectionInfo.connectionParameters).toBe(parameterValues); + expect(connectionInfo.isUpdate).toBe(false); expect(options.connectionMetadata).toEqual({ required: true, type: 'KnowledgeHub' }); }); + it('marks an edited knowledge connection as an update', async () => { + mockCreateConnection.mockResolvedValue({ id: '/connections/knowledgeHub' }); + + await createOrUpdateConnection({ displayName: 'My Connection' }, false); + + expect(mockCreateConnection.mock.calls[0][2].isUpdate).toBe(true); + }); + it('updates query cache after successful connection creation', async () => { const createdConnection = { id: '/connections/knowledgeHub' }; mockCreateConnection.mockResolvedValue(createdConnection); @@ -251,8 +264,7 @@ describe('knowledge connection utils', () => { it('returns connection parameters excluding non-serializable ones', () => { const result = getConnectionParametersForEdit(intl, undefined); - // cosmosDbServiceAccountId and cognitiveServiceAccountId have serialize: false - expect(result.connectionParameters).not.toHaveProperty('cosmosDbServiceAccountId'); + expect(result.connectionParameters).toHaveProperty('cosmosDbServiceAccountId'); expect(result.connectionParameters).not.toHaveProperty('cognitiveServiceAccountId'); // These should be present as they are serializable @@ -272,6 +284,7 @@ describe('knowledge connection utils', () => { value: { cosmosDB: { endpoint: 'https://cosmos.test.com', + resourceId: '/subscriptions/1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/db', authentication: { type: 'Key', key: 'cosmos-secret-key', @@ -300,6 +313,9 @@ describe('knowledge connection utils', () => { const result = getConnectionParametersForEdit(intl, connection); expect(result.parameterValues.cosmosDBEndpoint).toBe('https://cosmos.test.com'); + expect(result.parameterValues.cosmosDbServiceAccountId).toBe( + '/subscriptions/1/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/db' + ); expect(result.parameterValues.cosmosDBKey).toBe('cosmos-secret-key'); expect(result.parameterValues.cosmosDBAuthenticationType).toBe('Key'); expect(result.parameterValues.openAIEndpoint).toBe('https://openai.test.com'); diff --git a/libs/designer/src/lib/core/knowledge/utils/connection.ts b/libs/designer/src/lib/core/knowledge/utils/connection.ts index a75dcda1d4a..debea7a26a0 100644 --- a/libs/designer/src/lib/core/knowledge/utils/connection.ts +++ b/libs/designer/src/lib/core/knowledge/utils/connection.ts @@ -39,7 +39,7 @@ const getAllConnectionParameters = (intl: IntlShape) => { constraints: { clearText: true, required: 'true', - serialize: false, + serializationPath: ['cosmosDB', 'resourceId'], }, }, } as ConnectionParameter, @@ -374,7 +374,7 @@ export const createOrUpdateConnection = async (parameterValues: Record; alternativeParameterValues?: Record; displayName?: string; + isUpdate?: boolean; features?: ConnectionFeatureType; parameterName?: string; appSettings?: Record; diff --git a/libs/logic-apps-shared/src/designer-client-services/lib/standard/__tests__/connection.spec.ts b/libs/logic-apps-shared/src/designer-client-services/lib/standard/__tests__/connection.spec.ts index b3029cacf73..a0907899a67 100644 --- a/libs/logic-apps-shared/src/designer-client-services/lib/standard/__tests__/connection.spec.ts +++ b/libs/logic-apps-shared/src/designer-client-services/lib/standard/__tests__/connection.spec.ts @@ -96,6 +96,110 @@ describe('StandardConnectionService', () => { expect(mcpConnection).toBeDefined(); expect(mcpConnection?.properties.connectionParameters?.authentication).toBeUndefined(); }); + + it('should load legacy Knowledge Hub connections without a Cosmos DB resource ID', async () => { + const legacyConnection = { + displayName: 'Legacy Knowledge Hub', + completionsOpenAI: { + completionsModel: 'gpt-4o', + openAI: { endpoint: 'https://openai.openai.azure.com', authentication: { type: 'ManagedServiceIdentity' } }, + }, + embeddingsOpenAI: { + embeddingsModel: 'text-embedding-3-small', + openAI: { endpoint: 'https://openai.openai.azure.com', authentication: { type: 'ManagedServiceIdentity' } }, + }, + cosmosDB: { + endpoint: 'https://cosmos.documents.azure.com', + authentication: { type: 'ManagedServiceIdentity' }, + }, + }; + const service = new StandardConnectionService({ + ...createMockOptions({ + knowledgeHubConnections: { + HubConnection: legacyConnection, + }, + }), + }); + + const connections = await service.getConnections(); + const connection = connections.find((item) => item.name === 'HubConnection'); + + expect(connection?.properties.displayName).toBe('Legacy Knowledge Hub'); + expect(connection?.properties.connectionParameters?.data?.metadata?.value.cosmosDB).toEqual(legacyConnection.cosmosDB); + }); + }); + + describe('createConnection - Knowledge Hub', () => { + it('should store the selected Cosmos DB account resource ID', async () => { + InitLoggerService([ + { + log: vi.fn(), + startTrace: vi.fn().mockReturnValue('mock-trace-id'), + endTrace: vi.fn(), + logErrorWithFormatting: vi.fn(), + }, + ]); + let capturedConnectionData: any; + const writeConnection = vi.fn().mockImplementation((data: any) => { + capturedConnectionData = data; + return Promise.resolve(); + }); + const options = createMockOptions({}); + options.writeConnection = writeConnection; + const service = new StandardConnectionService(options); + const resourceId = '/subscriptions/sub/resourceGroups/rg/providers/Microsoft.DocumentDB/databaseAccounts/cosmos'; + const connectionInfo = { + displayName: 'Knowledge Hub', + isUpdate: true, + connectionParameters: { + displayName: 'Knowledge Hub', + cosmosDbServiceAccountId: resourceId, + cosmosDBEndpoint: 'https://cosmos.documents.azure.com', + cosmosDBAuthenticationType: 'ManagedServiceIdentity', + openAIEndpoint: 'https://openai.openai.azure.com', + openAIAuthenticationType: 'ManagedServiceIdentity', + openAICompletionsModel: 'gpt-4o', + openAIEmbeddingsModel: 'text-embedding-3-small', + }, + }; + const parametersMetadata = { + connectionMetadata: { type: ConnectionType.KnowledgeHub }, + connectionParameters: { + cosmosDbServiceAccountId: { + uiDefinition: { constraints: { serializationPath: ['cosmosDB', 'resourceId'] } }, + }, + cosmosDBEndpoint: { + uiDefinition: { constraints: { serializationPath: ['cosmosDB', 'endpoint'] } }, + }, + cosmosDBAuthenticationType: { + uiDefinition: { constraints: { serializationPath: ['cosmosDB', 'authentication', 'type'] } }, + }, + openAIEndpoint: { + uiDefinition: { constraints: { serializationPath: ['openAI', 'endpoint'] } }, + }, + openAIAuthenticationType: { + uiDefinition: { constraints: { serializationPath: ['openAI', 'authentication', 'type'] } }, + }, + openAICompletionsModel: { + uiDefinition: { constraints: { serializationPath: ['completionsOpenAI', 'completionsModel'] } }, + }, + openAIEmbeddingsModel: { + uiDefinition: { constraints: { serializationPath: ['embeddingsOpenAI', 'embeddingsModel'] } }, + }, + }, + }; + + await service.createConnection('HubConnection', { id: '/dummy/knowledgehub' } as any, connectionInfo, parametersMetadata as any); + + expect(writeConnection).toHaveBeenCalledOnce(); + expect(capturedConnectionData.pathLocation).toEqual(['knowledgeHubConnections']); + expect(capturedConnectionData.isUpdate).toBe(true); + expect(capturedConnectionData.connectionData.cosmosDB).toEqual({ + endpoint: 'https://cosmos.documents.azure.com', + resourceId, + authentication: { type: 'ManagedServiceIdentity' }, + }); + }); }); describe('createConnection - MCP with ManagedServiceIdentity', () => { diff --git a/libs/logic-apps-shared/src/designer-client-services/lib/standard/connection.ts b/libs/logic-apps-shared/src/designer-client-services/lib/standard/connection.ts index 08779ee1efb..9603ed60e4a 100644 --- a/libs/logic-apps-shared/src/designer-client-services/lib/standard/connection.ts +++ b/libs/logic-apps-shared/src/designer-client-services/lib/standard/connection.ts @@ -111,6 +111,7 @@ export interface KnowledgeHubConnectionModel { key?: string; }; endpoint: string; + resourceId?: string; }; displayName: string; } @@ -120,6 +121,7 @@ export interface ConnectionAndAppSetting { connectionData: T; settings: Record; pathLocation: string[]; + isUpdate?: boolean; } export interface AgentMcpConnectionModel { @@ -997,6 +999,7 @@ function convertToKnowledgeHubConnectionsData( connectionData: connectionParameterValues, settings, pathLocation: [knowledgeHubLocation], + isUpdate: connectionInfo.isUpdate, }; const rawConnection = createCopy(connectionsData.connectionData); rawConnection.parameterValues = rawParameterValues;