diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index fbeba76af4..1f544d50eb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -13,6 +13,7 @@ import { import { AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; import { aggregateOrchestratorActionsReport } from 'src/engine/workspace-manager/workspace-migration/utils/aggregate-orchestrator-actions-report.util'; import { crossEntityTransversalValidation } from 'src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util'; +import { mergeOrchestratorFailureReports } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; import { WorkspaceMigrationFieldPermissionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service'; @@ -822,14 +823,16 @@ export class WorkspaceMigrationBuildOrchestratorService { } } - const { objectMetadata, viewField } = crossEntityTransversalValidation({ + const crossEntityFailureReport = crossEntityTransversalValidation({ optimisticUniversalFlatMaps: optimisticAllFlatEntityMaps, orchestratorActionsReport, preDeletionFlatViewFieldMaps, }); - orchestratorFailureReport.objectMetadata.push(...objectMetadata); - orchestratorFailureReport.viewField.push(...viewField); + mergeOrchestratorFailureReports({ + target: orchestratorFailureReport, + source: crossEntityFailureReport, + }); const allErrors = Object.values(orchestratorFailureReport); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/build-all-universal-identifier-map.util.spec.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/build-all-universal-identifier-map.util.spec.ts new file mode 100644 index 0000000000..7a8201e158 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/__tests__/build-all-universal-identifier-map.util.spec.ts @@ -0,0 +1,122 @@ +import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; +import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type'; +import { buildAllUniversalIdentifierMap } from 'src/engine/workspace-manager/workspace-migration/utils/build-all-universal-identifier-map.util'; + +describe('buildAllUniversalIdentifierMap', () => { + it('should return an empty map when all flat entity maps are empty', () => { + const allFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + const result = buildAllUniversalIdentifierMap(allFlatEntityMaps); + + expect(result.size).toBe(0); + }); + + it('should collect a single entity from one metadata type', () => { + const allFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + allFlatEntityMaps.flatRoleMaps.byUniversalIdentifier = { + 'role-uid-1': { + universalIdentifier: 'role-uid-1', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'role'>, + }; + + const result = buildAllUniversalIdentifierMap(allFlatEntityMaps); + + expect(result.size).toBe(1); + expect(result.get('role-uid-1')).toEqual({ + metadataName: 'role', + applicationUniversalIdentifier: 'app-uid-1', + }); + }); + + it('should collect entities across multiple metadata types', () => { + const allFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + allFlatEntityMaps.flatRoleMaps.byUniversalIdentifier = { + 'role-uid-1': { + universalIdentifier: 'role-uid-1', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'role'>, + }; + + allFlatEntityMaps.flatObjectPermissionMaps.byUniversalIdentifier = { + 'perm-uid-1': { + universalIdentifier: 'perm-uid-1', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'objectPermission'>, + }; + + allFlatEntityMaps.flatViewMaps.byUniversalIdentifier = { + 'view-uid-1': { + universalIdentifier: 'view-uid-1', + applicationUniversalIdentifier: 'app-uid-2', + } as MetadataFlatEntity<'view'>, + }; + + const result = buildAllUniversalIdentifierMap(allFlatEntityMaps); + + expect(result.size).toBe(3); + expect(result.get('role-uid-1')).toEqual({ + metadataName: 'role', + applicationUniversalIdentifier: 'app-uid-1', + }); + expect(result.get('perm-uid-1')).toEqual({ + metadataName: 'objectPermission', + applicationUniversalIdentifier: 'app-uid-1', + }); + expect(result.get('view-uid-1')).toEqual({ + metadataName: 'view', + applicationUniversalIdentifier: 'app-uid-2', + }); + }); + + it('should skip undefined entries in byUniversalIdentifier', () => { + const allFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + allFlatEntityMaps.flatRoleMaps.byUniversalIdentifier = { + 'role-uid-1': undefined, + 'role-uid-2': { + universalIdentifier: 'role-uid-2', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'role'>, + }; + + const result = buildAllUniversalIdentifierMap(allFlatEntityMaps); + + expect(result.size).toBe(1); + expect(result.has('role-uid-1')).toBe(false); + expect(result.get('role-uid-2')).toEqual({ + metadataName: 'role', + applicationUniversalIdentifier: 'app-uid-1', + }); + }); + + it('should handle multiple entities within the same metadata type', () => { + const allFlatEntityMaps = createEmptyAllFlatEntityMaps(); + + allFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier = { + 'field-uid-1': { + universalIdentifier: 'field-uid-1', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'fieldMetadata'>, + 'field-uid-2': { + universalIdentifier: 'field-uid-2', + applicationUniversalIdentifier: 'app-uid-1', + } as MetadataFlatEntity<'fieldMetadata'>, + 'field-uid-3': { + universalIdentifier: 'field-uid-3', + applicationUniversalIdentifier: 'app-uid-2', + } as MetadataFlatEntity<'fieldMetadata'>, + }; + + const result = buildAllUniversalIdentifierMap(allFlatEntityMaps); + + expect(result.size).toBe(3); + expect(result.get('field-uid-1')?.metadataName).toBe('fieldMetadata'); + expect(result.get('field-uid-2')?.metadataName).toBe('fieldMetadata'); + expect(result.get('field-uid-3')?.applicationUniversalIdentifier).toBe( + 'app-uid-2', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/build-all-universal-identifier-map.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/build-all-universal-identifier-map.util.ts new file mode 100644 index 0000000000..672d0b1ac5 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/build-all-universal-identifier-map.util.ts @@ -0,0 +1,43 @@ +import { + ALL_METADATA_NAME, + type AllMetadataName, +} from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; + +export type UniversalIdentifierOwner = { + metadataName: AllMetadataName; + applicationUniversalIdentifier: string; +}; + +export type AllUniversalIdentifierMap = Map; + +export const buildAllUniversalIdentifierMap = ( + allFlatEntityMaps: AllUniversalFlatEntityMaps, +): AllUniversalIdentifierMap => { + const universalIdentifierMap: AllUniversalIdentifierMap = new Map(); + + for (const metadataName of Object.values(ALL_METADATA_NAME)) { + const flatEntityMapsKey = getMetadataFlatEntityMapsKey(metadataName); + const flatEntityMaps = allFlatEntityMaps[flatEntityMapsKey]; + + if (!isDefined(flatEntityMaps)) { + continue; + } + + for (const [universalIdentifier, entity] of Object.entries( + flatEntityMaps.byUniversalIdentifier, + )) { + if (isDefined(entity)) { + universalIdentifierMap.set(universalIdentifier, { + metadataName, + applicationUniversalIdentifier: entity.applicationUniversalIdentifier, + }); + } + } + } + + return universalIdentifierMap; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts index 37b7d32015..60d5d1766f 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util.ts @@ -4,9 +4,11 @@ import { type OrchestratorActionsReport, type OrchestratorFailureReport, } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; +import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-failure-report.constant'; import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; import { type UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; +import { validateUniversalIdentifierCrossEntityUniquenessThroughReportMutation } from 'src/engine/workspace-manager/workspace-migration/utils/validate-universal-identifier-cross-entity-uniqueness-through-report-mutation.util'; export const crossEntityTransversalValidation = ({ optimisticUniversalFlatMaps, @@ -16,7 +18,9 @@ export const crossEntityTransversalValidation = ({ optimisticUniversalFlatMaps: AllUniversalFlatEntityMaps; orchestratorActionsReport: OrchestratorActionsReport; preDeletionFlatViewFieldMaps: UniversalFlatEntityMaps; -}): Pick => { +}): OrchestratorFailureReport => { + const crossEntityFailureReport = EMPTY_ORCHESTRATOR_FAILURE_REPORT(); + const { objectMetadata } = validateObjectMetadataCrossEntity({ optimisticUniversalFlatMaps, orchestratorActionsReport, @@ -28,5 +32,14 @@ export const crossEntityTransversalValidation = ({ preDeletionFlatViewFieldMaps, }); - return { objectMetadata, viewField }; + crossEntityFailureReport.objectMetadata.push(...objectMetadata); + crossEntityFailureReport.viewField.push(...viewField); + + validateUniversalIdentifierCrossEntityUniquenessThroughReportMutation({ + optimisticUniversalFlatMaps, + orchestratorActionsReport, + orchestratorFailureReport: crossEntityFailureReport, + }); + + return crossEntityFailureReport; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util.ts new file mode 100644 index 0000000000..4343921173 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util.ts @@ -0,0 +1,46 @@ +import { + ALL_METADATA_NAME, + type AllMetadataName, +} from 'twenty-shared/metadata'; + +import { type WorkspaceMigrationActionType } from 'src/engine/metadata-modules/flat-entity/types/metadata-workspace-migration-action.type'; +import { type OrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; + +type AnyFailedValidation = FailedFlatEntityValidation< + AllMetadataName, + WorkspaceMigrationActionType +>; + +export const pushToOrchestratorFailureReport = < + TMetadataName extends AllMetadataName, +>({ + report, + metadataName, + items, +}: { + report: OrchestratorFailureReport; + metadataName: TMetadataName; + items: FailedFlatEntityValidation< + TMetadataName, + WorkspaceMigrationActionType + >[]; +}): void => { + (report[metadataName] as AnyFailedValidation[]).push(...items); +}; + +export const mergeOrchestratorFailureReports = ({ + target, + source, +}: { + target: OrchestratorFailureReport; + source: OrchestratorFailureReport; +}): void => { + for (const metadataName of Object.values(ALL_METADATA_NAME)) { + pushToOrchestratorFailureReport({ + report: target, + metadataName, + items: source[metadataName], + }); + } +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/validate-universal-identifier-cross-entity-uniqueness-through-report-mutation.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/validate-universal-identifier-cross-entity-uniqueness-through-report-mutation.util.ts new file mode 100644 index 0000000000..8c76371e66 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/utils/validate-universal-identifier-cross-entity-uniqueness-through-report-mutation.util.ts @@ -0,0 +1,66 @@ +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { FlatEntityMapsExceptionCode } from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { + type OrchestratorActionsReport, + type OrchestratorFailureReport, +} from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-orchestrator.type'; +import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; +import { buildAllUniversalIdentifierMap } from 'src/engine/workspace-manager/workspace-migration/utils/build-all-universal-identifier-map.util'; +import { pushToOrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; + +export const validateUniversalIdentifierCrossEntityUniquenessThroughReportMutation = + ({ + optimisticUniversalFlatMaps, + orchestratorActionsReport, + orchestratorFailureReport, + }: { + optimisticUniversalFlatMaps: AllUniversalFlatEntityMaps; + orchestratorActionsReport: OrchestratorActionsReport; + orchestratorFailureReport: OrchestratorFailureReport; + }): void => { + const allUniversalIdentifierMap = buildAllUniversalIdentifierMap( + optimisticUniversalFlatMaps, + ); + + for (const metadataName of Object.values(ALL_METADATA_NAME)) { + const createActions = orchestratorActionsReport[metadataName]?.create; + + if (!isDefined(createActions) || createActions.length === 0) { + continue; + } + + for (const createAction of createActions) { + const universalIdentifier = createAction.flatEntity + .universalIdentifier as string; + + const existingOwner = + allUniversalIdentifierMap.get(universalIdentifier); + + if (!existingOwner || existingOwner.metadataName === metadataName) { + continue; + } + + const failedValidation = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName, + type: 'create', + }); + + failedValidation.errors.push({ + code: FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS, + message: `Cannot create ${metadataName}: universalIdentifier "${universalIdentifier}" is already taken by ${existingOwner.metadataName} from application "${existingOwner.applicationUniversalIdentifier}"`, + }); + + pushToOrchestratorFailureReport({ + report: orchestratorFailureReport, + metadataName, + items: [failedValidation], + }); + } + } + }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts index e3321e6a08..0acf999a0a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service.ts @@ -1,6 +1,6 @@ import { Inject } from '@nestjs/common'; -import { AllMetadataName } from 'twenty-shared/metadata'; +import { type AllMetadataName } from 'twenty-shared/metadata'; import { type FromTo } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; @@ -18,6 +18,7 @@ import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-e import { WorkspaceMigrationBuilderAdditionalCacheDataMaps } from 'src/engine/workspace-manager/workspace-migration/types/workspace-migration-builder-additional-cache-data-maps.type'; import { AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type'; import { MetadataUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/metadata-universal-flat-entity-maps.type'; +import { UniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-maps.type'; import { addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; import { deleteUniversalFlatEntityForeignKeyAggregators } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/delete-universal-flat-entity-foreign-key-aggregators.util'; import { deleteUniversalFlatEntityFromUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/delete-universal-flat-entity-from-universal-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; @@ -362,26 +363,66 @@ export abstract class WorkspaceEntityMigrationBuilderService< return []; } + private validateUniversalIdentifierNotAlreadyInCurrentMetadataMaps({ + universalIdentifier, + universalFlatEntityMaps, + }: { + universalFlatEntityMaps: UniversalFlatEntityMaps< + MetadataFlatEntity + >; + universalIdentifier: string; + }): FlatEntityValidationError[] { + const existingEntity = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: universalFlatEntityMaps, + universalIdentifier, + }); + + if (isDefined(existingEntity)) { + return [ + { + code: FlatEntityMapsExceptionCode.ENTITY_ALREADY_EXISTS, + message: `Cannot create ${this.metadataName}: universalIdentifier "${universalIdentifier}" already exists in ${this.metadataName} maps from application "${existingEntity.applicationUniversalIdentifier}"`, + }, + ]; + } + + return []; + } + private async innerValidateFlatEntityCreation( args: UniversalFlatEntityValidationArgs, ): Promise> { const uuidValidationResult = this.validateUniversalIdentifier(args); + const perTypeExistenceResult = + this.validateUniversalIdentifierNotAlreadyInCurrentMetadataMaps({ + universalIdentifier: args.flatEntityToValidate.universalIdentifier, + universalFlatEntityMaps: + args.optimisticFlatEntityMapsAndRelatedFlatEntityMaps[ + getMetadataFlatEntityMapsKey(this.metadataName) + ], + }); + + const centralizedErrors = [ + ...uuidValidationResult, + ...perTypeExistenceResult, + ]; + const result = await this.validateFlatEntityCreation(args); if (result.status === 'fail') { return { ...result, - errors: [...result.errors, ...uuidValidationResult], + errors: [...result.errors, ...centralizedErrors], }; } - if (result.status === 'success' && uuidValidationResult.length > 0) { + if (result.status === 'success' && centralizedErrors.length > 0) { return { status: 'fail', flatEntityMinimalInformation: { universalIdentifier: args.flatEntityToValidate.universalIdentifier, } as Partial>, - errors: uuidValidationResult, + errors: centralizedErrors, metadataName: this.metadataName, type: 'create', }; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-universal-identifier-conflict.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-universal-identifier-conflict.integration-spec.ts.snap new file mode 100644 index 0000000000..5e7f2c8aaa --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-sync-application-universal-identifier-conflict.integration-spec.ts.snap @@ -0,0 +1,68 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Sync application should fail on universalIdentifier conflicts should fail when an entity uses a universalIdentifier from the twenty-standard app 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "role": [ + { + "errors": [ + { + "code": "ENTITY_ALREADY_EXISTS", + "message": "Cannot create role: universalIdentifier "20202020-02c2-43f2-b94d-cab1f2b532eb" already exists in role maps from application "20202020-64aa-4b6f-b003-9c74b97cee20"", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "role", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 role", + "summary": { + "role": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; + +exports[`Sync application should fail on universalIdentifier conflicts should fail when two entities in the same manifest share the same universalIdentifier 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "role": [ + { + "errors": [ + { + "code": "ENTITY_ALREADY_EXISTS", + "message": "Cannot create role: universalIdentifier "a1b2c3d4-0003-4000-a000-000000000003" is already taken by objectPermission from application "a1b2c3d4-0001-4000-a000-000000000001"", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "role", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 role", + "summary": { + "role": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Validation errors occurred while syncing application manifest metadata", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-universal-identifier-conflict.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-universal-identifier-conflict.integration-spec.ts new file mode 100644 index 0000000000..b588b066c8 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-sync-application-universal-identifier-conflict.integration-spec.ts @@ -0,0 +1,117 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; + +import { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant'; + +const TEST_APP_ID = 'a1b2c3d4-0001-4000-a000-000000000001'; +const TEST_ROLE_ID = 'a1b2c3d4-0002-4000-a000-000000000002'; +const DUPLICATED_UNIVERSAL_IDENTIFIER = + 'a1b2c3d4-0003-4000-a000-000000000003'; + +describe('Sync application should fail on universalIdentifier conflicts', () => { + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test Universal Id Conflict App', + description: 'App for testing universalIdentifier conflict detection', + sourcePath: 'test-universal-id-conflict', + }); + }, 60000); + + afterAll(async () => { + await globalThis.testDataSource.query( + `DELETE FROM core."role" WHERE "universalIdentifier" = $1`, + [TEST_ROLE_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."file" WHERE "applicationId" IN ( + SELECT id FROM core."application" WHERE "universalIdentifier" = $1 + )`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."application" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + + await globalThis.testDataSource.query( + `DELETE FROM core."applicationRegistration" + WHERE "universalIdentifier" = $1`, + [TEST_APP_ID], + ); + }); + + it('should fail when two entities in the same manifest share the same universalIdentifier', async () => { + const manifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + roles: [ + { + universalIdentifier: TEST_ROLE_ID, + label: 'First Role', + description: 'First role', + }, + { + universalIdentifier: DUPLICATED_UNIVERSAL_IDENTIFIER, + label: 'Second Role', + description: 'Second role', + objectPermissions: [ + { + universalIdentifier: DUPLICATED_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: + STANDARD_OBJECTS.company.universalIdentifier, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + ], + }, + }); + + const { errors } = await syncApplication({ + manifest, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, 60000); + + it('should fail when an entity uses a universalIdentifier from the twenty-standard app', async () => { + const manifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + roles: [ + { + universalIdentifier: TEST_ROLE_ID, + label: 'App Default Role', + description: 'Default role for the test app', + }, + { + universalIdentifier: STANDARD_ROLE.admin.universalIdentifier, + label: 'Stolen Admin Role', + description: + 'Attempts to create a role with the standard admin universalIdentifier', + }, + ], + }, + }); + + const { errors } = await syncApplication({ + manifest, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }, 60000); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-universal-identifier-reuse-cross-entity.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-universal-identifier-reuse-cross-entity.integration-spec.ts new file mode 100644 index 0000000000..d36102848e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-sync-application-universal-identifier-reuse-cross-entity.integration-spec.ts @@ -0,0 +1,82 @@ +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { buildDefaultObjectManifest } from 'test/integration/metadata/suites/application/utils/build-default-object-manifest.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { uninstallApplication } from 'test/integration/metadata/suites/application/utils/uninstall-application.util'; +import { type FieldManifest } from 'twenty-shared/application'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { v4 as uuidv4 } from 'uuid'; + +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const REUSABLE_UID = uuidv4(); + +const TEST_OBJECT = buildDefaultObjectManifest({ + universalIdentifier: REUSABLE_UID, + nameSingular: 'ephemeral', + namePlural: 'ephemerals', + labelSingular: 'Ephemeral', + labelPlural: 'Ephemerals', + description: 'An ephemeral object that will be removed on second sync', + icon: 'IconTrash', +}); + +const REUSED_UID_FIELD: FieldManifest = { + universalIdentifier: REUSABLE_UID, + type: FieldMetadataType.TEXT, + name: 'reusedUidField', + label: 'Reused UID Field', + description: + 'Field that reuses the universalIdentifier from the deleted object', + icon: 'IconRefresh', + objectUniversalIdentifier: STANDARD_OBJECTS.company.universalIdentifier, +}; + +describe('Cross-entity universalIdentifier reuse across syncs', () => { + beforeAll(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Test UID Reuse App', + description: + 'App for testing universalIdentifier reuse across entity types', + sourcePath: 'test-uid-reuse', + }); + }, 60000); + + afterAll(async () => { + await uninstallApplication({ + universalIdentifier: TEST_APP_ID, + expectToFail: false, + }); + }); + + it('should allow reusing a universalIdentifier from a deleted object as a field in the same sync', async () => { + const firstManifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [TEST_OBJECT], + }, + }); + + await syncApplication({ + manifest: firstManifest, + expectToFail: false, + }); + + const secondManifest = buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [], + fields: [REUSED_UID_FIELD], + }, + }); + + await syncApplication({ + manifest: secondManifest, + expectToFail: false, + }); + }, 60000); +});