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/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..df60070c 100644 --- a/src/queue/consumers/oneidentity/README.md +++ b/src/queue/consumers/oneidentity/README.md @@ -3,21 +3,21 @@ ## 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 -- 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 @@ -86,21 +86,49 @@ 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 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 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 Synchronization +- 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 +- 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 +- 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) +- Allowance deletion continues to send empty date parameters ### 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 - 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) --- @@ -226,4 +254,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/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 d148af7b..f8704a18 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: { @@ -39,21 +39,36 @@ const mockOneIdentity: jest.Mocked> = { removeConnectionBetweenPersonAndProposal: jest.fn(), upsertPersonWantsOrg: jest.fn(), cancelPersonWantsOrg: jest.fn(), + syncPEJAllowance: jest.fn(), 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' }], 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: 'daily_allowance_is_approved', value: true }, + ], }; const visitMessageVisitorNotMember: VisitMessage = { @@ -86,6 +101,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 @@ -111,12 +130,347 @@ 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 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 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']; + }[] = [ + { + scenario: 'approval withdrawn', + registrationAnswers: answers(true, false), + }, + { + scenario: 'both withdrawn', + registrationAnswers: answers(false, false), + }, + { scenario: 'approval null', registrationAnswers: answers(true, null) }, + { scenario: 'approval numeric', registrationAnswers: answers(true, 1) }, + { + scenario: 'approval undefined', + registrationAnswers: answers(true, undefined), + }, + { + scenario: 'approval 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()); + }); + + 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' + ); + } + ); + }); + + 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 }) => { + 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, ...eligibleStates])( + '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 reconcile a previously approved allowance after %s withdrawal on repeated updates', + 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 + ); + + 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([ + approvedCall, + expectedCall, + expectedCall, + ]); + 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 - 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', @@ -168,21 +522,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( @@ -190,7 +537,7 @@ describe('syncVisitToOneIdentityHandler', () => { PersonWantsOrgRole.SYSTEM_ACCESS, visitMessageWithTimes.visitorId, expectedValidFrom, - expectedEndDate.toISOString(), + expectedValidUntil, visitMessageWithTimes.id ); @@ -220,9 +567,269 @@ describe('syncVisitToOneIdentityHandler', () => { ); expect(mockOneIdentity.logout).toHaveBeenCalled(); + }); + + it.each([ + [ + 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', + 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', + 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', + localMidnightIso('2023-12-31'), + localMidnightIso('2024-01-01'), + '2023-12-31', + '2023-12-31', + ], + [ + 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 ( + startAt, + endAt, + expectedFrom, + expectedTo, + allowanceFrom, + allowanceTo + ) => { + 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]); + + await syncVisitToOneIdentityHandler( + allowanceVisitMessage, + Event.VISIT_CREATED + ); + + expect(mockOneIdentity.upsertPersonWantsOrg).toHaveBeenNthCalledWith( + 1, + PersonWantsOrgRole.SITE_ACCESS, + allowanceVisitMessage.visitorId, + expectedFrom, + expectedTo, + allowanceVisitMessage.id + ); + expect(mockOneIdentity.syncPEJAllowance).toHaveBeenCalledWith( + allowanceVisitMessage.visitorId, + allowanceVisitMessage.id, + 'upsert', + allowanceFrom, + allowanceTo + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: allowanceVisitMessage.id, + centralAccount: allowanceVisitMessage.visitorId, + operation: 'upsert', + dateFrom: allowanceFrom, + dateTo: allowanceTo, + } + ); + } + ); + + 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 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 + ); - // Restore original Date.now - Date.now = originalDateNow; + expect(mockOneIdentity.syncPEJAllowance).not.toHaveBeenCalled(); }); it('should skip creating proposal connection if it already exists', async () => { @@ -250,7 +857,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, @@ -327,7 +934,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( @@ -338,7 +945,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 = { @@ -439,6 +1146,56 @@ 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', + '', + '' + ); + 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 () => { const mockPerson = { UID_Person: 'visitor-uid', @@ -605,96 +1362,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); + }); }); }); @@ -735,14 +1568,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( @@ -763,8 +1596,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' ); @@ -775,24 +1608,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' ); @@ -810,6 +1634,96 @@ describe('syncVisitToOneIdentityHandler', () => { expect(mockOneIdentity.logout).toHaveBeenCalled(); }); + it.each(['missing', 'changed', 'unchanged'])( + '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', + CCC_EmployeeSubType: IdentityType.ESSSCIENCEUSER, + } as Person; + const updatedVisitMessage: VisitMessage = { + ...visitMessageWithApprovedDailyAllowance, + startAt: localMidnightIso('2026-10-28'), + endAt: localMidnightIso('2026-10-29'), + }; + const expectedFrom = localMidnightIso('2026-10-28'); + const expectedTo = localMidnightIso('2026-10-30'); + 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, + ValidUntil: + scenario === 'unchanged' + ? localMidnightIso('2026-11-29') + : visitMessage.endAt, + } as PersonWantsOrg; + + 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 + ); + + 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', + '2026-10-28', + '2026-10-29' + ); + expect(logger.logInfo).toHaveBeenCalledWith( + 'PEJ allowance synchronized in One Identity', + { + visitId: updatedVisitMessage.id, + centralAccount: updatedVisitMessage.visitorId, + operation: 'upsert', + dateFrom: '2026-10-28', + dateTo: '2026-10-29', + } + ); + } + ); + it('should skip update when visit dates have not changed', async () => { const mockPerson = { UID_Person: 'visitor-uid', @@ -820,8 +1734,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; @@ -830,8 +1744,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; @@ -870,7 +1784,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, @@ -902,15 +1818,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, @@ -919,7 +1841,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, @@ -949,10 +2135,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 3806722f..65edcaf8 100644 --- a/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts +++ b/src/queue/consumers/oneidentity/consumerCallbacks/syncVisitToOneIdentityHandler.ts @@ -11,14 +11,27 @@ import { OrderState, PersonWantsOrgRole, } from '../utils/interfaces/PersonWantsOrg'; -import { VisitMessage } from '../utils/interfaces/VisitMessage'; +import { + VisitMessage, + VisitRegistrationAnswer, +} from '../utils/interfaces/VisitMessage'; + +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,8 +65,18 @@ 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. + // Reconcile each access independently so retries can finish partial updates. await updateAccessInOneIdentity( oneIdentity, visitId, @@ -65,7 +88,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 +128,65 @@ export async function syncVisitToOneIdentityHandler( } } +async function syncDailyAllowance( + oneIdentity: ESSOneIdentity, + visitId: string, + centralAccount: string, + startAt: string, + endAt: string, + registrationAnswers: VisitRegistrationAnswer[], + type: Event +): Promise { + 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) { + // 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 = + operation === 'delete' + ? '' + : toLocalDate(startAt).toLocaleDateString('sv-SE'); + const dateTo = + operation === 'delete' + ? '' + : toLocalDate(endAt).toLocaleDateString('sv-SE'); + + await oneIdentity.syncPEJAllowance( + centralAccount, + visitId, + operation, + dateFrom, + dateTo + ); + + logger.logInfo('PEJ allowance synchronized in One Identity', { + visitId, + centralAccount, + operation, + dateFrom, + dateTo, + }); +} + // Find person UID from oidcSub // If the person is not a science user, return undefined async function getScienceUser( @@ -123,18 +227,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 ); @@ -151,56 +252,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', { @@ -212,38 +294,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( @@ -251,48 +341,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( @@ -309,10 +388,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', { @@ -351,27 +433,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(); + 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(); } diff --git a/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts b/src/queue/consumers/oneidentity/utils/ESSOneIdentity.spec.ts index 05fa8b71..667e7c43 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 inclusive date-only visit dates', async () => { + mockOneIdentityApi.callScript.mockResolvedValueOnce('allowance-uid'); + + const result = await essOneIdentity.syncPEJAllowance( + centralAccount, + externalAllowanceId, + 'upsert', + '2026-08-24', + '2026-08-24' + ); + + expect(mockOneIdentityApi.callScript).toHaveBeenCalledWith( + 'SCPEJAllowance', + [ + centralAccount, + externalAllowanceId, + 'upsert', + '2026-08-24', + '2026-08-24', + ] + ); + 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/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, + }, + }; +} 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 + ))) ); }