From 6220dcdc2656032132a57ae5240884dd48f52121 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Fri, 11 Sep 2026 16:34:03 +0200 Subject: [PATCH 01/11] feat(oneidentity): sync PEJ allowances from visit registration answers --- ...osalAndMembersToOneIdentityHandler.spec.ts | 1 + .../syncVisitToOneIdentityHandler.spec.ts | 160 ++++++++++++++++++ .../syncVisitToOneIdentityHandler.ts | 101 ++++++++++- .../oneidentity/utils/ESSOneIdentity.spec.ts | 45 +++++ .../oneidentity/utils/ESSOneIdentity.ts | 16 ++ .../utils/interfaces/VisitMessage.ts | 6 + .../oneidentity/utils/isVisitMessage.spec.ts | 32 ++++ .../oneidentity/utils/isVisitMessage.ts | 11 +- 8 files changed, 369 insertions(+), 3 deletions(-) diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts index 60d14aa8..2bf970d0 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncProposalAndMembersToOneIdentityHandler.spec.ts @@ -27,6 +27,7 @@ const mockOneIdentity: jest.Mocked> = { getPersonWantsOrg: jest.fn(), upsertPersonWantsOrg: jest.fn(), cancelPersonWantsOrg: jest.fn(), + syncPEJAllowance: 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 8d827690..a5358ba6 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -39,6 +39,7 @@ const mockOneIdentity: jest.Mocked> = { removeConnectionBetweenPersonAndProposal: jest.fn(), upsertPersonWantsOrg: jest.fn(), cancelPersonWantsOrg: jest.fn(), + syncPEJAllowance: jest.fn(), hasPersonSiteAccessToProposal: jest.fn(), }; @@ -54,6 +55,16 @@ const visitMessage: VisitMessage = { members: [{ oidcSub: 'member-oidc-sub' }], dataAccessUsers: [{ oidcSub: 'visitor-oidc-sub' }], // Visitor is also a data access user } as ProposalMessageData, + registrationAnswers: [], +}; + +const visitMessageWithApprovedDailyAllowance: VisitMessage = { + ...visitMessage, + id: '1e94c2d1-743b-48b5-99ae-f8c2d5f34e2b', + registrationAnswers: [ + { questionNaturalKey: 'request_daily_allowance', value: true }, + { questionNaturalKey: 'daily_allowance_is_approved', value: true }, + ], }; const visitMessageVisitorNotMember: VisitMessage = { @@ -215,6 +226,76 @@ describe('syncVisitToOneIdentityHandler', () => { Date.now = originalDateNow; }); + it('should upsert a PEJ allowance when it is requested and approved', 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.getProposalPersonConnections.mockResolvedValueOnce([]); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + + await syncVisitToOneIdentityHandler( + visitMessageWithApprovedDailyAllowance, + Event.VISIT_CREATED + ); + + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + visitMessageWithApprovedDailyAllowance.visitorId, + visitMessageWithApprovedDailyAllowance.id, + 'upsert', + visitMessageWithApprovedDailyAllowance.startAt, + visitMessageWithApprovedDailyAllowance.endAt + ); + }); + + it('should not upsert a PEJ allowance unless it is both requested and approved', 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; + const visitMessageWithUnapprovedDailyAllowance: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + registrationAnswers: [ + { questionNaturalKey: 'request_daily_allowance', value: true }, + { + questionNaturalKey: 'daily_allowance_is_approved', + value: false, + }, + ], + }; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + + await syncVisitToOneIdentityHandler( + visitMessageWithUnapprovedDailyAllowance, + Event.VISIT_CREATED + ); + + expect(mockOneIdentity.syncPEJAllowance).not.toHaveBeenCalled(); + }); + it('should skip creating proposal connection if it already exists', async () => { const mockPerson = { UID_Person: 'visitor-uid', @@ -429,6 +510,46 @@ describe('syncVisitToOneIdentityHandler', () => { ); }); + it('should delete an approved PEJ allowance with empty date parameters', async () => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const mockPersonWantsOrgs = [ + { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: visitMessageWithApprovedDailyAllowance.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: visitMessageWithApprovedDailyAllowance.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg, + ]; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + mockPersonWantsOrgs + ); + + await syncVisitToOneIdentityHandler( + visitMessageWithApprovedDailyAllowance, + Event.VISIT_DELETED + ); + + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + visitMessageWithApprovedDailyAllowance.visitorId, + visitMessageWithApprovedDailyAllowance.id, + 'delete', + '', + '' + ); + }); + it('should skip removing proposal connection if visitor is a member of the proposal', async () => { const mockPerson = { UID_Person: 'visitor-uid', @@ -796,6 +917,45 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); + it('should upsert an approved PEJ allowance on visit update', 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; + const updatedVisitMessage: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt: '2023-02-01T00:00:00.000Z', + endAt: '2023-02-15T00:00:00.000Z', + }; + + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([]); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + + await syncVisitToOneIdentityHandler( + updatedVisitMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + updatedVisitMessage.visitorId, + updatedVisitMessage.id, + 'upsert', + updatedVisitMessage.startAt, + updatedVisitMessage.endAt + ); + }); + it('should skip update when visit dates have not changed', async () => { const mockPerson = { UID_Person: 'visitor-uid', diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index add0ad9a..296466fc 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -11,14 +11,28 @@ import { OrderState, PersonWantsOrgRole, } from '../utils/interfaces/PersonWantsOrg'; -import { VisitMessage } from '../utils/interfaces/VisitMessage'; +import { + VisitMessage, + VisitRegistrationAnswer, +} from '../utils/interfaces/VisitMessage'; + +const REQUEST_DAILY_ALLOWANCE_QUESTION_NATURAL_KEY = 'request_daily_allowance'; +const DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY = + 'daily_allowance_is_approved'; const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = parseInt( process.env.ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS || '30' ); export async function syncVisitToOneIdentityHandler( - { id: visitId, startAt, endAt, visitorId: oidcSub, proposal }: VisitMessage, + { + id: visitId, + startAt, + endAt, + visitorId: oidcSub, + proposal, + registrationAnswers = [], + }: VisitMessage, type: Event ): Promise { const oneIdentity = new ESSOneIdentity(); @@ -52,6 +66,16 @@ export async function syncVisitToOneIdentityHandler( // Every visitor should have access to the proposal folders await createProposalConnection(oneIdentity, uidESet, uidPerson); + + await syncDailyAllowance( + oneIdentity, + visitId, + oidcSub, + startAt, + endAt, + registrationAnswers, + type + ); } 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( @@ -65,7 +89,29 @@ export async function syncVisitToOneIdentityHandler( // If the Proposal connection has not been established during Visit Creation, this will be a backup await createProposalConnection(oneIdentity, uidESet, uidPerson); + + await syncDailyAllowance( + oneIdentity, + visitId, + oidcSub, + startAt, + endAt, + registrationAnswers, + type + ); } else if (type === Event.VISIT_DELETED) { + // Delete the allowance first. Its idempotent delete keeps message retries + // safe if one of the remaining visit cleanup operations fails. + await syncDailyAllowance( + oneIdentity, + visitId, + oidcSub, + startAt, + endAt, + registrationAnswers, + type + ); + await removeAccessFromOneIdentity(oneIdentity, visitId, uidPerson); // Remove the connection between the proposal and the visitor @@ -83,6 +129,57 @@ export async function syncVisitToOneIdentityHandler( } } +async function syncDailyAllowance( + oneIdentity: ESSOneIdentity, + visitId: string, + centralAccount: string, + startAt: string, + endAt: string, + registrationAnswers: VisitRegistrationAnswer[], + type: Event +): Promise { + if (!hasApprovedDailyAllowance(registrationAnswers)) return; + + const operation = + type === Event.VISIT_DELETED + ? 'delete' + : type === Event.VISIT_CREATED || type === Event.VISIT_UPDATED + ? 'upsert' + : undefined; + + if (!operation) return; + + await oneIdentity.syncPEJAllowance( + centralAccount, + visitId, + operation, + operation === 'delete' ? '' : startAt, + operation === 'delete' ? '' : endAt + ); + + logger.logInfo('PEJ allowance synchronized in One Identity', { + visitId, + centralAccount, + operation, + }); +} + +function hasApprovedDailyAllowance( + registrationAnswers: VisitRegistrationAnswer[] +): boolean { + const hasAffirmativeAnswer = (questionNaturalKey: string) => + registrationAnswers.some( + (answer) => + answer.questionNaturalKey === questionNaturalKey && + answer.value === true + ); + + return ( + hasAffirmativeAnswer(REQUEST_DAILY_ALLOWANCE_QUESTION_NATURAL_KEY) && + hasAffirmativeAnswer(DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY) + ); +} + // Find person UID from oidcSub // If the person is not a science user, return undefined async function getScienceUser( diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts index 05fa8b71..fb68f672 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts @@ -290,6 +290,51 @@ describe('ESSOneIdentity', () => { }); }); + describe('syncPEJAllowance', () => { + const centralAccount = 'visitor-oidc-sub'; + const externalAllowanceId = '1e94c2d1-743b-48b5-99ae-f8c2d5f34e2b'; + + it('should upsert a PEJ allowance with the visit dates', async () => { + mockOneIdentityApi.callScript.mockResolvedValueOnce('allowance-uid'); + + const result = await essOneIdentity.syncPEJAllowance( + centralAccount, + externalAllowanceId, + 'upsert', + '2026-08-24T08:00:00.000Z', + '2026-08-24T16:00:00.000Z' + ); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCPEJAllowance', + [ + centralAccount, + externalAllowanceId, + 'upsert', + '2026-08-24T08:00:00.000Z', + '2026-08-24T16:00:00.000Z', + ] + ); + expect(result).toBe('allowance-uid'); + }); + + it('should send empty date parameters when deleting a PEJ allowance', async () => { + mockOneIdentityApi.callScript.mockResolvedValueOnce('allowance-uid'); + + const result = await essOneIdentity.syncPEJAllowance( + centralAccount, + externalAllowanceId, + 'delete' + ); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCPEJAllowance', + [centralAccount, externalAllowanceId, 'delete', '', ''] + ); + expect(result).toBe('allowance-uid'); + }); + }); + describe('createPersonWantsOrg', () => { const role = PersonWantsOrgRole.SITE_ACCESS; const centralAccount = 'user123'; diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts index 8b993f65..a26a93b1 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.ts @@ -139,6 +139,22 @@ export class ESSOneIdentity { return entities.map(({ values }) => values); } + public async syncPEJAllowance( + centralAccount: string, + externalAllowanceId: string, + operation: 'upsert' | 'delete', + dateFrom: string = '', + dateTo: string = '' + ): Promise { + return this.oneIdentityApi.callScript('SCPEJAllowance', [ + centralAccount, + externalAllowanceId, + operation, + dateFrom, + dateTo, + ]); + } + public async upsertPersonWantsOrg( role: PersonWantsOrgRole, centralAccount: string, diff --git a/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts index d273cfad..4b28a2f0 100644 --- a/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts +++ b/src/queue/consumers/oneidentity/utils/interfaces/VisitMessage.ts @@ -1,9 +1,15 @@ import { ProposalMessageData } from '../../../../../models/ProposalMessage'; +export interface VisitRegistrationAnswer { + questionNaturalKey: string; + value: unknown; +} + export interface VisitMessage { id: string; startAt: string; endAt: string; visitorId: string; proposal: ProposalMessageData; + registrationAnswers?: VisitRegistrationAnswer[]; } diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts index 9c593de2..0e5e9b68 100644 --- a/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.spec.ts @@ -56,6 +56,38 @@ describe('isVisitMessage', () => { expect(isVisitMessage(message)).toBe(false); }); + it('should return false if registrationAnswers is malformed', () => { + const message = { + id: 'visit123', + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + registrationAnswers: 'not-an-array', + }; + + expect(isVisitMessage(message)).toBe(false); + }); + + it('should return true if the message includes valid registration answers', () => { + const message = { + id: 'visit123', + visitorId: 'visitor123', + startAt: '2023-01-01T00:00:00Z', + endAt: '2023-01-02T00:00:00Z', + proposal: { + shortCode: 'proposal-short-code', + }, + registrationAnswers: [ + { questionNaturalKey: 'request_daily_allowance', value: true }, + ], + }; + + expect(isVisitMessage(message)).toBe(true); + }); + it('should return true if the message is valid', () => { const message = { id: 'visit123', diff --git a/src/queue/consumers/oneidentity/utils/isVisitMessage.ts b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts index 167dcc1a..9e479536 100644 --- a/src/queue/consumers/oneidentity/utils/isVisitMessage.ts +++ b/src/queue/consumers/oneidentity/utils/isVisitMessage.ts @@ -8,6 +8,15 @@ export function isVisitMessage(message: any): message is VisitMessage { 'visitorId' in message && 'startAt' in message && 'endAt' in message && - 'proposal' in message + 'proposal' in message && + (!('registrationAnswers' in message) || + (Array.isArray(message.registrationAnswers) && + message.registrationAnswers.every( + (answer: any) => + answer != null && + typeof answer === 'object' && + typeof answer.questionNaturalKey === 'string' && + 'value' in answer + ))) ); } From 60ab08e0fe0c0483694d5e5a8c79a0ff470ead13 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Fri, 2 Oct 2026 15:43:58 +0200 Subject: [PATCH 02/11] fix(oneidentity): include request context in HTTP error logs --- ...neIdentityIntegrationQueueConsumer.spec.ts | 24 +++++++ .../OneIdentityIntegrationQueueConsumer.ts | 18 +---- src/queue/consumers/oneidentity/README.md | 1 + .../utils/getAxiosErrorDetails.spec.ts | 69 +++++++++++++++++++ .../oneidentity/utils/getAxiosErrorDetails.ts | 20 ++++++ 5 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.spec.ts create mode 100644 src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.ts diff --git a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts index 65c23830..832d888b 100644 --- a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts +++ b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.spec.ts @@ -135,6 +135,7 @@ describe('OneIdentityIntegrationQueueConsumer', () => { const axiosError = new Error('Axios Error'); const mockResponse = { status: 400, + statusText: 'Bad Request', headers: { 'content-type': 'application/json' }, data: { message: 'Bad Request' }, }; @@ -142,6 +143,11 @@ describe('OneIdentityIntegrationQueueConsumer', () => { Object.assign(axiosError, { isAxiosError: true, response: mockResponse, + config: { + method: 'post', + baseURL: 'https://one-identity.example.org/api', + url: '/entity/ESET', + }, }); (isAxiosError as unknown as jest.Mock).mockReturnValueOnce(true); @@ -159,8 +165,14 @@ describe('OneIdentityIntegrationQueueConsumer', () => { { type, message, + request: { + method: 'POST', + baseURL: 'https://one-identity.example.org/api', + url: '/entity/ESET', + }, response: { status: mockResponse.status, + statusText: mockResponse.statusText, headers: mockResponse.headers, data: mockResponse.data, }, @@ -222,6 +234,7 @@ describe('OneIdentityIntegrationQueueConsumer', () => { const axiosError = new Error('Axios Error'); const mockResponse = { status: 500, + statusText: 'Internal Server Error', headers: { 'content-type': 'application/json' }, data: { message: 'Internal Server Error' }, }; @@ -229,6 +242,11 @@ describe('OneIdentityIntegrationQueueConsumer', () => { Object.assign(axiosError, { isAxiosError: true, response: mockResponse, + config: { + method: 'put', + baseURL: 'https://one-identity.example.org/api', + url: '/script/SCPEJAllowance', + }, }); (isVisitMessage as unknown as jest.Mock).mockReturnValue(true); @@ -248,8 +266,14 @@ describe('OneIdentityIntegrationQueueConsumer', () => { { type, message, + request: { + method: 'PUT', + baseURL: 'https://one-identity.example.org/api', + url: '/script/SCPEJAllowance', + }, response: { status: mockResponse.status, + statusText: mockResponse.statusText, headers: mockResponse.headers, data: mockResponse.data, }, diff --git a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts index 1c60a5e9..f7e32fea 100644 --- a/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts +++ b/src/queue/consumers/oneidentity/OneIdentityIntegrationQueueConsumer.ts @@ -1,10 +1,10 @@ import { logger } from '@user-office-software/duo-logger'; import { ConsumerCallback } from '@user-office-software/duo-message-broker'; -import { isAxiosError } from 'axios'; import { QueueConsumer } from '../QueueConsumer'; import { syncProposalAndMembersToOneIdentityHandler } from './consumerCallbacks/syncProposalAndMembersToOneIdentityHandler'; import { syncVisitToOneIdentityHandler } from './consumerCallbacks/syncVisitToOneIdentityHandler'; +import { getAxiosErrorDetails } from './utils/getAxiosErrorDetails'; import { isVisitMessage } from './utils/isVisitMessage'; import { validateProposalMessage } from './utils/validateProposalMessage'; import { Event } from '../../../models/Event'; @@ -83,15 +83,13 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer { message, }); } catch (error) { - const response = extractAxiosErrorResponse(error); - logger.logException( 'Error while handling message in OneIdentityIntegrationQueueConsumer', error, { type, message, - response, + ...getAxiosErrorDetails(error), } ); @@ -100,15 +98,3 @@ export class OneIdentityIntegrationQueueConsumer extends QueueConsumer { } }; } - -function extractAxiosErrorResponse(error: unknown) { - if (isAxiosError(error)) { - return { - status: error.response?.status, - headers: error.response?.headers, - data: error.response?.data, - }; - } - - return undefined; -} diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 48d8cf10..fdca42c9 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -101,6 +101,7 @@ The handler manages site and system access in One Identity based on visit creati ### Error Handling - Proper error messages when person or access records are not found - Always performs logout in finally block to ensure clean session management +- HTTP error logs include the request method, base URL and path, plus the response status, headers and full body (without request credentials) --- diff --git a/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.spec.ts b/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.spec.ts new file mode 100644 index 00000000..44ffd5e1 --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.spec.ts @@ -0,0 +1,69 @@ +import { AxiosError, AxiosHeaders, AxiosResponse } from 'axios'; + +import { getAxiosErrorDetails } from './getAxiosErrorDetails'; + +describe('getAxiosErrorDetails', () => { + it.each([ + { + Message: 'Authentication failed', + InnerException: { Message: 'User disabled' }, + }, + 'Authentication failed', + '401', + ])('should preserve the full server response body: %p', (data) => { + const config = { + method: 'post', + baseURL: 'https://one-identity.example.org', + url: '/auth/apphost', + headers: new AxiosHeaders({ Cookie: 'secret-cookie' }), + data: { password: 'secret-password' }, + }; + const response: AxiosResponse = { + status: 401, + statusText: 'Unauthorized', + headers: { 'www-authenticate': 'apphost' }, + config, + data, + }; + const error = new AxiosError( + 'Request failed', + undefined, + config, + undefined, + response + ); + + expect(getAxiosErrorDetails(error)).toEqual({ + request: { + method: 'POST', + baseURL: config.baseURL, + url: config.url, + }, + response: { + status: 401, + statusText: 'Unauthorized', + headers: response.headers, + data, + }, + }); + }); + + it('should handle Axios errors without a response', () => { + const error = new AxiosError('Network error'); + + expect(getAxiosErrorDetails(error)).toEqual({ + request: { method: undefined, baseURL: undefined, url: undefined }, + response: { + status: undefined, + statusText: undefined, + headers: undefined, + data: undefined, + }, + }); + }); + + it('should not extract HTTP details from other errors', () => { + expect(getAxiosErrorDetails(new Error('Processing failed'))).toEqual({}); + expect(getAxiosErrorDetails(undefined)).toEqual({}); + }); +}); diff --git a/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.ts b/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.ts new file mode 100644 index 00000000..42b70fbf --- /dev/null +++ b/src/queue/consumers/oneidentity/utils/getAxiosErrorDetails.ts @@ -0,0 +1,20 @@ +import { isAxiosError } from 'axios'; + +export function getAxiosErrorDetails(error: unknown) { + if (!isAxiosError(error)) return {}; + + // Do not log request headers or data: they can contain session cookies and passwords. + return { + request: { + method: error.config?.method?.toUpperCase(), + baseURL: error.config?.baseURL, + url: error.config?.url, + }, + response: { + status: error.response?.status, + statusText: error.response?.statusText, + headers: error.response?.headers, + data: error.response?.data, + }, + }; +} From e5d68cd00ec54e346d0222568b0b96a3620b57b9 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Mon, 5 Oct 2026 12:55:13 +0200 Subject: [PATCH 03/11] fix(oneidentity): make visit access cleanup idempotent Cancel site and system access independently, skipping missing or already-cancelled records. Add regression tests for partial cleanup and retries. --- src/queue/consumers/oneidentity/README.md | 11 +- .../syncVisitToOneIdentityHandler.spec.ts | 234 ++++++++++++------ .../syncVisitToOneIdentityHandler.ts | 61 ++--- 3 files changed, 186 insertions(+), 120 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index fdca42c9..3e73060b 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -15,9 +15,8 @@ The handler manages site and system access in One Identity based on visit creati - **System Access**: Digital access to systems, extends beyond the visit end date by a configurable number of days (default: 30) ### Key Relationships -- System access is linked to site access via `CustomProperty04` which stores the site access UID +- Both site and system access store the visit ID in `CustomProperty04` - Both access types are identified by specific roles in `PersonWantsOrgRole` enum -- Proposal's short code is stored in `CustomProperty04` of the system access record ## Process Flow Chart @@ -92,11 +91,13 @@ The handler manages site and system access in One Identity based on visit creati ### Access Creation - Site access matches exact visit dates - System access starts from the visit start date and extends beyond the visit end date by a configurable number of days (default: 30) -- System access links to site access via `CustomProperty04` +- Site and system access share the visit ID in `CustomProperty04` ### Access Cancellation -- System access cancellation depends on finding the parent site access first -- Both must be cancelled +- Site and system access are matched independently by role and visit ID +- All matching access records are cancelled, except those already `Aborted` or `Unsubscribed` +- Missing or already-cancelled access is treated as successful cleanup, so retries can finish partial deletions +- Actual API cancellation failures still propagate for retry ### Error Handling - Proper error messages when person or access records are not found diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index a5358ba6..46a0f121 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -716,96 +716,172 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); - it('should throw error if site access not found', async () => { + describe('idempotent access cleanup', () => { const mockPerson = { UID_Person: 'visitor-uid', CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, } as Person; - const mockPersonWantsOrgs = [ - // No site access matching the dates - { - UID_PersonWantsOrg: 'site-access-uid', - UID_PersonOrdered: 'visitor-uid', - DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, - ValidFrom: '2023-01-02T00:00:00.000Z', // Different from message.startAt - ValidUntil: '2023-01-10T00:00:00.000Z', - OrderState: OrderState.GRANTED, - } as PersonWantsOrg, - ]; - - mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); - mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); - mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( - mockPersonWantsOrgs - ); - - await expect( - syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) - ).rejects.toThrow( - 'Site access not found in One Identity, cannot remove access' - ); - - expect(mockOneIdentity.login).toHaveBeenCalled(); - expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( - 'visitor-oidc-sub' - ); - expect(mockOneIdentity.logout).toHaveBeenCalled(); - }); + const siteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: visitMessage.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + const systemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: visitMessage.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; - it('should throw error if system access not found', async () => { - const mockPerson = { - UID_Person: 'visitor-uid', - CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, - } as Person; + beforeEach(() => { + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + }); - const mockPersonWantsOrgs = [ + it.each([ { - UID_PersonWantsOrg: 'site-access-uid', - UID_PersonOrdered: 'visitor-uid', - DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, - ValidFrom: visitMessage.startAt, - ValidUntil: visitMessage.endAt, - CustomProperty04: visitMessage.id, - OrderState: OrderState.GRANTED, - } as PersonWantsOrg, - // No system access with CustomProperty04 matching visitMessage.id + scenario: 'site access is missing', + accesses: [ + { ...siteAccess, CustomProperty04: 'other-visit' }, + systemAccess, + ], + expectedUids: ['system-access-uid'], + }, { - 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-visit-id', - OrderState: OrderState.GRANTED, - } as PersonWantsOrg, - ]; - - mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); - mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); - mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( - mockPersonWantsOrgs - ); - - await expect( - syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_DELETED) - ).rejects.toThrow( - 'System access not found in One Identity, cannot remove access' - ); - - expect(mockOneIdentity.login).toHaveBeenCalled(); - expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( - 'visitor-oidc-sub' - ); - expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenCalledWith( - 'site-access-uid' - ); - expect(logger.logInfo).toHaveBeenCalledWith( - 'Site access cancelled in One Identity', + scenario: 'system access is missing', + accesses: [ + siteAccess, + { ...systemAccess, CustomProperty04: 'other-visit' }, + ], + expectedUids: ['site-access-uid'], + }, { - UID_PersonWantsOrg: 'site-access-uid', + scenario: 'both access records are missing', + accesses: [], + expectedUids: [], + }, + { + scenario: 'only records for another visit exist', + accesses: [ + { ...siteAccess, CustomProperty04: 'other-visit' }, + { ...systemAccess, CustomProperty04: 'other-visit' }, + ], + expectedUids: [], + }, + { + scenario: 'site access is already aborted', + accesses: [ + { ...siteAccess, OrderState: OrderState.ABORTED }, + systemAccess, + ], + expectedUids: ['system-access-uid'], + }, + { + scenario: 'site access is already unsubscribed', + accesses: [ + { ...siteAccess, OrderState: OrderState.UNSUBSCRIBED }, + systemAccess, + ], + expectedUids: ['system-access-uid'], + }, + { + scenario: 'system access is already aborted', + accesses: [ + siteAccess, + { ...systemAccess, OrderState: OrderState.ABORTED }, + ], + expectedUids: ['site-access-uid'], + }, + { + scenario: 'system access is already unsubscribed', + accesses: [ + siteAccess, + { ...systemAccess, OrderState: OrderState.UNSUBSCRIBED }, + ], + expectedUids: ['site-access-uid'], + }, + { + scenario: 'both access records are already cancelled', + accesses: [ + { ...siteAccess, OrderState: OrderState.ABORTED }, + { ...systemAccess, OrderState: OrderState.UNSUBSCRIBED }, + ], + expectedUids: [], + }, + { + scenario: 'multiple active records exist for the visit', + accesses: [ + siteAccess, + { ...siteAccess, UID_PersonWantsOrg: 'second-site-access-uid' }, + systemAccess, + ], + expectedUids: [ + 'site-access-uid', + 'second-site-access-uid', + 'system-access-uid', + ], + }, + ])( + 'should finish cleanup when $scenario', + async ({ accesses, expectedUids }) => { + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce(accesses); + + await syncVisitToOneIdentityHandler( + visitMessageVisitorNotMember, + Event.VISIT_DELETED + ); + + expect(mockOneIdentity.cancelPersonWantsOrg.mock.calls).toEqual( + expectedUids.map((uid) => [uid]) + ); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledWith(mockUidESet, mockPerson.UID_Person); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(1); } ); - expect(mockOneIdentity.logout).toHaveBeenCalled(); + + it('should retry system cancellation without cancelling site access again', async () => { + const error = new Error('System cancellation failed'); + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg + .mockResolvedValueOnce([siteAccess, systemAccess]) + .mockResolvedValueOnce([ + { ...siteAccess, OrderState: OrderState.ABORTED }, + systemAccess, + ]); + mockOneIdentity.cancelPersonWantsOrg + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(error) + .mockResolvedValueOnce(undefined); + + await expect( + syncVisitToOneIdentityHandler( + visitMessageVisitorNotMember, + Event.VISIT_DELETED + ) + ).rejects.toBe(error); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).not.toHaveBeenCalled(); + + await syncVisitToOneIdentityHandler( + visitMessageVisitorNotMember, + Event.VISIT_DELETED + ); + + expect(mockOneIdentity.cancelPersonWantsOrg.mock.calls).toEqual([ + ['site-access-uid'], + ['system-access-uid'], + ['system-access-uid'], + ]); + expect( + mockOneIdentity.removeConnectionBetweenPersonAndProposal + ).toHaveBeenCalledTimes(1); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 296466fc..da4d6315 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -348,48 +348,37 @@ async function removeAccessFromOneIdentity( visitId: string, uidPerson: UID_Person ) { - // 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 (!siteAccess) { - throw new Error( - 'Site access not found in One Identity, cannot remove access' + const accessTypes = [ + { role: PersonWantsOrgRole.SITE_ACCESS, name: 'Site' }, + { role: PersonWantsOrgRole.SYSTEM_ACCESS, name: 'System' }, + ]; + + // Clean up each access type independently so retries can finish partial deletions. + for (const { role, name } of accessTypes) { + const accesses = personWantsOrgs.filter( + (pwo) => + pwo.DisplayOrg === role && + pwo.CustomProperty04 === visitId && + pwo.OrderState !== OrderState.ABORTED && + pwo.OrderState !== OrderState.UNSUBSCRIBED ); - } - await oneIdentity.cancelPersonWantsOrg(siteAccess.UID_PersonWantsOrg); - - logger.logInfo('Site access cancelled in One Identity', { - UID_PersonWantsOrg: siteAccess.UID_PersonWantsOrg, - }); + if (accesses.length === 0) { + logger.logInfo(`${name} access absent or already cancelled, skipping`, { + visitId, + uidPerson, + }); + } - // 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 - ); + for (const access of accesses) { + await oneIdentity.cancelPersonWantsOrg(access.UID_PersonWantsOrg); - if (!systemAccess) { - throw new Error( - 'System access not found in One Identity, cannot remove access' - ); + logger.logInfo(`${name} access cancelled in One Identity`, { + UID_PersonWantsOrg: access.UID_PersonWantsOrg, + }); + } } - - await oneIdentity.cancelPersonWantsOrg(systemAccess.UID_PersonWantsOrg); - - logger.logInfo('System access cancelled in One Identity', { - UID_PersonWantsOrg: systemAccess.UID_PersonWantsOrg, - }); } async function createProposalConnection( From 18f9c64918a94658ec0cce14aeb987b717251664 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Mon, 5 Oct 2026 13:52:37 +0200 Subject: [PATCH 04/11] fix(oneidentity): clarify existing visitor-proposal connection log message --- .../syncVisitToOneIdentityHandler.spec.ts | 2 +- .../syncVisitToOneIdentityHandler.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index 6baf1936..1b3fa816 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -331,7 +331,7 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.connectPersonToProposal).not.toHaveBeenCalled(); expect(logger.logInfo).toHaveBeenCalledWith( - 'Connection already exists, skipping', + 'Visitor is already connected to proposal in One Identity, skipping connection creation', { uidPerson: mockPerson.UID_Person, uidESet: mockUidESet, diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 852b55f7..dbfec187 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -395,10 +395,13 @@ async function createProposalConnection( ); if (exists) { - logger.logInfo('Connection already exists, skipping', { - uidPerson, - uidESet, - }); + logger.logInfo( + 'Visitor is already connected to proposal in One Identity, skipping connection creation', + { + uidPerson, + uidESet, + } + ); } else { await oneIdentity.connectPersonToProposal(uidESet, uidPerson); logger.logInfo('Connection created between proposal and visitor', { From bf1d015e81a3ebc1459f1630b66a66028e95a916 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Mon, 5 Oct 2026 15:53:15 +0200 Subject: [PATCH 05/11] update: The PEJ allowance period matches the calculated site access period. --- src/queue/consumers/oneidentity/README.md | 6 +- .../syncVisitToOneIdentityHandler.spec.ts | 206 ++++++++++++------ .../syncVisitToOneIdentityHandler.ts | 4 +- 3 files changed, 149 insertions(+), 67 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 3e73060b..22f6dd73 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -89,10 +89,14 @@ The handler manages site and system access in One Identity based on visit creati - Extends beyond visit by `ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS` (default: 30 days) ### Access Creation -- Site access matches exact visit dates +- Site access starts at UTC midnight on the visit start date and ends at UTC midnight after the visit end date, covering both visit days in full - System access starts from the visit start date and extends beyond the visit end date by a configurable number of days (default: 30) - Site and system access share the visit ID in `CustomProperty04` +### Daily Allowance Dates +- Approved PEJ allowance upserts use the same full-day UTC boundaries as site access, on both visit creation and update +- Allowance deletion continues to send empty date parameters + ### Access Cancellation - Site and system access are matched independently by role and visit ID - All matching access records are cancelled, except those already `Aborted` or `Unsubscribed` diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index 1b3fa816..f394a16d 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -236,38 +236,79 @@ describe('syncVisitToOneIdentityHandler', () => { Date.now = originalDateNow; }); - it('should upsert a PEJ allowance when it is requested and approved', 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; + it.each([ + [ + '2023-01-01T00:00:00.000Z', + '2023-01-10T00:00:00.000Z', + '2023-01-01T00:00:00.000Z', + '2023-01-11T00:00:00.000Z', + ], + [ + '2023-01-01T10:30:00.000Z', + '2023-01-10T14:45:00.000Z', + '2023-01-01T00:00:00.000Z', + '2023-01-11T00:00:00.000Z', + ], + [ + '2023-02-28T10:30:00.000Z', + '2023-02-28T14:45:00.000Z', + '2023-02-28T00:00:00.000Z', + '2023-03-01T00:00:00.000Z', + ], + [ + '2023-12-31T10:30:00.000Z', + '2023-12-31T14:45:00.000Z', + '2023-12-31T00:00:00.000Z', + '2024-01-01T00:00:00.000Z', + ], + ])( + 'should create an approved PEJ allowance with the same dates as site access (%s to %s)', + async (startAt, endAt, expectedFrom, expectedTo) => { + 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; + const allowanceVisitMessage: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt, + endAt, + }; - mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); - mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); - mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); - mockOneIdentity.upsertPersonWantsOrg - .mockResolvedValueOnce([mockSiteAccess]) - .mockResolvedValueOnce([mockSystemAccess]); + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); - await syncVisitToOneIdentityHandler( - visitMessageWithApprovedDailyAllowance, - Event.VISIT_CREATED - ); + await syncVisitToOneIdentityHandler( + allowanceVisitMessage, + Event.VISIT_CREATED + ); - expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( - visitMessageWithApprovedDailyAllowance.visitorId, - visitMessageWithApprovedDailyAllowance.id, - 'upsert', - visitMessageWithApprovedDailyAllowance.startAt, - visitMessageWithApprovedDailyAllowance.endAt - ); - }); + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 1, + PersonWantsOrgRole.SITE_ACCESS, + allowanceVisitMessage.visitorId, + expectedFrom, + expectedTo, + allowanceVisitMessage.id + ); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + allowanceVisitMessage.visitorId, + allowanceVisitMessage.id, + 'upsert', + expectedFrom, + expectedTo + ); + } + ); it('should not upsert a PEJ allowance unless it is both requested and approved', async () => { const mockPerson = { @@ -1007,44 +1048,81 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); - it('should upsert an approved PEJ allowance on visit update', 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; - const updatedVisitMessage: VisitMessage = { - ...visitMessageWithApprovedDailyAllowance, - startAt: '2023-02-01T00:00:00.000Z', - endAt: '2023-02-15T00:00:00.000Z', - }; + it.each(['missing', 'changed', 'unchanged'])( + 'should upsert an approved PEJ allowance with full-day dates when site access is %s on visit update', + async (scenario) => { + const mockPerson = { + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const updatedVisitMessage: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt: '2023-02-01T10:30:00.000Z', + endAt: '2023-02-15T14:45:00.000Z', + }; + const expectedFrom = '2023-02-01T00:00:00.000Z'; + const expectedTo = '2023-02-16T00:00:00.000Z'; + const mockSiteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: updatedVisitMessage.id, + OrderState: OrderState.GRANTED, + ValidFrom: + scenario === 'unchanged' ? expectedFrom : visitMessage.startAt, + ValidUntil: + scenario === 'unchanged' ? expectedTo : visitMessage.endAt, + } as PersonWantsOrg; + const mockSystemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: updatedVisitMessage.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; - mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); - mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); - mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([]); - mockOneIdentity.upsertPersonWantsOrg - .mockResolvedValueOnce([mockSiteAccess]) - .mockResolvedValueOnce([mockSystemAccess]); - mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce( + scenario === 'missing' ? [] : [mockSiteAccess, mockSystemAccess] + ); + if (scenario !== 'unchanged') { + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([mockSystemAccess]); + } + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); - await syncVisitToOneIdentityHandler( - updatedVisitMessage, - Event.VISIT_UPDATED - ); + await syncVisitToOneIdentityHandler( + updatedVisitMessage, + Event.VISIT_UPDATED + ); - expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( - updatedVisitMessage.visitorId, - updatedVisitMessage.id, - 'upsert', - updatedVisitMessage.startAt, - updatedVisitMessage.endAt - ); - }); + const expectedSiteAccessCalls = + scenario === 'unchanged' + ? [] + : [ + [ + PersonWantsOrgRole.SITE_ACCESS, + updatedVisitMessage.visitorId, + expectedFrom, + expectedTo, + updatedVisitMessage.id, + ...(scenario === 'changed' ? ['site-access-uid'] : []), + ], + ]; + expect( + mockOneIdentity.upsertPersonWantsOrg.mock.calls.filter( + ([role]) => role === PersonWantsOrgRole.SITE_ACCESS + ) + ).toEqual(expectedSiteAccessCalls); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + updatedVisitMessage.visitorId, + updatedVisitMessage.id, + 'upsert', + expectedFrom, + expectedTo + ); + } + ); it('should skip update when visit dates have not changed', async () => { const mockPerson = { diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index dbfec187..416c1daf 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -153,8 +153,8 @@ async function syncDailyAllowance( centralAccount, visitId, operation, - operation === 'delete' ? '' : startAt, - operation === 'delete' ? '' : endAt + operation === 'delete' ? '' : toStartOfDayIsoString(startAt), + operation === 'delete' ? '' : toStartOfNextDayIsoString(endAt) ); logger.logInfo('PEJ allowance synchronized in One Identity', { From 40b162cbeb1b895e3ae4aabac2d981ecc7bac8d2 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Tue, 6 Oct 2026 13:12:21 +0200 Subject: [PATCH 06/11] fix(oneidentity): use inclusive ISO dates for PEJ allowance validity Send YYYY-MM-DD dates without extending the allowance end date. --- src/queue/consumers/oneidentity/README.md | 4 +- .../syncVisitToOneIdentityHandler.spec.ts | 67 +++++++++++++++++-- .../syncVisitToOneIdentityHandler.ts | 15 ++++- .../oneidentity/utils/ESSOneIdentity.spec.ts | 10 +-- 4 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 22f6dd73..a9032e08 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -94,7 +94,9 @@ The handler manages site and system access in One Identity based on visit creati - Site and system access share the visit ID in `CustomProperty04` ### Daily Allowance Dates -- Approved PEJ allowance upserts use the same full-day UTC boundaries as site access, on both visit creation and update +- Approved PEJ allowance upserts send the visit's start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update +- PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date +- For a visit on 15–16 October, allowance validity is `2026-10-15` through `2026-10-16`, while site access retains its exclusive end of `2026-10-17T00:00:00.000Z` - Allowance deletion continues to send empty date parameters ### Access Cancellation diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index f394a16d..ed64cdb8 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -242,28 +242,51 @@ describe('syncVisitToOneIdentityHandler', () => { '2023-01-10T00:00:00.000Z', '2023-01-01T00:00:00.000Z', '2023-01-11T00:00:00.000Z', + '2023-01-01', + '2023-01-10', ], [ '2023-01-01T10:30:00.000Z', '2023-01-10T14:45:00.000Z', '2023-01-01T00:00:00.000Z', '2023-01-11T00:00:00.000Z', + '2023-01-01', + '2023-01-10', ], [ '2023-02-28T10:30:00.000Z', '2023-02-28T14:45:00.000Z', '2023-02-28T00:00:00.000Z', '2023-03-01T00:00:00.000Z', + '2023-02-28', + '2023-02-28', ], [ '2023-12-31T10:30:00.000Z', '2023-12-31T14:45:00.000Z', '2023-12-31T00:00:00.000Z', '2024-01-01T00:00:00.000Z', + '2023-12-31', + '2023-12-31', + ], + [ + '2026-10-15T00:00:00.000Z', + '2026-10-16T00:00:00.000Z', + '2026-10-15T00:00:00.000Z', + '2026-10-17T00:00:00.000Z', + '2026-10-15', + '2026-10-16', ], ])( - 'should create an approved PEJ allowance with the same dates as site access (%s to %s)', - async (startAt, endAt, expectedFrom, expectedTo) => { + 'should create an approved PEJ allowance with inclusive date-only validity (%s to %s)', + async ( + startAt, + endAt, + expectedFrom, + expectedTo, + allowanceFrom, + allowanceTo + ) => { const mockPerson = { UID_Person: 'visitor-uid', CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, @@ -304,8 +327,18 @@ describe('syncVisitToOneIdentityHandler', () => { allowanceVisitMessage.visitorId, allowanceVisitMessage.id, 'upsert', - expectedFrom, - expectedTo + allowanceFrom, + allowanceTo + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: allowanceVisitMessage.id, + centralAccount: allowanceVisitMessage.visitorId, + operation: 'upsert', + dateFrom: allowanceFrom, + dateTo: allowanceTo, + } ); } ); @@ -599,6 +632,16 @@ describe('syncVisitToOneIdentityHandler', () => { '', '' ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: visitMessageWithApprovedDailyAllowance.id, + centralAccount: visitMessageWithApprovedDailyAllowance.visitorId, + operation: 'delete', + dateFrom: '', + dateTo: '', + } + ); }); it('should skip removing proposal connection if visitor is a member of the proposal', async () => { @@ -1049,7 +1092,7 @@ describe('syncVisitToOneIdentityHandler', () => { }); it.each(['missing', 'changed', 'unchanged'])( - 'should upsert an approved PEJ allowance with full-day dates when site access is %s on visit update', + 'should upsert an approved PEJ allowance with inclusive date-only validity when site access is %s on visit update', async (scenario) => { const mockPerson = { UID_Person: 'visitor-uid', @@ -1118,8 +1161,18 @@ describe('syncVisitToOneIdentityHandler', () => { updatedVisitMessage.visitorId, updatedVisitMessage.id, 'upsert', - expectedFrom, - expectedTo + '2023-02-01', + '2023-02-15' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: updatedVisitMessage.id, + centralAccount: updatedVisitMessage.visitorId, + operation: 'upsert', + dateFrom: '2023-02-01', + dateTo: '2023-02-15', + } ); } ); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 416c1daf..2bc2704c 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -149,18 +149,25 @@ async function syncDailyAllowance( if (!operation) return; + // PEJ requires ISO 8601 date format (YYYY-MM-DD) for the allowance dates. + // Both dateFrom and dateTo are inclusive. + const dateFrom = operation === 'delete' ? '' : toIsoDateString(startAt); + const dateTo = operation === 'delete' ? '' : toIsoDateString(endAt); + await oneIdentity.syncPEJAllowance( centralAccount, visitId, operation, - operation === 'delete' ? '' : toStartOfDayIsoString(startAt), - operation === 'delete' ? '' : toStartOfNextDayIsoString(endAt) + dateFrom, + dateTo ); logger.logInfo('PEJ allowance synchronized in One Identity', { visitId, centralAccount, operation, + dateFrom, + dateTo, }); } @@ -450,6 +457,10 @@ function toIsoString(date: string | number) { return parsedDate.toISOString(); } +function toIsoDateString(date: string | number): string { + return toIsoString(date).slice(0, 10); +} + function toStartOfDayIsoString(date: string | number) { const parsedDate = new Date(toIsoString(date)); parsedDate.setUTCHours(0, 0, 0, 0); diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts index fb68f672..667e7c43 100644 --- a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts +++ b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts @@ -294,15 +294,15 @@ describe('ESSOneIdentity', () => { const centralAccount = 'visitor-oidc-sub'; const externalAllowanceId = '1e94c2d1-743b-48b5-99ae-f8c2d5f34e2b'; - it('should upsert a PEJ allowance with the visit dates', async () => { + it('should upsert a PEJ allowance with inclusive date-only visit dates', async () => { mockOneIdentityApi.callScript.mockResolvedValueOnce('allowance-uid'); const result = await essOneIdentity.syncPEJAllowance( centralAccount, externalAllowanceId, 'upsert', - '2026-08-24T08:00:00.000Z', - '2026-08-24T16:00:00.000Z' + '2026-08-24', + '2026-08-24' ); expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( @@ -311,8 +311,8 @@ describe('ESSOneIdentity', () => { centralAccount, externalAllowanceId, 'upsert', - '2026-08-24T08:00:00.000Z', - '2026-08-24T16:00:00.000Z', + '2026-08-24', + '2026-08-24', ] ); expect(result).toBe('allowance-uid'); From 80087fe89d5e5d845470d85e47f2b7f1f9df89ca Mon Sep 17 00:00:00 2001 From: janosbabik Date: Tue, 6 Oct 2026 17:51:12 +0200 Subject: [PATCH 07/11] update(oneidentity): correct timezone handling and retry-safe access updates - Validate and log the configured process timezone - Use DST-aware local calendar days for access and PEJ validity - Start system access at the current instant - Reconcile accesses independently and create missing active records - Add regression tests and update documentation --- sample.env | 3 + src/config/validateEnv.ts | 10 +- src/index.ts | 1 + src/queue/consumers/oneidentity/README.md | 26 +- .../syncVisitToOneIdentityHandler.spec.ts | 668 +++++++++++++++--- .../syncVisitToOneIdentityHandler.ts | 177 +++-- 6 files changed, 699 insertions(+), 186 deletions(-) diff --git a/sample.env b/sample.env index 8fccfdfa..ee01b00e 100644 --- a/sample.env +++ b/sample.env @@ -1,6 +1,9 @@ NODE_ENV=production SERVICE_NAME=folder-creation +# Process-wide timezone. +# TZ=Europe/Stockholm + RABBITMQ_HOSTNAME=localhost RABBITMQ_USERNAME=guest RABBITMQ_PASSWORD=guest diff --git a/src/config/validateEnv.ts b/src/config/validateEnv.ts index f974cfe0..47c2aaee 100644 --- a/src/config/validateEnv.ts +++ b/src/config/validateEnv.ts @@ -1,4 +1,11 @@ -import { cleanEnv, str, bool, port } from 'envalid'; +import { cleanEnv, str, bool, port, makeValidator } from 'envalid'; + +const timeZone = makeValidator((value) => { + // Fail on invalid timezone identifiers rather than silently using another timezone. + new Intl.DateTimeFormat('en-GB', { timeZone: value }); + + return value; +}); function validateEnv() { cleanEnv(process.env, { @@ -6,6 +13,7 @@ function validateEnv() { RABBITMQ_USERNAME: str(), RABBITMQ_PASSWORD: str(), // NOTE: All variables are optional except RabbitMQ connection ones. + TZ: timeZone({ default: undefined }), KAFKA_CLIENTID: str({ default: undefined }), KAFKA_BROKERS: str({ default: undefined }), KAFKA_TOPIC: str({ default: undefined }), diff --git a/src/index.ts b/src/index.ts index 86a62ee4..5e7721df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ async function bootstrap() { logger.logInfo('Server information: ', { nodeVersion: process.version, env: process.env.NODE_ENV, + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); const PORT = process.env.PORT || 4010; diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index a9032e08..b77e3f6e 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -3,16 +3,17 @@ ## Functional Specification ### Purpose -The handler manages site and system access in One Identity based on visit creation and deletion events for science users. +The handler manages site and system access in One Identity based on visit creation, update and deletion events for science users. ### Process Overview - When a visit is created, both site access and system access are provisioned in One Identity +- When a visit is updated, each access period is reconciled independently and missing active access records are created - When a visit is deleted, both site access and system access are cancelled in One Identity - Only science users (users with `CCC_EmployeeSubType === ESSSCIENCEUSER`) are processed ### Access Types - **Site Access**: Physical access to facility for the exact visit duration -- **System Access**: Digital access to systems, extends beyond the visit end date by a configurable number of days (default: 30) +- **System Access**: Digital access to systems starting at the current time when provisioned, extending beyond the visit end date by a configurable number of days (default: 30) ### Key Relationships - Both site and system access store the visit ID in `CustomProperty04` @@ -85,18 +86,29 @@ The handler manages site and system access in One Identity based on visit creati ## Key Implementation Details +### Timezone Configuration +- Set `TZ=Europe/Stockholm` in the dedicated OIM connector service/container environment before starting Node, or in its `.env` file + ### System Access Duration - Extends beyond visit by `ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS` (default: 30 days) ### Access Creation -- Site access starts at UTC midnight on the visit start date and ends at UTC midnight after the visit end date, covering both visit days in full -- System access starts from the visit start date and extends beyond the visit end date by a configurable number of days (default: 30) +- Site access starts at local midnight on the visit start date and ends at local midnight after the visit end date, covering both visit days in full +- System access starts at the current instant when provisioned, serialized as a UTC ISO timestamp without rounding to midnight; it ends at local midnight after the visit end date plus the configured calendar-day extension (default: 30) - Site and system access share the visit ID in `CustomProperty04` +### Access Updates +- Access records are matched by role and visit ID in `CustomProperty04`; both `Aborted` and `Unsubscribed` records are excluded +- Site access is updated only when its local-day start or end boundary differs from the visit's required validity +- System access is updated only when its expiry differs from local midnight after the visit end date plus the configured calendar-day extension; its start is set to the current instant when that update is processed +- The system start time is not compared against the current time; a correct existing system expiry preserves the original start, even if site access needs updating +- Missing active site or system access is created independently, without recreating the other access +- Both access periods must match before all access writes are skipped, allowing retries to repair system access after a successful site write followed by a failed system write + ### Daily Allowance Dates -- Approved PEJ allowance upserts send the visit's start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update +- Approved PEJ allowance upserts send the visit's local start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update - PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date -- For a visit on 15–16 October, allowance validity is `2026-10-15` through `2026-10-16`, while site access retains its exclusive end of `2026-10-17T00:00:00.000Z` +- With `TZ=Europe/Stockholm`, a visit on 28–29 October 2026 has allowance validity `2026-10-28` through `2026-10-29`. Site access starts at `2026-10-27T23:00:00.000Z` and ends at `2026-10-29T23:00:00.000Z` (30 October at Swedish midnight) - Allowance deletion continues to send empty date parameters ### Access Cancellation @@ -234,4 +246,4 @@ PROPOSAL_ACCEPTED PROPOSAL_UPDATED ### Error Handling - **Proposal Creation Failure**: If creating a new proposal (`ESet`) in One Identity fails during a `PROPOSAL_ACCEPTED` event, an error is thrown, and the process is halted. - **User Not Found**: If any users listed in the proposal message (proposer or members) cannot be found in One Identity, an error is logged. The process continues with the users that were found. -- **Logout Guarantee**: The One Identity session is always closed in a `finally` block, ensuring resources are released even if errors occur during the synchronization process. \ No newline at end of file +- **Logout Guarantee**: The One Identity session is always closed in a `finally` block, ensuring resources are released even if errors occur during the synchronization process. diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index ed64cdb8..1ca13f59 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -3,7 +3,7 @@ jest.mock('../utils/ESSOneIdentity', () => ({ ESSOneIdentity: jest.fn().mockImplementation(() => mockOneIdentity), })); -const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = '45'; +const ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS = '30'; jest.mock('process', () => ({ env: { @@ -43,13 +43,18 @@ const mockOneIdentity: jest.Mocked> = { hasPersonSiteAccessToProposal: jest.fn(), }; +// Expected local-midnight instants follow the process TZ, without offset calculations. +function localMidnightIso(date: string): string { + return new Date(`${date}T00:00:00`).toISOString(); +} + 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', + startAt: localMidnightIso('2023-01-01'), + endAt: localMidnightIso('2023-01-10'), proposal: { shortCode: 'proposal-short-code', members: [{ oidcSub: 'member-oidc-sub' }], @@ -97,6 +102,10 @@ describe('syncVisitToOneIdentityHandler', () => { jest.clearAllMocks(); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('Science user verification', () => { it('should skip processing if visitor is not a science user', async () => { // Mock person that is not a science user @@ -125,9 +134,8 @@ describe('syncVisitToOneIdentityHandler', () => { describe('VISIT_CREATED', () => { it('should create site access and system access in One Identity for science users and connect to proposal', async () => { // Mock the current time to a fixed value for testing - const mockNowDate = new Date('2022-12-15T12:30:00.000Z'); - const originalDateNow = Date.now; - Date.now = jest.fn(() => mockNowDate.getTime()); + const mockNowDate = new Date('2022-12-15T12:30:45.678Z'); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); const visitMessageWithTimes = { ...visitMessage, startAt: '2023-01-01T10:30:00.000Z', @@ -179,21 +187,14 @@ describe('syncVisitToOneIdentityHandler', () => { 1, PersonWantsOrgRole.SITE_ACCESS, visitMessageWithTimes.visitorId, - '2023-01-01T00:00:00.000Z', - '2023-01-11T00:00:00.000Z', + localMidnightIso('2023-01-01'), + localMidnightIso('2023-01-11'), visitMessageWithTimes.id ); - // Calculate expected system access dates - // validFrom should be the current mock date - const expectedValidFrom = '2022-12-15T00:00:00.000Z'; - const expectedEndDate = new Date(visitMessageWithTimes.endAt); - expectedEndDate.setUTCDate( - expectedEndDate.getUTCDate() + - parseInt(ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS) + - 1 - ); - expectedEndDate.setUTCHours(0, 0, 0, 0); + // Start at the exact current instant; expiry remains a local-day boundary. + const expectedValidFrom = mockNowDate.toISOString(); + const expectedValidUntil = localMidnightIso('2023-02-10'); // Verify system access creation expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( @@ -201,7 +202,7 @@ describe('syncVisitToOneIdentityHandler', () => { PersonWantsOrgRole.SYSTEM_ACCESS, visitMessageWithTimes.visitorId, expectedValidFrom, - expectedEndDate.toISOString(), + expectedValidUntil, visitMessageWithTimes.id ); @@ -231,52 +232,97 @@ describe('syncVisitToOneIdentityHandler', () => { ); expect(mockOneIdentity.logout).toHaveBeenCalled(); - - // Restore original Date.now - Date.now = originalDateNow; }); it.each([ [ - '2023-01-01T00:00:00.000Z', - '2023-01-10T00:00:00.000Z', - '2023-01-01T00:00:00.000Z', - '2023-01-11T00:00:00.000Z', + localMidnightIso('2023-01-01'), + localMidnightIso('2023-01-10'), + localMidnightIso('2023-01-01'), + localMidnightIso('2023-01-11'), '2023-01-01', '2023-01-10', ], [ '2023-01-01T10:30:00.000Z', '2023-01-10T14:45:00.000Z', - '2023-01-01T00:00:00.000Z', - '2023-01-11T00:00:00.000Z', + localMidnightIso('2023-01-01'), + localMidnightIso('2023-01-11'), '2023-01-01', '2023-01-10', ], [ '2023-02-28T10:30:00.000Z', '2023-02-28T14:45:00.000Z', - '2023-02-28T00:00:00.000Z', - '2023-03-01T00:00:00.000Z', + localMidnightIso('2023-02-28'), + localMidnightIso('2023-03-01'), '2023-02-28', '2023-02-28', ], [ '2023-12-31T10:30:00.000Z', '2023-12-31T14:45:00.000Z', - '2023-12-31T00:00:00.000Z', - '2024-01-01T00:00:00.000Z', + localMidnightIso('2023-12-31'), + localMidnightIso('2024-01-01'), '2023-12-31', '2023-12-31', ], [ - '2026-10-15T00:00:00.000Z', - '2026-10-16T00:00:00.000Z', - '2026-10-15T00:00:00.000Z', - '2026-10-17T00:00:00.000Z', + localMidnightIso('2026-10-15'), + localMidnightIso('2026-10-16'), + localMidnightIso('2026-10-15'), + localMidnightIso('2026-10-17'), '2026-10-15', '2026-10-16', ], + [ + localMidnightIso('2026-10-28'), + localMidnightIso('2026-10-29'), + localMidnightIso('2026-10-28'), + localMidnightIso('2026-10-30'), + '2026-10-28', + '2026-10-29', + ], + [ + '2026-10-28T00:00:00.000', + '2026-10-29T00:00:00.000', + localMidnightIso('2026-10-28'), + localMidnightIso('2026-10-30'), + '2026-10-28', + '2026-10-29', + ], + [ + '2026-10-28', + '2026-10-29', + localMidnightIso('2026-10-28'), + localMidnightIso('2026-10-30'), + '2026-10-28', + '2026-10-29', + ], + [ + localMidnightIso('2026-07-15'), + localMidnightIso('2026-07-15'), + localMidnightIso('2026-07-15'), + localMidnightIso('2026-07-16'), + '2026-07-15', + '2026-07-15', + ], + [ + localMidnightIso('2026-03-29'), + localMidnightIso('2026-03-29'), + localMidnightIso('2026-03-29'), + localMidnightIso('2026-03-30'), + '2026-03-29', + '2026-03-29', + ], + [ + localMidnightIso('2026-10-25'), + localMidnightIso('2026-10-25'), + localMidnightIso('2026-10-25'), + localMidnightIso('2026-10-26'), + '2026-10-25', + '2026-10-25', + ], ])( 'should create an approved PEJ allowance with inclusive date-only validity (%s to %s)', async ( @@ -343,6 +389,77 @@ describe('syncVisitToOneIdentityHandler', () => { } ); + it.each([Event.VISIT_CREATED, Event.VISIT_UPDATED])( + 'should extend system access by local calendar days across the autumn DST change on %s', + async (type) => { + const message: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt: localMidnightIso('2026-10-24'), + endAt: localMidnightIso('2026-10-25'), + }; + const mockNowDate = new Date('2026-10-24T12:30:45.678'); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); + const siteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: message.id, + OrderState: OrderState.GRANTED, + ValidFrom: localMidnightIso('2026-10-01'), + ValidUntil: localMidnightIso('2026-10-02'), + } as PersonWantsOrg; + const systemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: message.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + mockOneIdentity.getPerson.mockResolvedValueOnce({ + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + if (type === Event.VISIT_UPDATED) { + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([ + siteAccess, + systemAccess, + ]); + } + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([siteAccess]) + .mockResolvedValueOnce([systemAccess]); + + await syncVisitToOneIdentityHandler(message, type); + + expect( + mockOneIdentity.upsertPersonWantsOrg.mock.calls[0].slice(0, 5) + ).toEqual([ + PersonWantsOrgRole.SITE_ACCESS, + message.visitorId, + localMidnightIso('2026-10-24'), + localMidnightIso('2026-10-26'), + message.id, + ]); + // The 30-day extension covers every day through 24 November. + expect( + mockOneIdentity.upsertPersonWantsOrg.mock.calls[1].slice(0, 5) + ).toEqual([ + PersonWantsOrgRole.SYSTEM_ACCESS, + message.visitorId, + mockNowDate.toISOString(), + localMidnightIso('2026-11-25'), + message.id, + ]); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + message.visitorId, + message.id, + 'upsert', + '2026-10-24', + '2026-10-25' + ); + } + ); + it('should not upsert a PEJ allowance unless it is both requested and approved', async () => { const mockPerson = { UID_Person: 'visitor-uid', @@ -482,7 +599,7 @@ describe('syncVisitToOneIdentityHandler', () => { await expect( syncVisitToOneIdentityHandler(invalidVisitMessage, Event.VISIT_CREATED) - ).rejects.toThrow('Invalid date provided to toIsoString: invalid-date'); + ).rejects.toThrow('Invalid date provided: invalid-date'); expect(mockOneIdentity.login).toHaveBeenCalled(); expect(mockOneIdentity.getPerson).toHaveBeenCalledWith( @@ -493,7 +610,107 @@ describe('syncVisitToOneIdentityHandler', () => { }); }); - describe('VISITOR_DELETED', () => { + describe('full-day validity with a 30-day system access extension', () => { + it.each([Event.VISIT_CREATED, Event.VISIT_UPDATED])( + 'should cover 28–29 October plus all 30 days through 28 November on %s', + async (type) => { + const message: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + // With TZ=Europe/Stockholm these serialize to the sender's exact + // timestamps: 2026-10-27T23:00Z and 2026-10-28T23:00Z. + startAt: localMidnightIso('2026-10-28'), + endAt: localMidnightIso('2026-10-29'), + }; + const mockNowDate = new Date('2026-10-06T12:30:45.678'); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); + const siteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: message.id, + OrderState: OrderState.GRANTED, + ValidFrom: localMidnightIso('2026-10-01'), + ValidUntil: localMidnightIso('2026-10-02'), + } as PersonWantsOrg; + const systemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: message.id, + OrderState: OrderState.GRANTED, + } as PersonWantsOrg; + mockOneIdentity.getPerson.mockResolvedValueOnce({ + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person); + mockOneIdentity.getProposal.mockResolvedValueOnce(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + if (type === Event.VISIT_UPDATED) { + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([ + siteAccess, + systemAccess, + ]); + } + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([siteAccess]) + .mockResolvedValueOnce([systemAccess]); + + await syncVisitToOneIdentityHandler(message, type); + + expect(mockOneIdentity.upsertPersonWantsOrg.mock.calls).toEqual([ + [ + PersonWantsOrgRole.SITE_ACCESS, + message.visitorId, + localMidnightIso('2026-10-28'), + localMidnightIso('2026-10-30'), + message.id, + ...(type === Event.VISIT_UPDATED ? ['site-access-uid'] : []), + ], + [ + PersonWantsOrgRole.SYSTEM_ACCESS, + message.visitorId, + mockNowDate.toISOString(), + localMidnightIso('2026-11-29'), + message.id, + ...(type === Event.VISIT_UPDATED ? ['system-access-uid'] : []), + ], + ]); + const [, , siteFrom, siteUntil] = + mockOneIdentity.upsertPersonWantsOrg.mock.calls[0]; + const [, , , systemUntil] = + mockOneIdentity.upsertPersonWantsOrg.mock.calls[1]; + // Count local calendar days, not fixed 24-hour intervals across DST. + const calendarDay = (iso: string) => { + const date = new Date(iso); + + return ( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / + 86400000 + ); + }; + expect(calendarDay(siteUntil) - calendarDay(siteFrom)).toBe(2); + expect(calendarDay(systemUntil) - calendarDay(siteUntil)).toBe(30); + expect(calendarDay(systemUntil) - calendarDay(siteFrom)).toBe(32); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + message.visitorId, + message.id, + 'upsert', + '2026-10-28', + '2026-10-29' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: message.id, + centralAccount: message.visitorId, + operation: 'upsert', + dateFrom: '2026-10-28', + dateTo: '2026-10-29', + } + ); + } + ); + }); + + describe('VISIT_DELETED', () => { it('should remove visitor access and proposal connection in One Identity for science users (if not a member)', async () => { // Mock person that is a science user const mockPerson = { @@ -1016,14 +1233,14 @@ describe('syncVisitToOneIdentityHandler', () => { 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()); + const mockNowDate = new Date('2023-02-01T12:30:45.678Z'); + jest.spyOn(Date, 'now').mockReturnValue(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', + startAt: localMidnightIso('2023-02-01'), + endAt: localMidnightIso('2023-03-15'), }; await syncVisitToOneIdentityHandler( @@ -1044,8 +1261,8 @@ describe('syncVisitToOneIdentityHandler', () => { 1, PersonWantsOrgRole.SITE_ACCESS, 'visitor-oidc-sub', - '2023-02-01T00:00:00.000Z', - '2023-02-16T00:00:00.000Z', + localMidnightIso('2023-02-01'), + localMidnightIso('2023-03-16'), '1', 'site-access-uid' ); @@ -1056,24 +1273,15 @@ describe('syncVisitToOneIdentityHandler', () => { } ); - // 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.setUTCDate( - expectedSystemAccessValidUntil.getUTCDate() + - parseInt(ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS) + - 1 - ); - expectedSystemAccessValidUntil.setUTCHours(0, 0, 0, 0); - const expectedSystemAccessValidFrom = new Date(mockNowDate); - expectedSystemAccessValidFrom.setUTCHours(0, 0, 0, 0); + // The 30-day extension crosses into summer time in Europe/Stockholm. + const expectedSystemAccessValidFrom = mockNowDate.toISOString(); + const expectedSystemAccessValidUntil = localMidnightIso('2023-04-15'); expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( 2, PersonWantsOrgRole.SYSTEM_ACCESS, 'visitor-oidc-sub', - expectedSystemAccessValidFrom.toISOString(), - expectedSystemAccessValidUntil.toISOString(), + expectedSystemAccessValidFrom, + expectedSystemAccessValidUntil, '1', 'system-access-uid' ); @@ -1100,11 +1308,11 @@ describe('syncVisitToOneIdentityHandler', () => { } as Person; const updatedVisitMessage: VisitMessage = { ...visitMessageWithApprovedDailyAllowance, - startAt: '2023-02-01T10:30:00.000Z', - endAt: '2023-02-15T14:45:00.000Z', + startAt: localMidnightIso('2026-10-28'), + endAt: localMidnightIso('2026-10-29'), }; - const expectedFrom = '2023-02-01T00:00:00.000Z'; - const expectedTo = '2023-02-16T00:00:00.000Z'; + const expectedFrom = localMidnightIso('2026-10-28'); + const expectedTo = localMidnightIso('2026-10-30'); const mockSiteAccess = { UID_PersonWantsOrg: 'site-access-uid', DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, @@ -1120,6 +1328,10 @@ describe('syncVisitToOneIdentityHandler', () => { DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, CustomProperty04: updatedVisitMessage.id, OrderState: OrderState.GRANTED, + ValidUntil: + scenario === 'unchanged' + ? localMidnightIso('2026-11-29') + : visitMessage.endAt, } as PersonWantsOrg; mockOneIdentity.getPerson.mockResolvedValueOnce(mockPerson); @@ -1149,7 +1361,7 @@ describe('syncVisitToOneIdentityHandler', () => { expectedFrom, expectedTo, updatedVisitMessage.id, - ...(scenario === 'changed' ? ['site-access-uid'] : []), + scenario === 'changed' ? 'site-access-uid' : '', ], ]; expect( @@ -1161,8 +1373,8 @@ describe('syncVisitToOneIdentityHandler', () => { updatedVisitMessage.visitorId, updatedVisitMessage.id, 'upsert', - '2023-02-01', - '2023-02-15' + '2026-10-28', + '2026-10-29' ); expect(logger.logInfo).toHaveBeenCalledWith( 'PEJ allowance synchronized in One Identity', @@ -1170,8 +1382,8 @@ describe('syncVisitToOneIdentityHandler', () => { visitId: updatedVisitMessage.id, centralAccount: updatedVisitMessage.visitorId, operation: 'upsert', - dateFrom: '2023-02-01', - dateTo: '2023-02-15', + dateFrom: '2026-10-28', + dateTo: '2026-10-29', } ); } @@ -1187,8 +1399,8 @@ describe('syncVisitToOneIdentityHandler', () => { UID_PersonWantsOrg: 'site-access-uid', UID_PersonOrdered: 'visitor-uid', DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, - ValidFrom: visitMessage.startAt, - ValidUntil: '2023-01-11T00:00:00.000Z', + ValidFrom: localMidnightIso('2023-01-01'), + ValidUntil: localMidnightIso('2023-01-11'), CustomProperty04: '1', OrderState: OrderState.GRANTED, } as PersonWantsOrg; @@ -1197,8 +1409,8 @@ describe('syncVisitToOneIdentityHandler', () => { UID_PersonWantsOrg: 'system-access-uid', UID_PersonOrdered: 'visitor-uid', DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, - ValidFrom: visitMessage.startAt, - ValidUntil: visitMessage.endAt, + ValidFrom: '2022-12-15T12:30:45.678Z', + ValidUntil: localMidnightIso('2023-02-10'), CustomProperty04: '1', OrderState: OrderState.GRANTED, } as PersonWantsOrg; @@ -1237,7 +1449,9 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); - it('should create new access if site access not found', async () => { + it('should create new access starting system access at the current time if site access is not found', async () => { + const mockNowDate = new Date('2022-12-15T12:30:45.678Z'); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); const mockPerson = { UID_Person: 'visitor-uid', CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, @@ -1269,15 +1483,21 @@ describe('syncVisitToOneIdentityHandler', () => { ); expect(logger.logInfo).toHaveBeenCalledWith( - 'Site access not found in One Identity, creating access', - { - visitId: visitMessage.id, - uidPerson: 'visitor-uid', - } + 'Site access created in One Identity', + { UID_PersonWantsOrg: 'site-access-uid' } ); - // Verify creation occurred instead of update + // Verify creation occurred instead of update, without rounding the start time. expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenCalledTimes(2); + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 2, + PersonWantsOrgRole.SYSTEM_ACCESS, + visitMessage.visitorId, + mockNowDate.toISOString(), + localMidnightIso('2023-02-10'), + visitMessage.id, + '' + ); expect(mockOneIdentity.connectPersonToProposal).toHaveBeenCalledWith( mockUidESet, @@ -1286,7 +1506,271 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); - it('should throw error if system access not found during update', async () => { + describe('independent access reconciliation', () => { + const mockNowDate = new Date('2022-12-15T12:30:45.678Z'); + const siteAccess = { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: visitMessage.id, + OrderState: OrderState.GRANTED, + ValidFrom: localMidnightIso('2023-01-01'), + ValidUntil: localMidnightIso('2023-01-11'), + } as PersonWantsOrg; + const systemAccess = { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: visitMessage.id, + OrderState: OrderState.GRANTED, + ValidFrom: '2022-11-01T12:30:45.678Z', + ValidUntil: localMidnightIso('2023-02-10'), + } as PersonWantsOrg; + const oldSiteAccess = { + ...siteAccess, + ValidFrom: localMidnightIso('2022-12-31'), + ValidUntil: localMidnightIso('2023-01-09'), + }; + const oldSystemAccess = { + ...systemAccess, + ValidUntil: localMidnightIso('2023-02-08'), + }; + const siteCall = (uid = siteAccess.UID_PersonWantsOrg) => [ + PersonWantsOrgRole.SITE_ACCESS, + visitMessage.visitorId, + siteAccess.ValidFrom, + siteAccess.ValidUntil, + visitMessage.id, + uid, + ]; + const systemCall = (uid = systemAccess.UID_PersonWantsOrg) => [ + PersonWantsOrgRole.SYSTEM_ACCESS, + visitMessage.visitorId, + mockNowDate.toISOString(), + systemAccess.ValidUntil, + visitMessage.id, + uid, + ]; + + beforeEach(() => { + Object.values(mockOneIdentity).forEach((mock) => mock.mockReset()); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); + mockOneIdentity.getPerson.mockResolvedValue({ + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person); + mockOneIdentity.getProposal.mockResolvedValue(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValue([]); + mockOneIdentity.upsertPersonWantsOrg.mockImplementation( + async (role) => [ + role === PersonWantsOrgRole.SITE_ACCESS ? siteAccess : systemAccess, + ] + ); + }); + + afterEach(() => { + Object.values(mockOneIdentity).forEach((mock) => mock.mockReset()); + }); + + it.each([ + { + scenario: 'only system expiry is incorrect', + accesses: [siteAccess, oldSystemAccess], + expectedCalls: [systemCall()], + }, + { + scenario: 'only site dates are incorrect', + accesses: [oldSiteAccess, systemAccess], + expectedCalls: [siteCall()], + }, + { + scenario: 'only the visit start date changed', + accesses: [ + { ...siteAccess, ValidFrom: localMidnightIso('2022-12-31') }, + systemAccess, + ], + expectedCalls: [siteCall()], + }, + { + scenario: 'site access is missing but system access is correct', + accesses: [systemAccess], + expectedCalls: [siteCall('')], + }, + { + scenario: 'system access is missing but site access is correct', + accesses: [siteAccess], + expectedCalls: [systemCall('')], + }, + { + scenario: 'both access records are missing', + accesses: [], + expectedCalls: [siteCall(''), systemCall('')], + }, + { + scenario: 'only records for another visit exist', + accesses: [ + { ...siteAccess, CustomProperty04: 'another-visit' }, + { ...systemAccess, CustomProperty04: 'another-visit' }, + ], + expectedCalls: [siteCall(''), systemCall('')], + }, + { + scenario: 'both periods are correct despite an earlier system start', + accesses: [siteAccess, systemAccess], + expectedCalls: [], + }, + { + scenario: 'stored timestamps use equivalent timezone offsets', + accesses: [ + { + ...siteAccess, + ValidFrom: new Date(siteAccess.ValidFrom).toString(), + ValidUntil: new Date(siteAccess.ValidUntil).toString(), + }, + { + ...systemAccess, + ValidUntil: new Date(systemAccess.ValidUntil).toString(), + }, + ], + expectedCalls: [], + }, + ])( + 'should reconcile independently when $scenario', + async ({ accesses, expectedCalls }) => { + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce(accesses); + + await syncVisitToOneIdentityHandler( + visitMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.upsertPersonWantsOrg.mock.calls).toEqual( + expectedCalls + ); + expect(mockOneIdentity.getPersonWantsOrg).toHaveBeenCalledWith( + 'visitor-uid' + ); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(1); + } + ); + + it.each([ + [PersonWantsOrgRole.SITE_ACCESS, OrderState.ABORTED], + [PersonWantsOrgRole.SITE_ACCESS, OrderState.UNSUBSCRIBED], + [PersonWantsOrgRole.SYSTEM_ACCESS, OrderState.ABORTED], + [PersonWantsOrgRole.SYSTEM_ACCESS, OrderState.UNSUBSCRIBED], + ])('should replace %s when it is %s', async (role, state) => { + const accesses = [siteAccess, systemAccess].map((access) => + access.DisplayOrg === role ? { ...access, OrderState: state } : access + ); + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce(accesses); + + await syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED); + + expect(mockOneIdentity.upsertPersonWantsOrg.mock.calls).toEqual([ + role === PersonWantsOrgRole.SITE_ACCESS + ? siteCall('') + : systemCall(''), + ]); + }); + + it.each([ + [PersonWantsOrgRole.SITE_ACCESS, OrderState.ABORTED], + [PersonWantsOrgRole.SITE_ACCESS, OrderState.UNSUBSCRIBED], + [PersonWantsOrgRole.SYSTEM_ACCESS, OrderState.ABORTED], + [PersonWantsOrgRole.SYSTEM_ACCESS, OrderState.UNSUBSCRIBED], + ])( + 'should ignore cancelled %s in state %s before an active record', + async (role, state) => { + const cancelledAccess = { + ...(role === PersonWantsOrgRole.SITE_ACCESS + ? oldSiteAccess + : oldSystemAccess), + UID_PersonWantsOrg: 'cancelled-access-uid', + OrderState: state, + }; + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([ + cancelledAccess, + siteAccess, + systemAccess, + ]); + + await syncVisitToOneIdentityHandler( + visitMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); + } + ); + + it.each(['update', 'creation'])( + 'should finish a partial %s on retry without repeating the site write', + async (operation) => { + const creating = operation === 'creation'; + const error = new Error('System access write failed'); + mockOneIdentity.getPersonWantsOrg + .mockResolvedValueOnce( + creating ? [] : [oldSiteAccess, oldSystemAccess] + ) + .mockResolvedValueOnce( + creating ? [siteAccess] : [siteAccess, oldSystemAccess] + ) + .mockResolvedValueOnce([siteAccess, systemAccess]); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([siteAccess]) + .mockRejectedValueOnce(error); + + await expect( + syncVisitToOneIdentityHandler(visitMessage, Event.VISIT_UPDATED) + ).rejects.toBe(error); + expect( + mockOneIdentity.connectPersonToProposal + ).not.toHaveBeenCalled(); + + const retryTime = new Date('2022-12-16T13:31:46.789Z'); + jest.mocked(Date.now).mockReturnValue(retryTime.getTime()); + await syncVisitToOneIdentityHandler( + visitMessage, + Event.VISIT_UPDATED + ); + const expectedSystemCall = systemCall( + creating ? '' : systemAccess.UID_PersonWantsOrg + ); + expect(mockOneIdentity.upsertPersonWantsOrg.mock.calls).toEqual([ + siteCall(creating ? '' : siteAccess.UID_PersonWantsOrg), + expectedSystemCall, + [ + ...expectedSystemCall.slice(0, 2), + retryTime.toISOString(), + ...expectedSystemCall.slice(3), + ], + ]); + + // Once both periods match, replaying the event must not reset the start time. + await syncVisitToOneIdentityHandler( + visitMessage, + Event.VISIT_UPDATED + ); + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenCalledTimes(3); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(3); + } + ); + + it('should validate visit dates before updating either access', async () => { + mockOneIdentity.getPersonWantsOrg.mockResolvedValueOnce([]); + + await expect( + syncVisitToOneIdentityHandler( + { ...visitMessage, endAt: 'invalid-date' }, + Event.VISIT_UPDATED + ) + ).rejects.toThrow('Invalid date provided: invalid-date'); + + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(1); + }); + }); + + it('should create missing system access during update', async () => { const mockPerson = { UID_Person: 'visitor-uid', CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, @@ -1316,10 +1800,28 @@ describe('syncVisitToOneIdentityHandler', () => { 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' + const mockNowDate = new Date('2023-01-15T12:30:45.678Z'); + jest.spyOn(Date, 'now').mockReturnValue(mockNowDate.getTime()); + mockOneIdentity.upsertPersonWantsOrg + .mockResolvedValueOnce([mockSiteAccess]) + .mockResolvedValueOnce([ + { UID_PersonWantsOrg: 'new-system-access-uid' } as PersonWantsOrg, + ]); + mockOneIdentity.getProposalPersonConnections.mockResolvedValueOnce([]); + + await syncVisitToOneIdentityHandler( + updatedVisitMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 2, + PersonWantsOrgRole.SYSTEM_ACCESS, + updatedVisitMessage.visitorId, + mockNowDate.toISOString(), + localMidnightIso('2023-03-18'), + updatedVisitMessage.id, + '' ); expect(mockOneIdentity.login).toHaveBeenCalled(); diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 2bc2704c..f19faf12 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -77,7 +77,7 @@ export async function syncVisitToOneIdentityHandler( type ); } 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. + // Reconcile each access independently so retries can finish partial updates. await updateAccessInOneIdentity( oneIdentity, visitId, @@ -149,10 +149,15 @@ async function syncDailyAllowance( if (!operation) return; - // PEJ requires ISO 8601 date format (YYYY-MM-DD) for the allowance dates. - // Both dateFrom and dateTo are inclusive. - const dateFrom = operation === 'delete' ? '' : toIsoDateString(startAt); - const dateTo = operation === 'delete' ? '' : toIsoDateString(endAt); + // PEJ requires the ISO 8601 dates (YYYY-MM-DD). + const dateFrom = + operation === 'delete' + ? '' + : toLocalDate(startAt).toLocaleDateString('sv-SE'); + const dateTo = + operation === 'delete' + ? '' + : toLocalDate(endAt).toLocaleDateString('sv-SE'); await oneIdentity.syncPEJAllowance( centralAccount, @@ -227,18 +232,15 @@ async function createAccessInOneIdentity( UID_PersonWantsOrg: pwoSite.UID_PersonWantsOrg, }); - // validFrom in One Identity should be in the future so that the access is not immediately available + // System access starts at the current instant, without rounding to midnight. const validFrom = Date.now(); - const validUntil = new Date(endAt).setUTCDate( - new Date(endAt).getUTCDate() + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS - ); // Create system access const [pwoSystem] = await oneIdentity.upsertPersonWantsOrg( PersonWantsOrgRole.SYSTEM_ACCESS, centralAccount, - toStartOfDayIsoString(validFrom), - toStartOfNextDayIsoString(validUntil), + new Date(validFrom).toISOString(), + toStartOfNextDayIsoString(endAt, ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS), visitId // CustomProperty04 - We store the visit ID for the system access to be able to find it later ); @@ -255,56 +257,37 @@ async function updateAccessInOneIdentity( 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( + const activeAccesses = personWantsOrgs.filter( (pwo) => - pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS && - pwo.CustomProperty04 === visitId && // CustomProperty04 is the visit ID for the site access - pwo.OrderState !== OrderState.ABORTED + pwo.CustomProperty04 === visitId && + pwo.OrderState !== OrderState.ABORTED && + pwo.OrderState !== OrderState.UNSUBSCRIBED + ); + const siteAccess = activeAccesses.find( + (pwo) => pwo.DisplayOrg === PersonWantsOrgRole.SITE_ACCESS + ); + const systemAccess = activeAccesses.find( + (pwo) => pwo.DisplayOrg === PersonWantsOrgRole.SYSTEM_ACCESS ); - // 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; - } - + // Calculate all date boundaries before making any changes. const validFrom = toStartOfDayIsoString(startAt); const validUntil = toStartOfNextDayIsoString(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 + const systemAccessValidUntil = toStartOfNextDayIsoString( + endAt, + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS ); - - if (!systemAccess) { - throw new Error( - 'System access not found in One Identity, cannot update access' - ); - } - - if ( + const siteDatesMatch = + siteAccess && isSameDateTime(siteAccess.ValidFrom, validFrom) && - isSameDateTime(siteAccess.ValidUntil, validUntil) - ) { + isSameDateTime(siteAccess.ValidUntil, validUntil); + // Never compare the stored system start time against the moving current time. + const systemExpiryMatches = + systemAccess && + isSameDateTime(systemAccess.ValidUntil, systemAccessValidUntil); + + if (siteDatesMatch && systemExpiryMatches) { logger.logInfo( 'Visit dates unchanged, skipping access update in One Identity', { @@ -316,38 +299,46 @@ async function updateAccessInOneIdentity( 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, - }); + if (!siteDatesMatch) { + const siteAccessResult = await oneIdentity.upsertPersonWantsOrg( + PersonWantsOrgRole.SITE_ACCESS, + centralAccount, + validFrom, + validUntil, + visitId, + siteAccess?.UID_PersonWantsOrg ?? '' + ); - const systemAccessValidFrom = toStartOfDayIsoString(Date.now()); - const systemAccessValidUntil = toStartOfNextDayIsoString( - new Date(endAt).setUTCDate( - new Date(endAt).getUTCDate() + ONE_IDENTITY_SYSTEM_ACCESS_LASTS_FOR_DAYS - ) - ); + logger.logInfo( + `Site access ${siteAccess ? 'updated' : 'created'} in One Identity`, + { + UID_PersonWantsOrg: + siteAccess?.UID_PersonWantsOrg ?? + siteAccessResult[0].UID_PersonWantsOrg, + } + ); + } - await oneIdentity.upsertPersonWantsOrg( - PersonWantsOrgRole.SYSTEM_ACCESS, - centralAccount, - systemAccessValidFrom, - systemAccessValidUntil, - visitId, - systemAccess.UID_PersonWantsOrg - ); + // A failed system update can be retried even if the site update already succeeded. + if (!systemExpiryMatches) { + const systemAccessResult = await oneIdentity.upsertPersonWantsOrg( + PersonWantsOrgRole.SYSTEM_ACCESS, + centralAccount, + new Date(Date.now()).toISOString(), + systemAccessValidUntil, + visitId, + systemAccess?.UID_PersonWantsOrg ?? '' + ); - logger.logInfo('System access updated in One Identity', { - UID_PersonWantsOrg: systemAccess.UID_PersonWantsOrg, - }); + logger.logInfo( + `System access ${systemAccess ? 'updated' : 'created'} in One Identity`, + { + UID_PersonWantsOrg: + systemAccess?.UID_PersonWantsOrg ?? + systemAccessResult[0].UID_PersonWantsOrg, + } + ); + } } async function removeAccessFromOneIdentity( @@ -447,31 +438,27 @@ async function removeProposalConnection( } } -function toIsoString(date: string | number) { +function toLocalDate(date: string | number) { const parsedDate = new Date(date); if (isNaN(parsedDate.getTime())) { - throw new Error(`Invalid date provided to toIsoString: ${date}`); + throw new Error(`Invalid date provided: ${date}`); } - return parsedDate.toISOString(); -} - -function toIsoDateString(date: string | number): string { - return toIsoString(date).slice(0, 10); + return parsedDate; } function toStartOfDayIsoString(date: string | number) { - const parsedDate = new Date(toIsoString(date)); - parsedDate.setUTCHours(0, 0, 0, 0); + const parsedDate = toLocalDate(date); + parsedDate.setHours(0, 0, 0, 0); return parsedDate.toISOString(); } -function toStartOfNextDayIsoString(date: string | number) { - const parsedDate = new Date(toIsoString(date)); - parsedDate.setUTCHours(0, 0, 0, 0); - parsedDate.setUTCDate(parsedDate.getUTCDate() + 1); +function toStartOfNextDayIsoString(date: string | number, additionalDays = 0) { + const parsedDate = toLocalDate(date); + parsedDate.setHours(0, 0, 0, 0); + parsedDate.setDate(parsedDate.getDate() + additionalDays + 1); return parsedDate.toISOString(); } From 527bb0df56cca6a16600b0d9e795a31d7d641947 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Wed, 7 Oct 2026 14:42:53 +0200 Subject: [PATCH 08/11] fix(oneidentity): remove allowances when request or approval is withdrawn - Reconcile allowance eligibility on visit updates without previous state - Always delete allowances during visit deletion - Add regression tests and update documentation --- src/queue/consumers/oneidentity/README.md | 7 +- .../syncVisitToOneIdentityHandler.spec.ts | 219 ++++++++++++++++++ .../syncVisitToOneIdentityHandler.ts | 24 +- 3 files changed, 239 insertions(+), 11 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index b77e3f6e..7ae9f334 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -105,7 +105,12 @@ The handler manages site and system access in One Identity based on visit creati - Missing active site or system access is created independently, without recreating the other access - Both access periods must match before all access writes are skipped, allowing retries to repair system access after a successful site write followed by a failed system write -### Daily Allowance Dates +### Daily Allowance Synchronization +- Allowance eligibility requires both `request_daily_allowance` and `daily_allowance_is_approved` to be boolean `true` +- Visit creation upserts an eligible allowance; otherwise it skips allowance synchronization +- Visit updates treat the current registration answers as the authoritative state: upsert an eligible allowance, otherwise issue an idempotent delete by visitor and visit ID, even when visit dates are unchanged +- Missing answers or missing approval/request values are treated as ineligible on updates; the previous allowance state is not required +- Visit deletion always issues an idempotent allowance delete regardless of the current answers, before cancelling visit access - Approved PEJ allowance upserts send the visit's local start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update - PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date - With `TZ=Europe/Stockholm`, a visit on 28–29 October 2026 has allowance validity `2026-10-28` through `2026-10-29`. Site access starts at `2026-10-27T23:00:00.000Z` and ends at `2026-10-29T23:00:00.000Z` (30 October at Swedish midnight) diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index 1ca13f59..0c709798 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -131,6 +131,225 @@ describe('syncVisitToOneIdentityHandler', () => { }); }); + describe('daily allowance current-state reconciliation', () => { + const requestedKey = 'request_daily_allowance'; + const approvedKey = 'daily_allowance_is_approved'; + const allowanceMessage: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt: localMidnightIso('2026-10-28'), + endAt: localMidnightIso('2026-10-29'), + }; + const answers = (requested: unknown, approved: unknown) => [ + { questionNaturalKey: requestedKey, value: requested }, + { questionNaturalKey: approvedKey, value: approved }, + ]; + const ineligibleStates: { + scenario: string; + registrationAnswers: VisitMessage['registrationAnswers']; + }[] = [ + { + scenario: 'request withdrawn', + registrationAnswers: answers(false, true), + }, + { + scenario: 'approval withdrawn', + registrationAnswers: answers(true, false), + }, + { + scenario: 'both withdrawn', + registrationAnswers: answers(false, false), + }, + { + scenario: 'approval missing', + registrationAnswers: [ + { questionNaturalKey: requestedKey, value: true }, + ], + }, + { + scenario: 'request missing', + registrationAnswers: [{ questionNaturalKey: approvedKey, value: true }], + }, + { scenario: 'answers empty', registrationAnswers: [] }, + { scenario: 'answers omitted', registrationAnswers: undefined }, + { + scenario: 'answers not boolean true', + registrationAnswers: answers('true', 'true'), + }, + ]; + + beforeEach(() => { + Object.values(mockOneIdentity).forEach((mock) => mock.mockReset()); + mockOneIdentity.getPerson.mockResolvedValue({ + UID_Person: 'visitor-uid', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person); + mockOneIdentity.getProposal.mockResolvedValue(mockUidESet); + mockOneIdentity.getProposalPersonConnections.mockResolvedValue([]); + mockOneIdentity.getPersonWantsOrg.mockResolvedValue([ + { + UID_PersonWantsOrg: 'site-access-uid', + DisplayOrg: PersonWantsOrgRole.SITE_ACCESS, + CustomProperty04: allowanceMessage.id, + OrderState: OrderState.GRANTED, + ValidFrom: localMidnightIso('2026-10-28'), + ValidUntil: localMidnightIso('2026-10-30'), + } as PersonWantsOrg, + { + UID_PersonWantsOrg: 'system-access-uid', + DisplayOrg: PersonWantsOrgRole.SYSTEM_ACCESS, + CustomProperty04: allowanceMessage.id, + OrderState: OrderState.GRANTED, + ValidUntil: localMidnightIso('2026-11-29'), + } as PersonWantsOrg, + ]); + mockOneIdentity.upsertPersonWantsOrg.mockImplementation(async (role) => [ + { + UID_PersonWantsOrg: + role === PersonWantsOrgRole.SITE_ACCESS + ? 'site-access-uid' + : 'system-access-uid', + } as PersonWantsOrg, + ]); + }); + + afterEach(() => { + Object.values(mockOneIdentity).forEach((mock) => mock.mockReset()); + }); + + it.each(ineligibleStates)( + 'should skip allowance creation when $scenario', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers }, + Event.VISIT_CREATED + ); + + expect(mockOneIdentity.syncPEJAllowance).not.toHaveBeenCalled(); + } + ); + + it.each(ineligibleStates)( + 'should delete allowance on update when $scenario even if visit dates are unchanged', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers }, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledTimes(1); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + allowanceMessage.visitorId, + allowanceMessage.id, + 'delete', + '', + '' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: allowanceMessage.id, + centralAccount: allowanceMessage.visitorId, + operation: 'delete', + dateFrom: '', + dateTo: '', + } + ); + } + ); + + it.each(ineligibleStates)( + 'should delete allowance before visit cleanup when $scenario', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + { + ...allowanceMessage, + registrationAnswers, + startAt: 'invalid-date', + endAt: 'invalid-date', + }, + Event.VISIT_DELETED + ); + + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + allowanceMessage.visitorId, + allowanceMessage.id, + 'delete', + '', + '' + ); + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenCalledTimes(2); + expect( + mockOneIdentity.syncPEJAllowance.mock.invocationCallOrder[0] + ).toBeLessThan( + mockOneIdentity.cancelPersonWantsOrg.mock.invocationCallOrder[0] + ); + } + ); + + it.each(['request', 'approval'])( + 'should remove a previously approved allowance after %s withdrawal and repeat the delete safely', + async (withdrawn) => { + await syncVisitToOneIdentityHandler( + allowanceMessage, + Event.VISIT_UPDATED + ); + const withdrawnMessage: VisitMessage = { + ...allowanceMessage, + registrationAnswers: answers( + withdrawn !== 'request', + withdrawn !== 'approval' + ), + }; + + await syncVisitToOneIdentityHandler( + withdrawnMessage, + Event.VISIT_UPDATED + ); + await syncVisitToOneIdentityHandler( + withdrawnMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.syncPEJAllowance.mock.calls).toEqual([ + [ + allowanceMessage.visitorId, + allowanceMessage.id, + 'upsert', + '2026-10-28', + '2026-10-29', + ], + [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + ]); + expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); + } + ); + + it('should propagate allowance delete failures and retry deletion on the next update', async () => { + const error = new Error('PEJ delete failed'); + const withdrawnMessage: VisitMessage = { + ...allowanceMessage, + registrationAnswers: answers(true, false), + }; + mockOneIdentity.syncPEJAllowance.mockRejectedValueOnce(error); + + await expect( + syncVisitToOneIdentityHandler(withdrawnMessage, Event.VISIT_UPDATED) + ).rejects.toBe(error); + await syncVisitToOneIdentityHandler( + withdrawnMessage, + Event.VISIT_UPDATED + ); + + expect(mockOneIdentity.syncPEJAllowance.mock.calls).toEqual([ + [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + ]); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(2); + }); + }); + describe('VISIT_CREATED', () => { it('should create site access and system access in One Identity for science users and connect to proposal', async () => { // Mock the current time to a fixed value for testing diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index f19faf12..86eb4a06 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -138,16 +138,20 @@ async function syncDailyAllowance( registrationAnswers: VisitRegistrationAnswer[], type: Event ): Promise { - if (!hasApprovedDailyAllowance(registrationAnswers)) return; - - const operation = - type === Event.VISIT_DELETED - ? 'delete' - : type === Event.VISIT_CREATED || type === Event.VISIT_UPDATED - ? 'upsert' - : undefined; - - if (!operation) return; + const isApproved = hasApprovedDailyAllowance(registrationAnswers); + let operation: 'upsert' | 'delete'; + + if (type === Event.VISIT_DELETED) { + // Current answers cannot tell us whether an allowance previously existed. + operation = 'delete'; + } else if (type === Event.VISIT_UPDATED) { + // Reconcile the current state; deletion is safe even if no allowance exists. + operation = isApproved ? 'upsert' : 'delete'; + } else if (type === Event.VISIT_CREATED && isApproved) { + operation = 'upsert'; + } else { + return; + } // PEJ requires the ISO 8601 dates (YYYY-MM-DD). const dateFrom = From aad8d64b4b37be5dae58a0797dad9d862e74b457 Mon Sep 17 00:00:00 2001 From: Janos Babik <143906591+janosbabik@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:41:58 +0200 Subject: [PATCH 09/11] Update src/queue/consumers/oneidentity/README.md Co-authored-by: Yoganandan Pandiyan <132274772+yoganandaness@users.noreply.github.com> --- src/queue/consumers/oneidentity/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 7ae9f334..89362a3c 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -106,7 +106,7 @@ The handler manages site and system access in One Identity based on visit creati - Both access periods must match before all access writes are skipped, allowing retries to repair system access after a successful site write followed by a failed system write ### Daily Allowance Synchronization -- Allowance eligibility requires both `request_daily_allowance` and `daily_allowance_is_approved` to be boolean `true` +- Allowance eligibility requires both the questions with natural key `request_daily_allowance` and `daily_allowance_is_approved` to be boolean `true` - Visit creation upserts an eligible allowance; otherwise it skips allowance synchronization - Visit updates treat the current registration answers as the authoritative state: upsert an eligible allowance, otherwise issue an idempotent delete by visitor and visit ID, even when visit dates are unchanged - Missing answers or missing approval/request values are treated as ineligible on updates; the previous allowance state is not required From 6f6e30838d2a1377bcfda68bf5e08feeccf4f660 Mon Sep 17 00:00:00 2001 From: janosbabik Date: Mon, 19 Oct 2026 10:41:58 +0200 Subject: [PATCH 10/11] fix(oneidentity): grant allowance based solely on approval Ignore request status so User Officers can approve unrequested allowances. --- src/queue/consumers/oneidentity/README.md | 6 +- .../syncVisitToOneIdentityHandler.spec.ts | 84 ++++++++++++++----- .../syncVisitToOneIdentityHandler.ts | 16 ++-- 3 files changed, 70 insertions(+), 36 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 89362a3c..86e28731 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -106,10 +106,12 @@ The handler manages site and system access in One Identity based on visit creati - Both access periods must match before all access writes are skipped, allowing retries to repair system access after a successful site write followed by a failed system write ### Daily Allowance Synchronization -- Allowance eligibility requires both the questions with natural key `request_daily_allowance` and `daily_allowance_is_approved` to be boolean `true` +- Allowance eligibility requires only the answer with natural key `daily_allowance_is_approved` to be boolean `true` +- `request_daily_allowance` is ignored: a User Officer can approve allowance even when the visitor did not request it - Visit creation upserts an eligible allowance; otherwise it skips allowance synchronization - Visit updates treat the current registration answers as the authoritative state: upsert an eligible allowance, otherwise issue an idempotent delete by visitor and visit ID, even when visit dates are unchanged -- Missing answers or missing approval/request values are treated as ineligible on updates; the previous allowance state is not required +- Missing answers or missing/non-true approval values are treated as ineligible on updates; the previous allowance state is not required +- Withdrawing a request does not remove an approved allowance; withdrawing approval triggers deletion on the next visit update - Visit deletion always issues an idempotent allowance delete regardless of the current answers, before cancelling visit access - Approved PEJ allowance upserts send the visit's local start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update - PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index 0c709798..b1e24eeb 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -67,7 +67,6 @@ const visitMessageWithApprovedDailyAllowance: VisitMessage = { ...visitMessage, id: '1e94c2d1-743b-48b5-99ae-f8c2d5f34e2b', registrationAnswers: [ - { questionNaturalKey: 'request_daily_allowance', value: true }, { questionNaturalKey: 'daily_allowance_is_approved', value: true }, ], }; @@ -143,14 +142,23 @@ describe('syncVisitToOneIdentityHandler', () => { { questionNaturalKey: requestedKey, value: requested }, { questionNaturalKey: approvedKey, value: approved }, ]; + const eligibleStates = [ + { scenario: 'request true', registrationAnswers: answers(true, true) }, + { scenario: 'request false', registrationAnswers: answers(false, true) }, + { + scenario: 'request missing', + registrationAnswers: [{ questionNaturalKey: approvedKey, value: true }], + }, + { scenario: 'request null', registrationAnswers: answers(null, true) }, + { + scenario: 'request non-boolean', + registrationAnswers: answers('false', true), + }, + ]; const ineligibleStates: { scenario: string; registrationAnswers: VisitMessage['registrationAnswers']; }[] = [ - { - scenario: 'request withdrawn', - registrationAnswers: answers(false, true), - }, { scenario: 'approval withdrawn', registrationAnswers: answers(true, false), @@ -165,15 +173,13 @@ describe('syncVisitToOneIdentityHandler', () => { { questionNaturalKey: requestedKey, value: true }, ], }, - { - scenario: 'request missing', - registrationAnswers: [{ questionNaturalKey: approvedKey, value: true }], - }, + { scenario: 'approval null', registrationAnswers: answers(true, null) }, + { scenario: 'approval numeric', registrationAnswers: answers(true, 1) }, { scenario: 'answers empty', registrationAnswers: [] }, { scenario: 'answers omitted', registrationAnswers: undefined }, { - scenario: 'answers not boolean true', - registrationAnswers: answers('true', 'true'), + scenario: 'approval not boolean true', + registrationAnswers: answers(true, 'true'), }, ]; @@ -216,6 +222,27 @@ describe('syncVisitToOneIdentityHandler', () => { Object.values(mockOneIdentity).forEach((mock) => mock.mockReset()); }); + describe.each([Event.VISIT_CREATED, Event.VISIT_UPDATED])('%s', (type) => { + it.each(eligibleStates)( + 'should upsert an approved allowance with $scenario', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers }, + type + ); + + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledTimes(1); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + allowanceMessage.visitorId, + allowanceMessage.id, + 'upsert', + '2026-10-28', + '2026-10-29' + ); + } + ); + }); + it.each(ineligibleStates)( 'should skip allowance creation when $scenario', async ({ registrationAnswers }) => { @@ -258,7 +285,7 @@ describe('syncVisitToOneIdentityHandler', () => { } ); - it.each(ineligibleStates)( + it.each([...ineligibleStates, ...eligibleStates])( 'should delete allowance before visit cleanup when $scenario', async ({ registrationAnswers }) => { await syncVisitToOneIdentityHandler( @@ -288,7 +315,7 @@ describe('syncVisitToOneIdentityHandler', () => { ); it.each(['request', 'approval'])( - 'should remove a previously approved allowance after %s withdrawal and repeat the delete safely', + 'should reconcile a previously approved allowance after %s withdrawal on repeated updates', async (withdrawn) => { await syncVisitToOneIdentityHandler( allowanceMessage, @@ -311,16 +338,27 @@ describe('syncVisitToOneIdentityHandler', () => { Event.VISIT_UPDATED ); + const approvedCall = [ + allowanceMessage.visitorId, + allowanceMessage.id, + 'upsert', + '2026-10-28', + '2026-10-29', + ]; + const expectedCall = + withdrawn === 'request' + ? approvedCall + : [ + allowanceMessage.visitorId, + allowanceMessage.id, + 'delete', + '', + '', + ]; expect(mockOneIdentity.syncPEJAllowance.mock.calls).toEqual([ - [ - allowanceMessage.visitorId, - allowanceMessage.id, - 'upsert', - '2026-10-28', - '2026-10-29', - ], - [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], - [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + approvedCall, + expectedCall, + expectedCall, ]); expect(mockOneIdentity.upsertPersonWantsOrg).not.toHaveBeenCalled(); } @@ -679,7 +717,7 @@ describe('syncVisitToOneIdentityHandler', () => { } ); - it('should not upsert a PEJ allowance unless it is both requested and approved', async () => { + it('should not upsert a PEJ allowance unless it is approved', async () => { const mockPerson = { UID_Person: 'visitor-uid', CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 86eb4a06..5849093a 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -16,7 +16,6 @@ import { VisitRegistrationAnswer, } from '../utils/interfaces/VisitMessage'; -const REQUEST_DAILY_ALLOWANCE_QUESTION_NATURAL_KEY = 'request_daily_allowance'; const DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY = 'daily_allowance_is_approved'; @@ -183,16 +182,11 @@ async function syncDailyAllowance( function hasApprovedDailyAllowance( registrationAnswers: VisitRegistrationAnswer[] ): boolean { - const hasAffirmativeAnswer = (questionNaturalKey: string) => - registrationAnswers.some( - (answer) => - answer.questionNaturalKey === questionNaturalKey && - answer.value === true - ); - - return ( - hasAffirmativeAnswer(REQUEST_DAILY_ALLOWANCE_QUESTION_NATURAL_KEY) && - hasAffirmativeAnswer(DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY) + return registrationAnswers.some( + (answer) => + answer.questionNaturalKey === + DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY && + answer.value === true ); } From 76d288b70793527eba200250b737141712dda52b Mon Sep 17 00:00:00 2001 From: janosbabik Date: Mon, 19 Oct 2026 10:53:09 +0200 Subject: [PATCH 11/11] fix(oneidentity): skip allowance sync when approval key is absent Preserve backward compatibility for legacy visit messages. --- src/queue/consumers/oneidentity/README.md | 7 +- .../syncVisitToOneIdentityHandler.spec.ts | 94 +++++++++++++++++-- .../syncVisitToOneIdentityHandler.ts | 21 ++--- 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/src/queue/consumers/oneidentity/README.md b/src/queue/consumers/oneidentity/README.md index 86e28731..df60070c 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -109,10 +109,11 @@ The handler manages site and system access in One Identity based on visit creati - Allowance eligibility requires only the answer with natural key `daily_allowance_is_approved` to be boolean `true` - `request_daily_allowance` is ignored: a User Officer can approve allowance even when the visitor did not request it - Visit creation upserts an eligible allowance; otherwise it skips allowance synchronization -- Visit updates treat the current registration answers as the authoritative state: upsert an eligible allowance, otherwise issue an idempotent delete by visitor and visit ID, even when visit dates are unchanged -- Missing answers or missing/non-true approval values are treated as ineligible on updates; the previous allowance state is not required +- If the `daily_allowance_is_approved` key is absent, skip allowance synchronization for all visit events for backward compatibility; existing allowances remain untouched and normal visit access processing continues +- When the approval key is present, visit updates upsert an allowance for boolean `true`; any other value issues an idempotent delete by visitor and visit ID, even when visit dates are unchanged +- A present approval answer with a missing/non-true value is distinct from an absent key; the previous allowance state is not required - Withdrawing a request does not remove an approved allowance; withdrawing approval triggers deletion on the next visit update -- Visit deletion always issues an idempotent allowance delete regardless of the current answers, before cancelling visit access +- When the approval key is present, visit deletion issues an idempotent allowance delete regardless of its value, before cancelling visit access - Approved PEJ allowance upserts send the visit's local start and end calendar dates as `YYYY-MM-DD`, on both visit creation and update - PEJ's `GuestValidityFrom` and `GuestValidityTo` are inclusive and cover the full day in the organization's configured timezone; do not add a day to the allowance end date - With `TZ=Europe/Stockholm`, a visit on 28–29 October 2026 has allowance validity `2026-10-28` through `2026-10-29`. Site access starts at `2026-10-27T23:00:00.000Z` and ends at `2026-10-29T23:00:00.000Z` (30 October at Swedish midnight) diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts index b1e24eeb..f8704a18 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.spec.ts @@ -155,6 +155,25 @@ describe('syncVisitToOneIdentityHandler', () => { registrationAnswers: answers('false', true), }, ]; + const legacyStates: { + scenario: string; + registrationAnswers: VisitMessage['registrationAnswers']; + }[] = [ + { + scenario: 'approval key missing', + registrationAnswers: [ + { questionNaturalKey: requestedKey, value: true }, + ], + }, + { scenario: 'answers empty', registrationAnswers: [] }, + { scenario: 'answers omitted', registrationAnswers: undefined }, + { + scenario: 'only unrelated answers present', + registrationAnswers: [ + { questionNaturalKey: 'another_question', value: true }, + ], + }, + ]; const ineligibleStates: { scenario: string; registrationAnswers: VisitMessage['registrationAnswers']; @@ -167,16 +186,12 @@ describe('syncVisitToOneIdentityHandler', () => { scenario: 'both withdrawn', registrationAnswers: answers(false, false), }, - { - scenario: 'approval missing', - registrationAnswers: [ - { questionNaturalKey: requestedKey, value: true }, - ], - }, { scenario: 'approval null', registrationAnswers: answers(true, null) }, { scenario: 'approval numeric', registrationAnswers: answers(true, 1) }, - { scenario: 'answers empty', registrationAnswers: [] }, - { scenario: 'answers omitted', registrationAnswers: undefined }, + { + scenario: 'approval undefined', + registrationAnswers: answers(true, undefined), + }, { scenario: 'approval not boolean true', registrationAnswers: answers(true, 'true'), @@ -243,6 +258,69 @@ describe('syncVisitToOneIdentityHandler', () => { ); }); + describe.each([ + Event.VISIT_CREATED, + Event.VISIT_UPDATED, + Event.VISIT_DELETED, + ])('legacy message on %s', (type) => { + it.each(legacyStates)( + 'should skip allowance sync when $scenario without skipping visit processing', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers }, + type + ); + + expect(mockOneIdentity.syncPEJAllowance).not.toHaveBeenCalled(); + expect(mockOneIdentity.cancelPersonWantsOrg).toHaveBeenCalledTimes( + type === Event.VISIT_DELETED ? 2 : 0 + ); + expect( + mockOneIdentity.getProposalPersonConnections + ).toHaveBeenCalledTimes(type === Event.VISIT_DELETED ? 0 : 1); + expect(mockOneIdentity.logout).toHaveBeenCalledTimes(1); + } + ); + }); + + it.each(legacyStates)( + 'should preserve an existing allowance when $scenario and still delete on explicit withdrawal', + async ({ registrationAnswers }) => { + await syncVisitToOneIdentityHandler( + allowanceMessage, + Event.VISIT_UPDATED + ); + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers }, + Event.VISIT_UPDATED + ); + expect(mockOneIdentity.syncPEJAllowance.mock.calls).toEqual([ + [ + allowanceMessage.visitorId, + allowanceMessage.id, + 'upsert', + '2026-10-28', + '2026-10-29', + ], + ]); + + await syncVisitToOneIdentityHandler( + { ...allowanceMessage, registrationAnswers: answers(true, false) }, + Event.VISIT_UPDATED + ); + expect(mockOneIdentity.syncPEJAllowance.mock.calls).toEqual([ + [ + allowanceMessage.visitorId, + allowanceMessage.id, + 'upsert', + '2026-10-28', + '2026-10-29', + ], + [allowanceMessage.visitorId, allowanceMessage.id, 'delete', '', ''], + ]); + } + ); + it.each(ineligibleStates)( 'should skip allowance creation when $scenario', async ({ registrationAnswers }) => { diff --git a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts index 5849093a..65edcaf8 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -137,7 +137,15 @@ async function syncDailyAllowance( registrationAnswers: VisitRegistrationAnswer[], type: Event ): Promise { - const isApproved = hasApprovedDailyAllowance(registrationAnswers); + const approvalAnswer = registrationAnswers.find( + (answer) => + answer.questionNaturalKey === + DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY + ); + // Legacy messages do not carry approval information; leave allowances untouched. + if (!approvalAnswer) return; + + const isApproved = approvalAnswer.value === true; let operation: 'upsert' | 'delete'; if (type === Event.VISIT_DELETED) { @@ -179,17 +187,6 @@ async function syncDailyAllowance( }); } -function hasApprovedDailyAllowance( - registrationAnswers: VisitRegistrationAnswer[] -): boolean { - return registrationAnswers.some( - (answer) => - answer.questionNaturalKey === - DAILY_ALLOWANCE_IS_APPROVED_QUESTION_NATURAL_KEY && - answer.value === true - ); -} - // Find person UID from oidcSub // If the person is not a science user, return undefined async function getScienceUser(