diff --git a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts index a535850181..f4523174dd 100644 --- a/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts +++ b/src/subdomains/generic/kyc/dto/mapper/__tests__/kyc-info.mapper.spec.ts @@ -432,4 +432,82 @@ describe('KycInfoMapper', () => { expect(result.processStatus).toBe(KycProcessStatus.IN_PROGRESS); }); }); + + // `processStatus` already reports only over the context's steps; `currentStep` did not, so a step that is + // irrelevant to the flow could hijack it and send the caller somewhere its flow never asked for. + describe('currentStep context scoping', () => { + const session = (userData: UserData, context?: KycContext): KycSessionDto => + KycInfoMapper.toDto(userData, true, [], undefined, context) as KycSessionDto; + + // OwnerDirectory sorts BEFORE ResidencePermit in the step order, so the unscoped fallback would reach it + // first. Picking a later-sorting out-of-context step would pass either way and prove nothing. + it('does not surface an out-of-context step as currentStep', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.RESIDENCE_PERMIT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.OWNER_DIRECTORY, ReviewStatus.IN_PROGRESS), + buildStep(KycStepName.RESIDENCE_PERMIT, ReviewStatus.IN_PROGRESS), + ], + }); + + expect(session(userData, KycContext.REALUNIT_BUY).currentStep?.name).toBe(KycStepName.RESIDENCE_PERMIT); + }); + + it('leaves currentStep undefined when only out-of-context steps are open', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.NATIONALITY_DATA); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.NATIONALITY_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.ADDRESS_CHANGE, ReviewStatus.IN_PROGRESS), + ], + }); + + const result = session(userData, KycContext.REALUNIT_BUY); + // the context is satisfied, so the caller is done — it must not be pointed at the change step + expect(result.currentStep).toBeUndefined(); + expect(result.processStatus).toBe(KycProcessStatus.COMPLETED); + }); + + it('applies the same scoping to the FAILED fallback', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.RESIDENCE_PERMIT); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.OWNER_DIRECTORY, ReviewStatus.FAILED), + buildStep(KycStepName.RESIDENCE_PERMIT, ReviewStatus.FAILED), + ], + }); + + expect(session(userData, KycContext.REALUNIT_BUY).currentStep?.name).toBe(KycStepName.RESIDENCE_PERMIT); + }); + + // The regression guard for every other client. AddressChange/PhoneChange/NameChange/PaymentAgreement are + // NOT members of requiredKycSteps, so scoping by requiredStepNames instead of the context set would drop + // them from currentStep for callers that pass no context and break the change flows. + it('still surfaces a change step when no context is supplied', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.NATIONALITY_DATA); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.ADDRESS_CHANGE, ReviewStatus.IN_PROGRESS), + ], + }); + + expect(session(userData).currentStep?.name).toBe(KycStepName.ADDRESS_CHANGE); + }); + + it('does not narrow anything for a context without a step whitelist (RealunitSell)', () => { + setRequiredSteps(KycStepName.CONTACT_DATA, KycStepName.NATIONALITY_DATA); + const userData = buildUserData({ + kycSteps: [ + buildStep(KycStepName.CONTACT_DATA, ReviewStatus.COMPLETED), + buildStep(KycStepName.ADDRESS_CHANGE, ReviewStatus.IN_PROGRESS), + ], + }); + + expect(session(userData, KycContext.REALUNIT_SELL).currentStep?.name).toBe(KycStepName.ADDRESS_CHANGE); + }); + }); }); diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts index 34e6337517..59f8f3a453 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts @@ -31,14 +31,26 @@ export class KycInfoMapper { // surfacing it as current produced a dead-end (blank) screen on the client. if (currentStep && KycStepNonUserActionable.includes(currentStep.name)) currentStep = undefined; + const contextSteps = context ? contextRequiredSteps(context) : undefined; + + // The fallback answers "what should this caller do next", so it has to obey the same scope as + // `processStatus`, which already reports only over the context's steps. Without this a step that is + // irrelevant to the flow — an open AddressChange, PhoneChange or PaymentAgreement — hijacks `currentStep` + // and sends the caller off to something its flow never asked for. + // + // Scoped by the CONTEXT set, deliberately not by `requiredStepNames`: those change steps are not members + // of `requiredKycSteps` at all, so filtering by it would drop them from `currentStep` for every caller and + // break the change flows. With no context nothing is narrowed and the answer is byte-identical to before. + const isCurrentStepCandidate = (s: KycStep): boolean => + !KycStepNonUserActionable.includes(s.name) && (!contextSteps || contextSteps.has(s.name)); + currentStep ??= - kycSteps.find((s) => s.status === ReviewStatus.IN_PROGRESS && !KycStepNonUserActionable.includes(s.name)) ?? - kycSteps.find((s) => s.status === ReviewStatus.FAILED && !KycStepNonUserActionable.includes(s.name)); + kycSteps.find((s) => s.status === ReviewStatus.IN_PROGRESS && isCurrentStepCandidate(s)) ?? + kycSteps.find((s) => s.status === ReviewStatus.FAILED && isCurrentStepCandidate(s)); const userKycClients = kycClients.filter((kc) => userData.kycClientList.includes(kc.id)); const allRequiredSteps = requiredKycSteps(userData); - const contextSteps = context ? contextRequiredSteps(context) : undefined; const requiredStepNames = new Set( contextSteps ? allRequiredSteps.filter((s) => contextSteps.has(s)) : allRequiredSteps, );