diff --git a/src/models/Event.ts b/src/models/Event.ts index 158124a4..ec05ef3d 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -4,6 +4,7 @@ export enum Event { PROPOSAL_UPDATED = 'PROPOSAL_UPDATED', VISIT_CREATED = 'VISIT_CREATED', VISIT_DELETED = 'VISIT_DELETED', + VISIT_UPDATED = 'VISIT_UPDATED', EXPERIMENT_CREATED = 'EXPERIMENT_CREATED', EXPERIMENT_UPDATED = 'EXPERIMENT_UPDATED', } diff --git a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts index 4b7493f9..1c60a5e9 100644 --- a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts +++ b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts @@ -25,6 +25,7 @@ const SYNC_PROPOSAL_AND_MEMBERS_EVENTS_FOR_HANDLING = [ const SYNC_VISIT_EVENTS_FOR_HANDLING = [ Event.VISIT_CREATED, Event.VISIT_DELETED, + Event.VISIT_UPDATED, ]; // Class for consuming messages from the ESS One Identity Integration Queue diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts index f2d5c968..60d14aa8 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts @@ -25,7 +25,7 @@ const mockOneIdentity: jest.Mocked> = { getProposalPersonConnections: jest.fn(), removeConnectionBetweenPersonAndProposal: jest.fn(), getPersonWantsOrg: jest.fn(), - createPersonWantsOrg: jest.fn(), + upsertPersonWantsOrg: jest.fn(), cancelPersonWantsOrg: jest.fn(), hasPersonSiteAccessToProposal: jest.fn(), }; diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index daae36a4..34219cd3 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -37,7 +37,7 @@ const mockOneIdentity: jest.Mocked> = { connectPersonToProposal: jest.fn(), getProposalPersonConnections: jest.fn(), removeConnectionBetweenPersonAndProposal: jest.fn(), - createPersonWantsOrg: jest.fn(), + upsertPersonWantsOrg: jest.fn(), cancelPersonWantsOrg: jest.fn(), hasPersonSiteAccessToProposal: jest.fn(), }; @@ -45,6 +45,7 @@ const mockOneIdentity: jest.Mocked> = { const mockUidESet: UID_ESet = 'eset-uid-123'; const visitMessage: VisitMessage = { + id: '1', visitorId: 'visitor-oidc-sub', startAt: '2023-01-01T00:00:00.000Z', endAt: '2023-01-10T00:00:00.000Z', @@ -105,7 +106,7 @@ describe('syncVisitToOneIdentityHandler', () => { 'Visitor is not a Science User, skipping', {} ); - expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); expect(mockOneIdentity.logout).toHaveBeenCalled(); }); }); @@ -136,7 +137,7 @@ describe('syncVisitToOneIdentityHandler', () => { mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); // No existing connection // Mock sequential calls to createPersonWantsOrg with different responses - mockOneIdentity.createPersonWantsOrg + mockOneIdentity.upsertPersonWantsOrg .mockResolvedValueOnce([mockSiteAccess]) .mockResolvedValueOnce([mockSystemAccess]); @@ -154,14 +155,14 @@ describe('syncVisitToOneIdentityHandler', () => { ); // Verify site access creation - expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenCalledTimes(2); - expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenNthCalledWith( + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenCalledTimes(2); + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( 1, PersonWantsOrgRole.SITE_ACCESS, visitMessage.visitorId, visitMessage.startAt, visitMessage.endAt, - visitMessage.proposal.shortCode + visitMessage.id ); // Calculate expected system access dates @@ -174,13 +175,13 @@ describe('syncVisitToOneIdentityHandler', () => { ); // Verify system access creation - expect(mockOneIdentity.createPersonWantsOrg).toHaveBeenNthCalledWith( + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( 2, PersonWantsOrgRole.SYSTEM_ACCESS, visitMessage.visitorId, expectedValidFrom, expectedEndDate.toISOString(), - 'site-access-uid' + visitMessage.id ); expect(logger.logInfo).toHaveBeenCalledWith( @@ -231,7 +232,7 @@ describe('syncVisitToOneIdentityHandler', () => { mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([ { UID_Person: mockPerson.UID_Person, UID_ESet: mockUidESet }, ]); // Connection exists - mockOneIdentity.createPersonWantsOrg + mockOneIdentity.upsertPersonWantsOrg .mockResolvedValueOnce([mockSiteAccess]) .mockResolvedValueOnce([mockSystemAccess]); @@ -270,7 +271,7 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( visitMessage.proposal ); - expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); expect(mockOneIdentity.logout).toHaveBeenCalled(); }); @@ -283,7 +284,7 @@ describe('syncVisitToOneIdentityHandler', () => { mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); - mockOneIdentity.createPersonWantsOrg.mockRejectedValueOnce( + mockOneIdentity.upsertPersonWantsOrg.mockRejectedValueOnce( new Error('Failed to create site access') ); @@ -322,7 +323,7 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( 'visitor-oidc-sub' ); - expect(mockOneIdentity.createPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); expect(mockOneIdentity.logout).toHaveBeenCalled(); }); }); @@ -342,7 +343,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'proposal-short-code', + CustomProperty04: visitMessageVisitorNotMember.id, OrderState: OrderState.GRANTED, } as PersonWantsOrg, { @@ -351,7 +352,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'site-access-uid', + CustomProperty04: visitMessageVisitorNotMember.id, OrderState: OrderState.GRANTED, } as PersonWantsOrg, ]; @@ -440,7 +441,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'proposal-short-code', + CustomProperty04: visitMessage.id, OrderState: OrderState.GRANTED, } as PersonWantsOrg, { @@ -449,7 +450,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'site-access-uid', + CustomProperty04: visitMessage.id, OrderState: OrderState.GRANTED, } as PersonWantsOrg, ]; @@ -488,7 +489,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'proposal-short-code', + CustomProperty04: '1', OrderState: OrderState.GRANTED, } as PersonWantsOrg, { @@ -497,7 +498,7 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'site-access-uid', + CustomProperty04: '1', OrderState: OrderState.GRANTED, } as PersonWantsOrg, ]; @@ -641,17 +642,17 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, ValidFrom: visitMessage.startAt, ValidUntil: visitMessage.endAt, - CustomProperty04: 'proposal-short-code', + CustomProperty04: visitMessage.id, OrderState: OrderState.GRANTED, } as PersonWantsOrg, - // No system access with CustomProperty04 matching site-access-uid + // No system access with CustomProperty04 matching visitMessage.id { UID_PersonWantsOrg: 'system-access-uid', UID_PersonOrdered: 'visitor-uid', DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, ValidFrom: '2023-01-01T00:00:00.000Z', ValidUntil: '2023-01-10T00:00:00.000Z', - CustomProperty04: 'different-site-access-uid', + CustomProperty04: 'different-visit-id', OrderState: OrderState.GRANTED, } as PersonWantsOrg, ]; @@ -684,4 +685,327 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); }); + + describe('VISIT_UPDATED', () => { + it('should update site access and system access dates when visit dates have changed', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const oldSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: '1', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + + const oldSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-25T00:00:00.000Z', + CustomProperty04: '1', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + + const mockPersonWantsOrgs = [oldSiteAccess, oldSystemAccess]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); // No existing connection + + // Mock Date.now for system access update (same as creation logic) + const mockNowDate = new Date(); + Date.now = jest.fn(() => mockNowDate.getTime()); + + // Updated visit message with new dates + const updatedVisitMessage: VisitMessage = { + ...visitMessage, + startAt: '2023-02-01T00:00:00.000Z', + endAt: '2023-02-15T00:00:00.000Z', + }; + + await syncVisitToOneIdentityHandler( + updatedVisitMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getPersonWantsOrg).toHaveBeenCalledWith( + 'visitor-uid' + ); + + // Verify site access update + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 1, + PersonWantsOrgRole.SITE_ACCESS, + 'visitor-oidc-sub', + '2023-02-01T00:00:00.000Z', + '2023-02-15T00:00:00.000Z', + '1', + 'site-access-uid' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Site access updated in One Identity', + { + UID_PersonWantsOrg: 'site-access-uid', + } + ); + + // Verify system access update (ValidUntil extended by ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS) + const expectedSystemAccessValidUntil = new Date( + '2023-02-15T00:00:00.000Z' + ); + expectedSystemAccessValidUntil.setDate( + expectedSystemAccessValidUntil.getDate() + + parseInt(ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS) + ); + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 2, + PersonWantsOrgRole.SYSTEM_ACCESS, + 'visitor-oidc-sub', + mockNowDate.toISOString(), + expectedSystemAccessValidUntil.toISOString(), + '1', + 'system-access-uid' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'System access updated in One Identity', + { + UID_PersonWantsOrg: 'system-access-uid', + } + ); + + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + mockUidESet, + 'visitor-uid' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should skip update when visit dates have not changed', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: visitMessage.startAt, + ValidUntil: visitMessage.endAt, + CustomProperty04: '1', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + + const mockSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + ValidFrom: visitMessage.startAt, + ValidUntil: visitMessage.endAt, + CustomProperty04: '1', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + + const mockPersonWantsOrgs = [mockSiteAccess, mockSystemAccess]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); // No existing connection + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + + // Verify no update occurred + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenCalledTimes(0); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Visit dates unchanged, skipping access update in One Identity', + { + UID_PersonWantsOrg: 'site-access-uid', + visitId: visitMessage.id, + } + ); + + // But proposal connection should still be created as backup + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + mockUidESet, + 'visitor-uid' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should create new access if site access not found', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + } as PersonWantsOrg; + const mockSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + } as PersonWantsOrg; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([]); // No existing access + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); // No existing connection + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getPersonWantsOrg).toHaveBeenCalledWith( + 'visitor-uid' + ); + + expect(logger.logInfo).toHaveBeenCalledWith( + 'Site access not found in One Identity, creating access', + { + visitId: visitMessage.id, + uidPerson: 'visitor-uid', + } + ); + + // Verify creation occurred instead of update + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenCalledTimes(2); + + expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( + mockUidESet, + 'visitor-uid' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if system access not found during update', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + UID_PersonOrdered: 'visitor-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + ValidFrom: '2023-01-01T00:00:00.000Z', + ValidUntil: '2023-01-10T00:00:00.000Z', + CustomProperty04: '1', + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + + const mockPersonWantsOrgs = [mockSiteAccess]; // Only site access, no system access + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + const updatedVisitMessage: VisitMessage = { + ...visitMessage, + startAt: '2023-02-01T00:00:00.000Z', + endAt: '2023-02-15T00:00:00.000Z', + }; + + await expect( + syncVisitToOneIdentityHandler(updatedVisitMessage, Event.VISIT_UPDATED) + ).rejects.toThrow( + 'System access not found in One Identity, cannot update access' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should skip processing if visitor is not a science user', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: 'EMPLOYEEDK', + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'Visitor is not a Science User, skipping', + {} + ); + expect(mockOneIdentity.getPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if proposal is not found in One Identity', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(undefined); // Proposal not found + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED) + ).rejects.toThrow( + 'Proposal not found in One Identity, cannot sync visit' + ); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getProposal).toHaveBeenCalledWith( + visitMessage.proposal + ); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it('should throw error if person not found', async () => { + mockOneIdentity.getPerson.mockResolvedValueOnce(undefined); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED) + ).rejects.toThrow('Person not found in One Identity'); + + expect(mockOneIdentity.login).toHaveBeenCalled(); + expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( + 'visitor-oidc-sub' + ); + expect(mockOneIdentity.getPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + }); }); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 1b58ac96..8fb9599a 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -18,7 +18,7 @@ const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = parseInt( ); export async function syncVisitToOneIdentityHandler( - { startAt, endAt, visitorId: oidcSub, proposal }: VisitMessage, + { id: visitId, startAt, endAt, visitorId: oidcSub, proposal }: VisitMessage, type: Event ): Promise { const oneIdentity = new ESSOneIdentity(); @@ -44,16 +44,29 @@ export async function syncVisitToOneIdentityHandler( if (type === Event.VISIT_CREATED) { await createAccessInOneIdentity( oneIdentity, + visitId, startAt, endAt, - oidcSub, - proposal + oidcSub ); // Every visitor should have access to the proposal folders await createProposalConnection(oneIdentity, uidESet, uidPerson); + } else if (type === Event.VISIT_UPDATED) { + // For simplicity, we will just update the access with the new dates. The update takes place only if the dates are changed. + await updateAccessInOneIdentity( + oneIdentity, + visitId, + startAt, + endAt, + uidPerson, + oidcSub + ); + + // If the Proposal connection has not been established during Visit Creation, this will be a backup + await createProposalConnection(oneIdentity, uidESet, uidPerson); } else if (type === Event.VISIT_DELETED) { - await removeAccessFromOneIdentity(oneIdentity, startAt, endAt, uidPerson); + await removeAccessFromOneIdentity(oneIdentity, visitId, uidPerson); // Remove the connection between the proposal and the visitor await removeProposalConnection( @@ -90,18 +103,18 @@ async function getScienceUser( async function createAccessInOneIdentity( oneIdentity: ESSOneIdentity, + visitId: string, startAt: string, endAt: string, - centralAccount: string, - proposal: ProposalMessageData + centralAccount: string ) { // Create site access - const [pwoSite] = await oneIdentity.createPersonWantsOrg( + const [pwoSite] = await oneIdentity.upsertPersonWantsOrg( PersonWantsOrgRole.SITE_ACCESS, centralAccount, toIsoString(startAt), toIsoString(endAt), - proposal.shortCode // CustomProperty04 - We store the proposal short code for the site access to be able to find it later + visitId // CustomProperty04 - We store the visit ID for the site access to be able to find it later ); logger.logInfo('Site access created in One Identity', { @@ -115,12 +128,12 @@ async function createAccessInOneIdentity( ); // Create system access - const [pwoSystem] = await oneIdentity.createPersonWantsOrg( + const [pwoSystem] = await oneIdentity.upsertPersonWantsOrg( PersonWantsOrgRole.SYSTEM_ACCESS, centralAccount, toIsoString(validFrom), toIsoString(validUntil), - pwoSite.UID_PersonWantsOrg // CustomProperty04 - We store the site access UID for the system access to be able to find it later + visitId // CustomProperty04 - We store the visit ID for the system access to be able to find it later ); logger.logInfo('System access created in One Identity', { @@ -128,10 +141,112 @@ async function createAccessInOneIdentity( }); } -async function removeAccessFromOneIdentity( +async function updateAccessInOneIdentity( oneIdentity: ESSOneIdentity, + visitId: string, startAt: string, endAt: string, + uidPerson: UID_Person, + centralAccount: string +) { + // Find person wants orgs for the visitor + const personWantsOrgs = await oneIdentity.getPersonWantsOrg(uidPerson); + + // Find site access for the visitor + const siteAccess = personWantsOrgs.find( + (pwo) => + pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS && + pwo.CustomProperty04 === visitId && // CustomProperty04 is the visit ID for the site access + pwo.OrderState !== OrderState.ABORTED + ); + + // If there is no existing siteAccess record, new one will be created for both siteAccess and systemAccess. + if (!siteAccess) { + logger.logInfo('Site access not found in One Identity, creating access', { + visitId, + uidPerson, + }); + + await createAccessInOneIdentity( + oneIdentity, + visitId, + startAt, + endAt, + centralAccount + ); + + return; + } + + const validFrom = toIsoString(startAt); + const validUntil = toIsoString(endAt); + + // Find system access for the site access (CustomProperty04 is the visit ID) + const systemAccess = personWantsOrgs.find( + (pwo) => + pwo.CustomProperty04 === visitId && + pwo.DisplayOrg === PersonWantsOrgRole.SYSTEM_ACCESS && + pwo.OrderState !== OrderState.UNSUBSCRIBED + ); + + if (!systemAccess) { + throw new Error( + 'System access not found in One Identity, cannot update access' + ); + } + + if ( + isSameDateTime(siteAccess.ValidFrom, validFrom) && + isSameDateTime(siteAccess.ValidUntil, validUntil) + ) { + logger.logInfo( + 'Visit dates unchanged, skipping access update in One Identity', + { + UID_PersonWantsOrg: siteAccess.UID_PersonWantsOrg, + visitId, + } + ); + + return; + } + + await oneIdentity.upsertPersonWantsOrg( + PersonWantsOrgRole.SITE_ACCESS, + centralAccount, + validFrom, + validUntil, + visitId, + siteAccess.UID_PersonWantsOrg + ); + + logger.logInfo('Site access updated in One Identity', { + UID_PersonWantsOrg: siteAccess.UID_PersonWantsOrg, + }); + + const systemAccessValidFrom = toIsoString(Date.now()); + const systemAccessValidUntil = toIsoString( + new Date(endAt).setDate( + new Date(endAt).getDate() + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS + ) + ); + + await oneIdentity.upsertPersonWantsOrg( + PersonWantsOrgRole.SYSTEM_ACCESS, + centralAccount, + systemAccessValidFrom, + systemAccessValidUntil, + visitId, + systemAccess.UID_PersonWantsOrg + ); + + logger.logInfo('System access updated in One Identity', { + UID_PersonWantsOrg: systemAccess.UID_PersonWantsOrg, + }); +} + +async function removeAccessFromOneIdentity( + oneIdentity: ESSOneIdentity, + visitId: string, uidPerson: UID_Person ) { // Find person wants orgs for the visitor @@ -141,8 +256,7 @@ async function removeAccessFromOneIdentity( const siteAccess = personWantsOrgs.find( (pwo) => pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS && - toIsoString(pwo.ValidFrom) === toIsoString(startAt) && - toIsoString(pwo.ValidUntil) === toIsoString(endAt) && + pwo.CustomProperty04 === visitId && // CustomProperty04 is the visit ID for the site access pwo.OrderState !== OrderState.ABORTED ); @@ -158,10 +272,10 @@ async function removeAccessFromOneIdentity( UID_PersonWantsOrg: siteAccess.UID_PersonWantsOrg, }); - // Find system access for the site access (CustomProperty04 is the site access UID) + // Find system access for the site access (CustomProperty04 is the visit ID) const systemAccess = personWantsOrgs.find( (pwo) => - pwo.CustomProperty04 === siteAccess.UID_PersonWantsOrg && + pwo.CustomProperty04 === visitId && pwo.DisplayOrg === PersonWantsOrgRole.SYSTEM_ACCESS && pwo.OrderState !== OrderState.UNSUBSCRIBED ); @@ -244,3 +358,14 @@ function toIsoString(date: string | number) { return parsedDate.toISOString(); } + +function isSameDateTime(left: string, right: string) { + const leftTime = new Date(left).getTime(); + const rightTime = new Date(right).getTime(); + + if (isNaN(leftTime) || isNaN(rightTime)) { + return left === right; + } + + return leftTime === rightTime; +} diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts index 918e09f2..05fa8b71 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts @@ -310,7 +310,7 @@ describe('ESSOneIdentity', () => { Message: 'Success', }); - const result = await essOneIdentity.createPersonWantsOrg( + const result = await essOneIdentity.upsertPersonWantsOrg( role, centralAccount, startDate, @@ -332,7 +332,7 @@ describe('ESSOneIdentity', () => { Message: 'Success', }); - const result = await essOneIdentity.createPersonWantsOrg( + const result = await essOneIdentity.upsertPersonWantsOrg( role, centralAccount, startDate, @@ -364,7 +364,7 @@ describe('ESSOneIdentity', () => { }); await expect( - essOneIdentity.createPersonWantsOrg( + essOneIdentity.upsertPersonWantsOrg( role, centralAccount, startDate, diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts index 4dc125e0..8b993f65 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts @@ -139,12 +139,13 @@ export class ESSOneIdentity { return entities.map(({ values }) => values); } - public async createPersonWantsOrg( + public async upsertPersonWantsOrg( role: PersonWantsOrgRole, centralAccount: string, startDate: string, endDate: string, - customData: string = '' + customData: string = '', + uidPersonWantsOrg: string = '' ): Promise { const res = await this.oneIdentityApi.callScript( @@ -156,7 +157,7 @@ export class ESSOneIdentity { startDate, endDate, customData, // PersonWantsOrg.CustomProperty04 - '', // UID_PersonWantsOrg (empty for new) + uidPersonWantsOrg, // UID_PersonWantsOrg (empty for new, provided for update) ] ); diff --git a/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts b/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts index 20c2045b..3f50561f 100644 --- a/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts +++ b/src/queue/consumers/oneidentity/utils/OneIdentityApi.ts @@ -87,6 +87,16 @@ export class OneIdentityApi { return data; } + public async updateEntity( + table: string, + uid: string, + values: Partial + ): Promise { + await this.axiosInstance.put(`/entity/${table}/${uid}`, { + values, + }); + } + public async getEntities( table: string, where: string, diff --git a/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts index ce180475..d273cfad 100644 --- a/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts +++ b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts @@ -1,6 +1,7 @@ import { ProposalMessageData } from '../../../../../models/ProposalMessage'; export interface VisitMessage { + id: string; startAt: string; endAt: string; visitorId: string; diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts index 5f7c0d78..9c593de2 100644 --- a/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts @@ -44,8 +44,21 @@ describe('isVisitMessage', () => { expect(isVisitMessage(message)).toBe(false); }); + it('should return false if id is undefined', () => { + const message = { + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + }; + expect(isVisitMessage(message)).toBe(false); + }); + it('should return true if the message is valid', () => { const message = { + id: 'visit123', visitorId: 'visitor123', startAt: '2023-01-01T00:00:00Z', endAt: '2023-01-02T00:00:00Z', diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts index ed7cb282..167dcc1a 100644 --- a/src/queue/consumers/oneidentity/utils/isVisitMessage.ts +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts @@ -4,6 +4,7 @@ export function isVisitMessage(message: any): message is VisitMessage { return ( message != null && typeof message === 'object' && + 'id' in message && 'visitorId' in message && 'startAt' in message && 'endAt' in message &&