diff --git a/scripts/generate-api-models.sh b/scripts/generate-api-models.sh index acd513fdcbe..091ce649e50 100755 --- a/scripts/generate-api-models.sh +++ b/scripts/generate-api-models.sh @@ -2,7 +2,7 @@ IO_BACKEND_VERSION=v16.7.4-RELEASE # need to change after merge on io-services-metadata -IO_SERVICES_METADATA_VERSION=1.0.59 +IO_SERVICES_METADATA_VERSION=1.0.60 # Session manager version IO_SESSION_MANAGER_VERSION=1.4.0 diff --git a/ts/components/ui/Markdown/handlers/__test__/link.test.ts b/ts/components/ui/Markdown/handlers/__test__/link.test.ts index b91f9921ca3..2d52a26072f 100644 --- a/ts/components/ui/Markdown/handlers/__test__/link.test.ts +++ b/ts/components/ui/Markdown/handlers/__test__/link.test.ts @@ -2,9 +2,7 @@ import * as E from "fp-ts/lib/Either"; import { deriveCustomHandledLink, isHttpsLink, - isIoFIMSLink, - isIoInternalLink, - removeFIMSPrefixFromUrl + isIoInternalLink } from "../link"; const loadingCases: ReadonlyArray< @@ -73,19 +71,6 @@ const loadingCases: ReadonlyArray< ] ]; -const fimsCases: ReadonlyArray< - [input: string, expectedResult: ReturnType] -> = [ - [ - "iosso://https://italia.io/main/messages?messageId=4&serviceId=5", - "https://italia.io/main/messages?messageId=4&serviceId=5" - ], - [ - "iOsSo://https://italia.io/main/messages?messageId=4&serviceId=5", - "https://italia.io/main/messages?messageId=4&serviceId=5" - ] -]; - describe("deriveCustomHandledLink", () => { test.each(loadingCases)( "given %p as argument, returns %p", @@ -96,16 +81,6 @@ describe("deriveCustomHandledLink", () => { ); }); -describe("removeFIMSPrefixFromUrl", () => { - test.each(fimsCases)( - "given %p as argument, returns %p", - (firstArg, expectedResult) => { - const result = removeFIMSPrefixFromUrl(firstArg); - expect(result).toEqual(expectedResult); - } - ); -}); - describe("isHttpsLink", () => { ["https://", "hTtPs://", "HTTPS://"].forEach(protocol => { it(`should return true for '${protocol}'`, () => { @@ -139,39 +114,6 @@ describe("isHttpsLink", () => { }); }); -describe("isIoFIMSLink", () => { - ["iosso://", "iOsSo://", "IOSSO://"].forEach(protocol => { - it(`should return true for '${protocol}'`, () => { - const isIOFIMSLink = isIoFIMSLink(`${protocol}whatever`); - expect(isIOFIMSLink).toBe(true); - }); - }); - [ - "iosso:/", - "iosso:", - "iosso", - "https://", - "http://", - "ioit://", - "iohandledlink://", - "clipboard://", - "clipboard:", - "sms://", - "sms:", - "tel://", - "tel:", - "mailto://", - "mailto:", - "copy://", - "copy:" - ].forEach(protocol => { - it(`should return false for '${protocol}'`, () => { - const isIOFIMSLink = isIoFIMSLink(`${protocol}whatever`); - expect(isIOFIMSLink).toBe(false); - }); - }); -}); - describe("isIoInternalLink", () => { ["ioit://", "iOiT://", "IOIT://"].forEach(protocol => { it(`should return true for '${protocol}'`, () => { diff --git a/ts/components/ui/Markdown/handlers/link.ts b/ts/components/ui/Markdown/handlers/link.ts index dc510ca84aa..2a4d1ce0400 100644 --- a/ts/components/ui/Markdown/handlers/link.ts +++ b/ts/components/ui/Markdown/handlers/link.ts @@ -2,28 +2,15 @@ import { IOToast } from "@pagopa/io-app-design-system"; import * as E from "fp-ts/lib/Either"; import * as t from "io-ts"; import I18n from "../../../../i18n"; -import { - IO_FIMS_LINK_PREFIX, - IO_FIMS_LINK_PROTOCOL, - IO_INTERNAL_LINK_PREFIX -} from "../../../../utils/navigation"; +import { IO_INTERNAL_LINK_PREFIX } from "../../../../utils/navigation"; import { openWebUrl } from "../../../../utils/url"; export const isIoInternalLink = (href: string): boolean => href.toLowerCase().startsWith(IO_INTERNAL_LINK_PREFIX); -export const isIoFIMSLink = (href: string): boolean => - href.toLowerCase().startsWith(IO_FIMS_LINK_PREFIX); - export const isHttpsLink = (href: string): boolean => href.toLowerCase().startsWith("https://"); -export const removeFIMSPrefixFromUrl = (fimsUrlWithProtocol: string) => { - // eslint-disable-next-line no-useless-escape - const regexp = new RegExp(`^${IO_FIMS_LINK_PROTOCOL}\/\/`, "i"); - return fimsUrlWithProtocol.replace(regexp, ""); -}; - /** * a dedicated codec for CustomHandledLink * ex: iohandledlink://tel:1234567 -> {url: tel:1234567, type: tel, value:1234567} diff --git a/ts/features/fims/common/analytics/__tests__/index.test.ts b/ts/features/fims/common/analytics/__tests__/index.test.ts index 46af155f9ac..10769274e60 100644 --- a/ts/features/fims/common/analytics/__tests__/index.test.ts +++ b/ts/features/fims/common/analytics/__tests__/index.test.ts @@ -16,6 +16,7 @@ import { ServiceId } from "../../../../../../definitions/backend/ServiceId"; import { ServicePublic } from "../../../../../../definitions/backend/ServicePublic"; import * as mixpanel from "../../../../../mixpanel"; import { GlobalState } from "../../../../../store/reducers/types"; +import { MESSAGES_ROUTES } from "../../../../messages/navigation/routes"; import * as serviceSelectors from "../../../../services/details/store/reducers"; import * as fimsAuthenticationSelectors from "../../../singleSignOn/store/selectors"; @@ -26,9 +27,6 @@ const organizationNames = [undefined, "organization name"]; const referenceReason = "The reason"; const referenceServiceId = "01J9RSWBB4VSHVRJSY33XGA6YH" as ServiceId; const serviceNames = [undefined, "service name"]; -const sources: ReadonlyArray< - "message_detail" | "service_detail" | "credential_detail" -> = ["message_detail", "service_detail", "credential_detail"] as const; describe("trackAuthenticationStart", () => { beforeEach(() => { @@ -38,38 +36,37 @@ describe("trackAuthenticationStart", () => { organizationFiscalCodes.forEach(organizationFiscalCode => organizationNames.forEach(organizationName => serviceNames.forEach(serviceName => - sources.forEach(source => - it(`should match event name, and expected parameters for ${ - organizationFiscalCode ? "defined " : "undefined " - } organization fiscal code, ${ - organizationName ? "defined " : "undefined " - } organization name, ${ - serviceName ? "defined " : "undefined " - } service name, ${source} source, `, () => { - const mixpanelTrackMock = generateMixpanelTrackMock(); - void trackAuthenticationStart( - referenceServiceId, - serviceName, - organizationName, - organizationFiscalCode, - referenceCtaLabel, - source - ); - expect(mixpanelTrackMock.mock.calls.length).toBe(1); - expect(mixpanelTrackMock.mock.calls[0].length).toBe(2); - expect(mixpanelTrackMock.mock.calls[0][0]).toBe("FIMS_START"); - expect(mixpanelTrackMock.mock.calls[0][1]).toEqual({ - event_category: "UX", - event_type: "action", - fims_label: referenceCtaLabel, - organization_fiscal_code: organizationFiscalCode, - organization_name: organizationName, - service_id: referenceServiceId, - service_name: serviceName, - source - }); - }) - ) + it(`should match event name, and expected parameters for ${ + organizationFiscalCode ? "defined " : "undefined " + } organization fiscal code, ${ + organizationName ? "defined " : "undefined " + } organization name, ${ + serviceName ? "defined " : "undefined " + } service name`, () => { + const source = MESSAGES_ROUTES.MESSAGE_DETAIL; + const mixpanelTrackMock = generateMixpanelTrackMock(); + void trackAuthenticationStart( + referenceServiceId, + serviceName, + organizationName, + organizationFiscalCode, + referenceCtaLabel, + source + ); + expect(mixpanelTrackMock.mock.calls.length).toBe(1); + expect(mixpanelTrackMock.mock.calls[0].length).toBe(2); + expect(mixpanelTrackMock.mock.calls[0][0]).toBe("FIMS_START"); + expect(mixpanelTrackMock.mock.calls[0][1]).toEqual({ + event_category: "UX", + event_type: "action", + fims_label: referenceCtaLabel, + organization_fiscal_code: organizationFiscalCode, + organization_name: organizationName, + service_id: referenceServiceId, + service_name: serviceName, + source + }); + }) ) ) ); diff --git a/ts/features/fims/common/analytics/index.ts b/ts/features/fims/common/analytics/index.ts index 08e7119c41c..fc1f98a901d 100644 --- a/ts/features/fims/common/analytics/index.ts +++ b/ts/features/fims/common/analytics/index.ts @@ -12,7 +12,7 @@ export const trackAuthenticationStart = ( organizationName: string | undefined, organizationFiscalCode: string | undefined, ctaLabel: string, - source: "message_detail" | "service_detail" | "credential_detail" + source: string ) => { const eventName = `FIMS_START`; const props = buildEventProperties("UX", "action", { diff --git a/ts/features/fims/common/hooks/__test__/index.test.tsx b/ts/features/fims/common/hooks/__test__/index.test.tsx new file mode 100644 index 00000000000..e9fd7b98709 --- /dev/null +++ b/ts/features/fims/common/hooks/__test__/index.test.tsx @@ -0,0 +1,699 @@ +import * as O from "fp-ts/lib/Option"; +import * as pot from "@pagopa/ts-commons/lib/pot"; +import { ComponentType } from "react"; +import { createStore } from "redux"; +import { + FIMSServiceData, + testable, + useAutoFetchingServiceByIdPot, + useFIMSAuthenticationFlow, + useFIMSFromServiceId, + useFIMSRemoteServiceConfiguration +} from ".."; +import { ServiceId } from "../../../../../../definitions/backend/ServiceId"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { MESSAGES_ROUTES } from "../../../../messages/navigation/routes"; +import { GlobalState } from "../../../../../store/reducers/types"; +import { ServicePublic } from "../../../../../../definitions/backend/ServicePublic"; +import { loadServiceDetail } from "../../../../services/details/store/actions/details"; +import { FIMS_ROUTES } from "../../navigation"; +import { FimsServiceConfiguration } from "../../../../../../definitions/content/FimsServiceConfiguration"; +import { + AppParamsList, + IOStackNavigationProp +} from "../../../../../navigation/params/AppParamsList"; + +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch +})); + +const mockNavigate = jest.fn(); +const mockNavigation = { + getState: () => ({ + index: 0, + routes: [ + { + name: "MESSAGE_DETAIL" + } + ] + }), + navigate: mockNavigate +} as unknown as IOStackNavigationProp; +jest.mock("@react-navigation/native", () => { + const actualNav = jest.requireActual("@react-navigation/native"); + return { + ...actualNav, + useNavigation: () => mockNavigation + }; +}); + +// eslint-disable-next-line functional/no-let +let serviceDataPot: pot.Pot | undefined; +// eslint-disable-next-line functional/no-let +let serviceData: FIMSServiceData | undefined; +// eslint-disable-next-line functional/no-let +let authenticationCallback: ((label: string, url: string) => void) | undefined; +// eslint-disable-next-line functional/no-let +let authenticationCallbackWithServiceId: + | ((label: string, serviceId: ServiceId, url: string) => void) + | undefined; + +describe("index", () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetAllMocks(); + serviceDataPot = undefined; + serviceData = undefined; + authenticationCallback = undefined; + authenticationCallbackWithServiceId = undefined; + }); + describe("useAutoFetchingServiceByIdPot", () => { + const service = {} as ServicePublic; + [ + pot.none, + pot.noneLoading, + pot.noneUpdating(service), + pot.noneError(Error("")), + pot.some(service), + pot.someLoading(service), + pot.someUpdating(service, service), + pot.someError(service, Error("")) + ].forEach(servicePot => { + const shouldHaveCalledDispatch = + servicePot.kind === "PotNone" || servicePot.kind === "PotNoneError"; + it(`should ${ + shouldHaveCalledDispatch ? "" : "not " + }have dispatched 'loadServiceDetail.request' and returned proper data for input pot of type '${ + servicePot.kind + }'`, () => { + const serviceId = "01JMESJKA9HS28MEW12P7WPYVC" as ServiceId; + + renderAutoFetchHook(serviceId, servicePot, serviceId); + + expect(serviceDataPot).toEqual(servicePot); + if (shouldHaveCalledDispatch) { + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0].length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toEqual( + loadServiceDetail.request(serviceId) + ); + } else { + expect(mockDispatch.mock.calls.length).toBe(0); + } + }); + }); + it(`should have dispatched 'loadServiceDetail.request' and returned proper data for unmatching serviceId`, () => { + const hookServiceId = "01JMESJKA9HS28MEW12P7WPYVC" as ServiceId; + + renderAutoFetchHook( + hookServiceId, + pot.some({} as ServicePublic), + "8MEW12P7WPYVC01JMESJKA9HS2" as ServiceId + ); + + expect(serviceDataPot).toEqual(pot.none); + + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0].length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toEqual( + loadServiceDetail.request(hookServiceId) + ); + }); + }); + describe("useFIMSFromServiceId", () => { + const serviceId = "01JMEWNY9BC3KVRCGTY1737J0S" as ServiceId; + const service = { + organization_fiscal_code: "01234567891", + organization_name: "An organization name", + service_id: serviceId, + service_name: "A service name" + } as ServicePublic; + [ + pot.none, + pot.noneLoading, + pot.noneUpdating(service), + pot.noneError(Error("")), + pot.some(service), + pot.someLoading(service), + pot.someUpdating(service, service), + pot.someError(service, Error("")) + ].forEach(servicePot => { + it(`should call 'navigation.navigate' with proper parameters for '${servicePot.kind}' service and return proper service data for analytics`, () => { + const expectedServiceData = pot.isSome(servicePot) + ? { + serviceId, + organizationFiscalCode: service.organization_fiscal_code, + organizationName: service.organization_name, + serviceName: service.service_name + } + : { + serviceId + }; + + renderFromServiceIdHook(serviceId, servicePot, serviceId); + + expect(serviceData).toEqual(expectedServiceData); + expect(authenticationCallback).toBeDefined(); + + const label = "A label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallback!(label, url); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: pot.isSome(servicePot) + ? service.organization_fiscal_code + : undefined, + organizationName: pot.isSome(servicePot) + ? service.organization_name + : undefined, + serviceId: service.service_id, + serviceName: pot.isSome(servicePot) + ? service.service_name + : undefined, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + }); + it(`should call 'navigation.navigate' with proper parameters for unmatching service id and return proper service data for analytics`, () => { + const hookServiceId = "01JMEZB6QNR7KKDEFRR6WZEH6F" as ServiceId; + const expectedServiceData = { + serviceId: hookServiceId + }; + + renderFromServiceIdHook(hookServiceId, pot.some(service), serviceId); + + expect(serviceData).toEqual(expectedServiceData); + expect(authenticationCallback).toBeDefined(); + + const label = "A label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallback!(label, url); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: undefined, + organizationName: undefined, + serviceId: hookServiceId, + serviceName: undefined, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + }); + describe("useFIMSAuthenticationFlow", () => { + const serviceId = "01JMEWNY9BC3KVRCGTY1737J0S" as ServiceId; + const service = { + organization_fiscal_code: "01234567891", + organization_name: "An organization name", + service_id: serviceId, + service_name: "A service name" + } as ServicePublic; + [ + pot.none, + pot.noneLoading, + pot.noneUpdating(service), + pot.noneError(Error("")), + pot.some(service), + pot.someLoading(service), + pot.someUpdating(service, service), + pot.someError(service, Error("")) + ].forEach(servicePot => { + it(`should call 'navigation.navigate' with proper parameters for '${servicePot.kind}' service and return proper service data for analytics`, () => { + renderFromAuthenticationFlowHook(servicePot, serviceId); + + expect(authenticationCallbackWithServiceId).toBeDefined(); + + const label = "A label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallbackWithServiceId!(label, serviceId, url); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: pot.isSome(servicePot) + ? service.organization_fiscal_code + : undefined, + organizationName: pot.isSome(servicePot) + ? service.organization_name + : undefined, + serviceId: service.service_id, + serviceName: pot.isSome(servicePot) + ? service.service_name + : undefined, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + }); + it(`should call 'navigation.navigate' with proper parameters for unmatching service id and return proper service data for analytics`, () => { + renderFromAuthenticationFlowHook(pot.some(service), serviceId); + + expect(authenticationCallbackWithServiceId).toBeDefined(); + + const label = "A label"; + const callbackServiceId = "01JMEZB6QNR7KKDEFRR6WZEH6F" as ServiceId; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallbackWithServiceId!(label, callbackServiceId, url); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: undefined, + organizationName: undefined, + serviceId: callbackServiceId, + serviceName: undefined, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + }); + describe("useFIMSRemoteServiceConfiguration", () => { + it(`should call 'navigation.navigate' with proper parameters and return proper service data for analytics`, () => { + const configurationId = "configId"; + const configuration: FimsServiceConfiguration = { + configuration_id: configurationId, + organization_fiscal_code: "01234567890", + organization_name: "Organization name", + service_id: "01JMF90E33B89232YER1WRYQ26" as ServiceId, + service_name: "Service name" + }; + + renderFromRemoteConfigurationHook(configuration, configurationId); + + expect(serviceData).toEqual({ + serviceId: configuration.service_id, + organizationFiscalCode: configuration.organization_fiscal_code, + organizationName: configuration.organization_name, + serviceName: configuration.service_name + }); + expect(authenticationCallback).toBeDefined(); + + const label = "FIMS label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallback!(label, url); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: configuration.organization_fiscal_code, + organizationName: configuration.organization_name, + serviceId: configuration.service_id, + serviceName: configuration.service_name, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + it(`should not call 'navigation.navigate' and return undefined serviceData when configuration id does not match`, () => { + const configuration: FimsServiceConfiguration = { + configuration_id: "configId", + organization_fiscal_code: "01234567890", + organization_name: "Organization name", + service_id: "01JMF90E33B89232YER1WRYQ26" as ServiceId, + service_name: "Service name" + }; + + renderFromRemoteConfigurationHook( + configuration, + "unmatchingConfiguration" + ); + + expect(serviceData).toBeUndefined(); + expect(authenticationCallback).toBeDefined(); + + const label = "FIMS label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallback!(label, url); + + expect(mockNavigate.mock.calls.length).toBe(0); + }); + }); + describe("navigateToFIMSAuthorizationFlow", () => { + it("should call 'navigation.navigate' with proper parameters", () => { + const label = "A label"; + const innerServiceData = { + organizationFiscalCode: "01234567891", + organizationName: "Organization name", + serviceId: "01JMFDP73MT43B4507XXQB0105" as ServiceId, + serviceName: "Service name" + }; + const url = "iosso://https://relyingParty.url/login"; + + testable!.navigateToFIMSAuthorizationFlow( + label, + mockNavigation, + innerServiceData, + url + ); + + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: innerServiceData.organizationFiscalCode, + organizationName: innerServiceData.organizationName, + serviceId: innerServiceData.serviceId, + serviceName: innerServiceData.serviceName, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + }); + describe("serviceDataFromConfigurationId", () => { + it(`should return service data when the configuration id matches`, () => { + const configuration: FimsServiceConfiguration = { + configuration_id: "configId", + organization_fiscal_code: "01234567890", + organization_name: "Organization name", + service_id: "01JMF90E33B89232YER1WRYQ26" as ServiceId, + service_name: "Service name" + }; + const appState = { + remoteConfig: O.some({ + fims: { + services: [configuration] + } + }) + } as GlobalState; + + const serviceConfiguration = testable!.serviceDataFromConfigurationId( + configuration.configuration_id, + appState + ); + expect(serviceConfiguration).toEqual({ + organizationFiscalCode: configuration.organization_fiscal_code, + organizationName: configuration.organization_name, + serviceId: configuration.service_id, + serviceName: configuration.service_name + }); + }); + it(`should return 'undefined' when the configuration id does not match`, () => { + const configuration: FimsServiceConfiguration = { + configuration_id: "configId", + organization_fiscal_code: "01234567890", + organization_name: "Organization name", + service_id: "01JMF90E33B89232YER1WRYQ26" as ServiceId, + service_name: "Service name" + }; + const appState = { + remoteConfig: O.some({ + fims: { + services: [configuration] + } + }) + } as GlobalState; + + const serviceConfiguration = testable!.serviceDataFromConfigurationId( + "unmatchingConfigurationId", + appState + ); + expect(serviceConfiguration).toBeUndefined(); + }); + }); + describe("serviceDataFromServiceId", () => { + const serviceId = "01JMFEZR305XG9VSAB9RYX6X6B" as ServiceId; + const service = { + service_id: serviceId, + organization_fiscal_code: "01234567890", + organization_name: "Organization name", + service_name: "Service name" + } as ServicePublic; + [ + pot.none, + pot.noneLoading, + pot.noneUpdating(service), + pot.noneError(Error("")), + pot.some(service), + pot.someLoading(service), + pot.someUpdating(service, service), + pot.someError(service, Error("")) + ].forEach(servicePot => { + it(`should return proper service data for matching service id and service data of type ${servicePot.kind}`, () => { + const state = { + features: { + services: { + details: { + byId: { + [serviceId]: servicePot + } + } + } + } + } as GlobalState; + + const internalServiceData = testable!.serviceDataFromServiceId( + serviceId, + state + ); + + expect(internalServiceData).toEqual({ + organizationFiscalCode: pot.isSome(servicePot) + ? service.organization_fiscal_code + : undefined, + organizationName: pot.isSome(servicePot) + ? service.organization_name + : undefined, + serviceId: service.service_id, + serviceName: pot.isSome(servicePot) ? service.service_name : undefined + }); + }); + }); + it(`should return proper service data for unmatching service id`, () => { + const callbackServiceId = "01JMFFSRBHTN09A6CFM0MTXFP6" as ServiceId; + const state = { + features: { + services: { + details: { + byId: { + [serviceId]: pot.some(service) + } + } + } + } + } as GlobalState; + + const internalServiceData = testable!.serviceDataFromServiceId( + callbackServiceId, + state + ); + + expect(internalServiceData).toEqual({ + serviceId: callbackServiceId + }); + }); + }); + describe("useFIMSFromServiceData", () => { + it(`should return authentication callback that calls navigation.navigate with proper parameters when input data is defined`, () => { + const internalServiceData = { + organizationFiscalCode: "01234567891", + organizationName: "Organization name", + serviceId: "01JMFG3E20JFQH6HAQD9BDRB19" as ServiceId, + serviceName: "Service name" + }; + + renderFromServiceDataHook(internalServiceData); + + expect(authenticationCallback).toBeDefined(); + + const label = "A label"; + const url = "iosso://https://relyingParty.url/login"; + authenticationCallback!(label, url); + expect(mockNavigate.mock.calls.length).toBe(1); + expect(mockNavigate.mock.calls[0].length).toBe(2); + expect(mockNavigate.mock.calls[0][0]).toBe(FIMS_ROUTES.MAIN); + expect(mockNavigate.mock.calls[0][1]).toEqual({ + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: "https://relyingParty.url/login", + ctaText: label, + organizationFiscalCode: internalServiceData.organizationFiscalCode, + organizationName: internalServiceData.organizationName, + serviceId: internalServiceData.serviceId, + serviceName: internalServiceData.serviceName, + source: MESSAGES_ROUTES.MESSAGE_DETAIL + } + }); + }); + it(`should return authentication callback that does nothing when input data is undefined`, () => { + renderFromServiceDataHook(undefined); + + expect(authenticationCallback).toBeDefined(); + + authenticationCallback!( + "a label", + "iosso://https://relyingParty.url/login" + ); + expect(mockNavigate.mock.calls.length).toBe(0); + }); + }); +}); + +const renderAutoFetchHook = ( + hookServiceId: ServiceId, + servicePot: pot.Pot, + storeServiceId: ServiceId +) => { + const appState = { + features: { + services: { + details: { + byId: { + [storeServiceId]: servicePot + } + } + } + } + } as GlobalState; + return genericRender(() => AutoFetchHookWrapper(hookServiceId), appState); +}; + +const renderFromServiceIdHook = ( + hookServiceId: ServiceId, + servicePot: pot.Pot, + storeServiceId: ServiceId +) => { + const appState = { + features: { + services: { + details: { + byId: { + [storeServiceId]: servicePot + } + } + } + } + } as GlobalState; + return genericRender(() => FromServiceIdHookWrapper(hookServiceId), appState); +}; + +const renderFromAuthenticationFlowHook = ( + servicePot: pot.Pot, + storeServiceId: ServiceId +) => { + const appState = { + features: { + services: { + details: { + byId: { + [storeServiceId]: servicePot + } + } + } + } + } as GlobalState; + return genericRender(() => AuthenticationFlowHookWrapper(), appState); +}; + +const renderFromRemoteConfigurationHook = ( + configuration: FimsServiceConfiguration, + hookConfigurationId: string +) => { + const appState = { + remoteConfig: O.some({ + cgn: { + enabled: false + }, + fims: { + services: [configuration] + }, + itw: { + min_app_version: { + android: "0.0.0.0", + ios: "0.0.0.0" + } + } + }) + } as GlobalState; + return genericRender( + () => FromRemoteConfigurationHookWrapper(hookConfigurationId), + appState + ); +}; + +const renderFromServiceDataHook = ( + internalServiceData: FIMSServiceData | undefined +) => { + const appState = {} as GlobalState; + return genericRender( + () => FromServiceDataHookWrapper(internalServiceData), + appState + ); +}; + +const AutoFetchHookWrapper = (serviceId: ServiceId) => { + serviceDataPot = useAutoFetchingServiceByIdPot(serviceId); + return undefined; +}; +const FromServiceIdHookWrapper = (serviceId: ServiceId) => { + const hookData = useFIMSFromServiceId(serviceId); + serviceData = hookData.serviceData; + authenticationCallback = hookData.startFIMSAuthenticationFlow; + return undefined; +}; +const AuthenticationFlowHookWrapper = () => { + authenticationCallbackWithServiceId = useFIMSAuthenticationFlow(); + return undefined; +}; +const FromRemoteConfigurationHookWrapper = (configurationId: string) => { + const hookData = useFIMSRemoteServiceConfiguration(configurationId); + serviceData = hookData.serviceData; + authenticationCallback = hookData.startFIMSAuthenticationFlow; + return undefined; +}; +const FromServiceDataHookWrapper = ( + internalServiceData: FIMSServiceData | undefined +) => { + authenticationCallback = + testable!.useFIMSFromServiceData(internalServiceData); + return undefined; +}; + +const genericRender = ( + hookWrapper: ComponentType, + appState: GlobalState +) => { + const initialState = appReducer(appState, applicationChangeState("active")); + const store = createStore(appReducer, initialState as any); + return renderScreenWithNavigationStoreContext( + hookWrapper, + MESSAGES_ROUTES.MESSAGE_DETAIL, + {}, + store + ); +}; diff --git a/ts/features/fims/common/hooks/index.tsx b/ts/features/fims/common/hooks/index.tsx index 288138d6e7b..7ab51b4dc13 100644 --- a/ts/features/fims/common/hooks/index.tsx +++ b/ts/features/fims/common/hooks/index.tsx @@ -1,9 +1,33 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; -import { useIODispatch, useIOSelector } from "../../../../store/hooks"; -import { serviceByIdPotSelector } from "../../../services/details/store/reducers"; +import { + useIODispatch, + useIOSelector, + useIOStore +} from "../../../../store/hooks"; +import { + serviceByIdPotSelector, + serviceByIdSelector +} from "../../../services/details/store/reducers"; import { loadServiceDetail } from "../../../services/details/store/actions/details"; import { isStrictNone } from "../../../../utils/pot"; +import { + AppParamsList, + IOStackNavigationProp, + useIONavigation +} from "../../../../navigation/params/AppParamsList"; +import { FIMS_ROUTES } from "../navigation"; +import { removeFIMSPrefixFromUrl } from "../../singleSignOn/utils"; +import { isTestEnv } from "../../../../utils/environment"; +import { fimsServiceConfiguration } from "../../../../store/reducers/backendStatus/remoteConfig"; +import { GlobalState } from "../../../../store/reducers/types"; + +export type FIMSServiceData = { + organizationFiscalCode?: string; + organizationName?: string; + serviceId: ServiceId; + serviceName?: string; +}; export const useAutoFetchingServiceByIdPot = (serviceId: ServiceId) => { const dispatch = useIODispatch(); @@ -23,3 +47,143 @@ export const useAutoFetchingServiceByIdPot = (serviceId: ServiceId) => { return serviceData; }; + +/** + * Use this hook to retrieve a function that starts the FIMS authentication flow. + * Choose this hook when the service data is already loaded into redux + * (e.g., when coming from a message details or service details). + */ +export const useFIMSFromServiceId = (serviceId: ServiceId) => { + const store = useIOStore(); + const serviceData = useMemo( + () => serviceDataFromServiceId(serviceId, store.getState()), + [serviceId, store] + ); + const startFIMSAuthenticationFlow = useFIMSFromServiceData(serviceData); + return useMemo( + () => ({ + serviceData, + startFIMSAuthenticationFlow + }), + [serviceData, startFIMSAuthenticationFlow] + ); +}; + +/** + * Use this hook to retrieve a function that starts the FIMS authentication flow. + * Choose this hook when the service id is not available upon hook invocation + * but it will be later, when the returned function is called. + */ +export const useFIMSAuthenticationFlow = () => { + const navigation = useIONavigation(); + const store = useIOStore(); + return useCallback( + (label: string, serviceId: ServiceId, url: string) => { + const serviceData = serviceDataFromServiceId(serviceId, store.getState()); + if (serviceData == null) { + return; + } + navigateToFIMSAuthorizationFlow(label, navigation, serviceData, url); + }, + [navigation, store] + ); +}; + +/** + * Use this hook to retrieve a function that starts the FIMS authentication flow. + * Choose this hook when the service data are stored into the remote CDN and you + * know the configuration id that identifies and retrieves such data. + */ +export const useFIMSRemoteServiceConfiguration = (configurationId: string) => { + const store = useIOStore(); + const serviceData = useMemo( + () => serviceDataFromConfigurationId(configurationId, store.getState()), + [configurationId, store] + ); + const startFIMSAuthenticationFlow = useFIMSFromServiceData(serviceData); + return useMemo( + () => ({ + serviceData, + startFIMSAuthenticationFlow + }), + [serviceData, startFIMSAuthenticationFlow] + ); +}; + +const serviceDataFromServiceId = ( + serviceId: ServiceId, + state: GlobalState +): FIMSServiceData => { + const service = serviceByIdSelector(state, serviceId); + return service != null + ? { + organizationFiscalCode: service.organization_fiscal_code, + organizationName: service.organization_name, + serviceId: service.service_id, + serviceName: service.service_name + } + : { serviceId }; +}; + +const serviceDataFromConfigurationId = ( + configurationId: string, + state: GlobalState +): FIMSServiceData | undefined => { + const serviceConfiguration = fimsServiceConfiguration(state, configurationId); + return serviceConfiguration != null + ? { + organizationFiscalCode: serviceConfiguration.organization_fiscal_code, + organizationName: serviceConfiguration.organization_name, + serviceId: serviceConfiguration.service_id as ServiceId, + serviceName: serviceConfiguration.service_name + } + : undefined; +}; + +const useFIMSFromServiceData = ( + serviceData: FIMSServiceData | undefined +): ((label: string, url: string) => void) => { + const navigation = useIONavigation(); + return useCallback( + (label: string, url: string) => { + if (serviceData == null) { + return; + } + navigateToFIMSAuthorizationFlow(label, navigation, serviceData, url); + }, + [navigation, serviceData] + ); +}; + +const navigateToFIMSAuthorizationFlow = ( + label: string, + navigation: IOStackNavigationProp, + serviceData: FIMSServiceData, + url: string +): void => { + const navigationState = navigation.getState(); + const source = navigationState.routes[navigationState.index].name; + + const sanitizedUrl = removeFIMSPrefixFromUrl(url); + navigation.navigate(FIMS_ROUTES.MAIN, { + screen: FIMS_ROUTES.CONSENTS, + params: { + ctaUrl: sanitizedUrl, + ctaText: label, + organizationFiscalCode: serviceData.organizationFiscalCode, + organizationName: serviceData.organizationName, + serviceId: serviceData.serviceId, + serviceName: serviceData.serviceName, + source + } + }); +}; + +export const testable = isTestEnv + ? { + navigateToFIMSAuthorizationFlow, + serviceDataFromConfigurationId, + serviceDataFromServiceId, + useFIMSFromServiceData + } + : undefined; diff --git a/ts/features/fims/singleSignOn/screens/FimsFlowHandlerScreen.tsx b/ts/features/fims/singleSignOn/screens/FimsFlowHandlerScreen.tsx index 8683cd570c2..440b96d5471 100644 --- a/ts/features/fims/singleSignOn/screens/FimsFlowHandlerScreen.tsx +++ b/ts/features/fims/singleSignOn/screens/FimsFlowHandlerScreen.tsx @@ -11,7 +11,10 @@ import I18n from "../../../../i18n"; import { IOStackNavigationRouteProps } from "../../../../navigation/params/AppParamsList"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { fimsRequiresAppUpdateSelector } from "../../../../store/reducers/backendStatus/remoteConfig"; -import { trackAuthenticationError } from "../../common/analytics"; +import { + trackAuthenticationError, + trackAuthenticationStart +} from "../../common/analytics"; import { FimsUpdateAppAlert } from "../../common/components/FimsUpdateAppAlert"; import { FimsParamsList } from "../../common/navigation"; import { FimsSSOFullScreenError } from "../components/FimsFullScreenErrors"; @@ -25,10 +28,29 @@ import { fimsAuthenticationFailedSelector, fimsLoadingStateSelector } from "../store/selectors"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; export type FimsFlowHandlerScreenRouteParams = { + /* The label on the button that started the FIMS flow */ ctaText: string; + /* The Relying Party's url that starts the FIMS flow, + * without the iosso:// protocol prefix */ ctaUrl: string; + /* A Relying Party is always associated with a service. + * This is the fiscal code of the service organization */ + organizationFiscalCode: string | undefined; + /* A Relying Party is always associated with a service. + * This is the name of the service organization */ + organizationName: string | undefined; + /* A Relying Party is always associated with a service. + * This is service id */ + serviceId: ServiceId; + /* A Relying Party is always associated with a service. + * This is service name */ + serviceName: string | undefined; + /* This is the entry point from which the FIMS's flow + * has been starded (e.g., the screen's route name) */ + source: string; }; type FimsFlowHandlerScreenRouteProps = IOStackNavigationRouteProps< @@ -39,7 +61,15 @@ type FimsFlowHandlerScreenRouteProps = IOStackNavigationRouteProps< export const FimsFlowHandlerScreen = ( props: FimsFlowHandlerScreenRouteProps ) => { - const { ctaText, ctaUrl } = props.route.params; + const { + ctaText, + ctaUrl, + organizationFiscalCode, + organizationName, + serviceId, + serviceName, + source + } = props.route.params; const dispatch = useIODispatch(); const requiresAppUpdate = useIOSelector(fimsRequiresAppUpdateSelector); @@ -73,6 +103,14 @@ export const FimsFlowHandlerScreen = ( useEffect(() => { if (ctaUrl && !requiresAppUpdate) { + trackAuthenticationStart( + serviceId, + serviceName, + organizationName, + organizationFiscalCode, + ctaText, + source + ); dispatch(fimsGetConsentsListAction.request({ ctaText, ctaUrl })); } else if (requiresAppUpdate) { trackAuthenticationError( @@ -83,7 +121,17 @@ export const FimsFlowHandlerScreen = ( "update_required" ); } - }, [ctaText, ctaUrl, dispatch, requiresAppUpdate]); + }, [ + ctaText, + ctaUrl, + dispatch, + organizationFiscalCode, + organizationName, + requiresAppUpdate, + serviceId, + serviceName, + source + ]); if (requiresAppUpdate) { return ; diff --git a/ts/features/fims/singleSignOn/screens/__test__/FimsFlowHandlerScreen.test.tsx b/ts/features/fims/singleSignOn/screens/__test__/FimsFlowHandlerScreen.test.tsx new file mode 100644 index 00000000000..9127e64f277 --- /dev/null +++ b/ts/features/fims/singleSignOn/screens/__test__/FimsFlowHandlerScreen.test.tsx @@ -0,0 +1,115 @@ +import * as O from "fp-ts/lib/Option"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { renderScreenWithNavigationStoreContext } from "../../../../../utils/testWrapper"; +import { FimsFlowHandlerScreen } from "../FimsFlowHandlerScreen"; +import { FIMS_ROUTES } from "../../../common/navigation"; +import { GlobalState } from "../../../../../store/reducers/types"; +import * as ANALYTICS from "../../../common/analytics"; +import { ServiceId } from "../../../../../../definitions/backend/ServiceId"; +import { fimsGetConsentsListAction } from "../../store/actions"; +import { ToolEnum } from "../../../../../../definitions/content/AssistanceToolConfig"; +import * as APPVERSION from "../../../../../utils/appVersion"; + +const ctaUrl = "https://relyingParty.url/login"; +const label = "A label"; +const organizationFiscalCode = "01234567891"; +const organizationName = "Organization name"; +const serviceId = "01JMFHJBNP8R55CJZX2G52Q1P2" as ServiceId; +const serviceName = "Service name"; +const source = "MESSAGE_DETAIL"; + +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch +})); + +describe("FimsFlowHandlerScreen", () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + it("should call 'trackAuthenticationStart' and dispatch action upon first rendering", () => { + jest.spyOn(APPVERSION, "getAppVersion").mockReturnValue("2.0.0.0"); + const spyOnTrackAuthenticationStart = jest.spyOn( + ANALYTICS, + "trackAuthenticationStart" + ); + renderComponent("1.0.0.0"); + + expect(spyOnTrackAuthenticationStart).toHaveBeenCalledWith( + serviceId, + serviceName, + organizationName, + organizationFiscalCode, + label, + source + ); + expect(mockDispatch.mock.calls.length).toBe(1); + expect(mockDispatch.mock.calls[0].length).toBe(1); + expect(mockDispatch.mock.calls[0][0]).toEqual( + fimsGetConsentsListAction.request({ ctaText: label, ctaUrl }) + ); + }); + it("should call 'trackAuthenticationError' upon first rendering if an app update is required", () => { + jest.spyOn(APPVERSION, "getAppVersion").mockReturnValue("2.0.0.0"); + const spyOnTrackAuthenticationError = jest.spyOn( + ANALYTICS, + "trackAuthenticationError" + ); + renderComponent("0.0.0.0"); + + expect(spyOnTrackAuthenticationError).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + "update_required" + ); + expect(mockDispatch.mock.calls.length).toBe(0); + }); +}); + +const renderComponent = (minAppVersion: string) => { + const baseState = appReducer(undefined, applicationChangeState("active")); + const testState = { + ...baseState, + remoteConfig: O.some({ + assistanceTool: { + tool: ToolEnum.none + }, + cgn: { + enabled: false + }, + fims: { + min_app_version: { + android: minAppVersion, + ios: minAppVersion + } + }, + itw: { + min_app_version: { + android: "0.0.0.0", + ios: "0.0.0.0" + } + } + }) + } as GlobalState; + const store = createStore(appReducer, testState as any); + return renderScreenWithNavigationStoreContext( + FimsFlowHandlerScreen, + FIMS_ROUTES.CONSENTS, + { + ctaText: label, + ctaUrl, + organizationFiscalCode, + organizationName, + serviceId, + serviceName, + source + }, + store + ); +}; diff --git a/ts/features/fims/singleSignOn/utils/__test__/index.test.ts b/ts/features/fims/singleSignOn/utils/__test__/index.test.ts new file mode 100644 index 00000000000..d97b277b201 --- /dev/null +++ b/ts/features/fims/singleSignOn/utils/__test__/index.test.ts @@ -0,0 +1,61 @@ +import { isFIMSLink, removeFIMSPrefixFromUrl } from ".."; + +describe("index", () => { + describe("removeFIMSPrefixFromUrl", () => { + const fimsCases: ReadonlyArray< + [ + input: string, + expectedResult: ReturnType + ] + > = [ + [ + "iosso://https://italia.io/main/messages?messageId=4&serviceId=5", + "https://italia.io/main/messages?messageId=4&serviceId=5" + ], + [ + "iOsSo://https://italia.io/main/messages?messageId=4&serviceId=5", + "https://italia.io/main/messages?messageId=4&serviceId=5" + ] + ]; + test.each(fimsCases)( + "given %p as argument, returns %p", + (firstArg, expectedResult) => { + const result = removeFIMSPrefixFromUrl(firstArg); + expect(result).toEqual(expectedResult); + } + ); + }); +}); + +describe("isIoFIMSLink", () => { + ["iosso://", "iOsSo://", "IOSSO://"].forEach(protocol => { + it(`should return true for '${protocol}'`, () => { + const isIOFIMSLink = isFIMSLink(`${protocol}whatever`); + expect(isIOFIMSLink).toBe(true); + }); + }); + [ + "iosso:/", + "iosso:", + "iosso", + "https://", + "http://", + "ioit://", + "iohandledlink://", + "clipboard://", + "clipboard:", + "sms://", + "sms:", + "tel://", + "tel:", + "mailto://", + "mailto:", + "copy://", + "copy:" + ].forEach(protocol => { + it(`should return false for '${protocol}'`, () => { + const isIOFIMSLink = isFIMSLink(`${protocol}whatever`); + expect(isIOFIMSLink).toBe(false); + }); + }); +}); diff --git a/ts/features/fims/singleSignOn/utils/index.ts b/ts/features/fims/singleSignOn/utils/index.ts index 545d3216cc2..3e070286a51 100644 --- a/ts/features/fims/singleSignOn/utils/index.ts +++ b/ts/features/fims/singleSignOn/utils/index.ts @@ -4,6 +4,9 @@ import { FimsFlowStateTags, FimsSSOState } from "../store/reducers"; import { startApplicationInitialization } from "../../../../store/actions/application"; import { isStrictSome } from "../../../../utils/pot"; +export const IO_FIMS_LINK_PROTOCOL = "iosso:"; +export const IO_FIMS_LINK_PREFIX = IO_FIMS_LINK_PROTOCOL + "//"; + export const foldFimsFlowState = ( flowState: FimsFlowStateTags, onConsents: (state: "consents") => A, @@ -64,3 +67,12 @@ export const shouldRestartFimsAuthAfterFastLoginFailure = ( } return false; }; + +export const removeFIMSPrefixFromUrl = (fimsUrlWithProtocol: string) => { + // eslint-disable-next-line no-useless-escape + const regexp = new RegExp(`^${IO_FIMS_LINK_PROTOCOL}\/\/`, "i"); + return fimsUrlWithProtocol.replace(regexp, ""); +}; + +export const isFIMSLink = (href: string): boolean => + href.toLowerCase().startsWith(IO_FIMS_LINK_PREFIX); diff --git a/ts/features/idpay/details/screens/IdPayInitiativeDetailsScreen.tsx b/ts/features/idpay/details/screens/IdPayInitiativeDetailsScreen.tsx index c33b598f6fe..25004a9ccf9 100644 --- a/ts/features/idpay/details/screens/IdPayInitiativeDetailsScreen.tsx +++ b/ts/features/idpay/details/screens/IdPayInitiativeDetailsScreen.tsx @@ -52,9 +52,8 @@ import { initiativeNeedsConfigurationSelector } from "../store"; import { idpayInitiativeGet, idpayTimelinePageGet } from "../store/actions"; -import NavigationService from "../../../../navigation/NavigationService"; -import { FIMS_ROUTES } from "../../../fims/common/navigation"; -import { removeFIMSPrefixFromUrl } from "../../../../components/ui/Markdown/handlers/link"; +import { useFIMSAuthenticationFlow } from "../../../fims/common/hooks"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; export type IdPayInitiativeDetailsScreenParams = { initiativeId: string; @@ -87,18 +86,17 @@ const IdPayInitiativeDetailsScreen = () => { }); }; + const startFIMSAuthenticationFlow = useFIMSAuthenticationFlow(); const onAddExpense = () => { const addExpenseFimsUrl = pot.toUndefined(initiativeDataPot)?.webViewUrl; if (!addExpenseFimsUrl) { return; } - NavigationService.navigate(FIMS_ROUTES.MAIN, { - screen: FIMS_ROUTES.CONSENTS, - params: { - ctaText: I18n.t("idpay.initiative.discountDetails.addExpenseButton"), - ctaUrl: removeFIMSPrefixFromUrl(addExpenseFimsUrl) - } - }); + startFIMSAuthenticationFlow( + I18n.t("idpay.initiative.discountDetails.addExpenseButton"), + "01JKB969XNTW23RZTV61XAE824" as ServiceId, // TODO change this as soon as the serviceId is available in the initiativeDataPot + addExpenseFimsUrl + ); }; const navigateToConfiguration = () => { diff --git a/ts/features/itwallet/presentation/details/components/ItwPresentationDetailsFooter.tsx b/ts/features/itwallet/presentation/details/components/ItwPresentationDetailsFooter.tsx index e3cdc4a6517..33248878a73 100644 --- a/ts/features/itwallet/presentation/details/components/ItwPresentationDetailsFooter.tsx +++ b/ts/features/itwallet/presentation/details/components/ItwPresentationDetailsFooter.tsx @@ -3,14 +3,12 @@ import { memo, ReactNode, useMemo } from "react"; import { Alert, View } from "react-native"; import { useStartSupportRequest } from "../../../../../hooks/useStartSupportRequest.ts"; import I18n from "../../../../../i18n.ts"; -import NavigationService from "../../../../../navigation/NavigationService.ts"; import { useIONavigation } from "../../../../../navigation/params/AppParamsList.ts"; import { useIODispatch, useIOSelector, useIOStore } from "../../../../../store/hooks.ts"; -import { FIMS_ROUTES } from "../../../../fims/common/navigation"; import { CREDENTIALS_MAP, trackCredentialDeleteProperties, @@ -20,8 +18,7 @@ import { import { itwIPatenteCtaConfigSelector } from "../../../common/store/selectors/remoteConfig.ts"; import { StoredCredential } from "../../../common/utils/itwTypesUtils.ts"; import { itwCredentialsRemove } from "../../../credentials/store/actions"; -import { trackAuthenticationStart } from "../../../../fims/common/analytics"; -import { ServiceId } from "../../../../../../definitions/backend/ServiceId.ts"; +import { useFIMSRemoteServiceConfiguration } from "../../../../fims/common/hooks/index.tsx"; type ItwPresentationDetailFooterProps = { credential: StoredCredential; @@ -133,6 +130,8 @@ const getCredentialActions = (credentialType: string): ReactNode => * Renders the IPatente service action item */ const IPatenteListItemAction = () => { + const { startFIMSAuthenticationFlow } = + useFIMSRemoteServiceConfiguration("iPatente"); const ctaConfig = useIOSelector(itwIPatenteCtaConfigSelector); if (!ctaConfig?.visibility) { @@ -143,32 +142,13 @@ const IPatenteListItemAction = () => { "features.itWallet.presentation.credentialDetails.actions.openIPatente" ); - const trackIPatenteAuthenticationStart = (label: string) => - trackAuthenticationStart( - ctaConfig.service_id as ServiceId, - ctaConfig.service_name, - ctaConfig.service_organization_name, - ctaConfig.service_organization_fiscal_code, - label, - "credential_detail" - ); - return ( { - trackIPatenteAuthenticationStart(label); - NavigationService.navigate(FIMS_ROUTES.MAIN, { - screen: FIMS_ROUTES.CONSENTS, - params: { - ctaText: label, - ctaUrl: ctaConfig.url - } - }); - }} + onPress={() => startFIMSAuthenticationFlow(label, ctaConfig.url)} /> ); }; diff --git a/ts/features/messages/components/MessageDetail/MessageDetailsStickyFooter.tsx b/ts/features/messages/components/MessageDetail/MessageDetailsStickyFooter.tsx index c13119cdc06..ecac1bc0a27 100644 --- a/ts/features/messages/components/MessageDetail/MessageDetailsStickyFooter.tsx +++ b/ts/features/messages/components/MessageDetail/MessageDetailsStickyFooter.tsx @@ -18,14 +18,12 @@ import { paymentsButtonStateSelector } from "../../store/reducers/payments"; import { trackPNOptInMessageAccepted } from "../../../pn/analytics"; -import { CTAActionType, handleCtaAction } from "../../utils/ctas"; +import { handleCtaAction } from "../../utils/ctas"; import { CTA, CTAS } from "../../types/MessageCTA"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; +import { useFIMSFromServiceId } from "../../../fims/common/hooks"; import { MessageDetailsPaymentButton } from "./MessageDetailsPaymentButton"; -import { - computeAndTrackCTAPressAnalytics, - computeAndTrackFIMSAuthenticationStart -} from "./detailsUtils"; +import { computeAndTrackCTAPressAnalytics } from "./detailsUtils"; const styles = StyleSheet.create({ container: { @@ -287,14 +285,7 @@ export const MessageDetailsStickyFooter = ({ canNavigateToPaymentFromMessageSelector(state) ); - const onCTAPreActionCallback = useCallback( - (cta: CTA) => (type: CTAActionType) => { - const state = store.getState(); - computeAndTrackFIMSAuthenticationStart(type, cta.text, serviceId, state); - }, - [serviceId, store] - ); - + const { startFIMSAuthenticationFlow } = useFIMSFromServiceId(serviceId); const linkTo = useLinkTo(); const onCTAPressedCallback = useCallback( (isFirstCTA: boolean, cta: CTA, isPNOptInMessage: boolean) => { @@ -303,9 +294,11 @@ export const MessageDetailsStickyFooter = ({ if (isPNOptInMessage) { trackPNOptInMessageAccepted(); } - handleCtaAction(cta, linkTo, onCTAPreActionCallback(cta)); + handleCtaAction(cta, linkTo, (label, url) => + startFIMSAuthenticationFlow(label, url) + ); }, - [linkTo, onCTAPreActionCallback, serviceId, store] + [linkTo, serviceId, startFIMSAuthenticationFlow, store] ); const footerData = computeFooterData(paymentData, paymentButtonStatus, ctas); diff --git a/ts/features/messages/components/MessageDetail/detailsUtils.ts b/ts/features/messages/components/MessageDetail/detailsUtils.ts index ed75babbe41..c6404cc0866 100644 --- a/ts/features/messages/components/MessageDetail/detailsUtils.ts +++ b/ts/features/messages/components/MessageDetail/detailsUtils.ts @@ -1,10 +1,8 @@ import { ServiceId } from "../../../../../definitions/backend/ServiceId"; import { GlobalState } from "../../../../store/reducers/types"; -import { trackAuthenticationStart } from "../../../fims/common/analytics"; import { serviceByIdSelector } from "../../../services/details/store/reducers"; import { trackCTAPressed, trackPaymentStart } from "../../analytics"; import { CTA } from "../../types/MessageCTA"; -import { CTAActionType } from "../../utils/ctas"; export const computeAndTrackCTAPressAnalytics = ( isFirstCTA: boolean, @@ -35,22 +33,3 @@ export const computeAndTrackPaymentStart = ( service?.organization_fiscal_code ); }; - -export const computeAndTrackFIMSAuthenticationStart = ( - type: CTAActionType, - ctaLabel: string, - serviceId: ServiceId, - state: GlobalState -) => { - if (type === "fims") { - const service = serviceByIdSelector(state, serviceId); - trackAuthenticationStart( - serviceId, - service?.service_name, - service?.organization_name, - service?.organization_fiscal_code, - ctaLabel, - "message_detail" - ); - } -}; diff --git a/ts/features/messages/saga/handleLoadMessageData.ts b/ts/features/messages/saga/handleLoadMessageData.ts index 879a4ba1158..c8edad25fa7 100644 --- a/ts/features/messages/saga/handleLoadMessageData.ts +++ b/ts/features/messages/saga/handleLoadMessageData.ts @@ -39,8 +39,8 @@ import { RemoteContentDetails } from "../../../../definitions/backend/RemoteCont import { MessageGetStatusFailurePhaseType } from "../store/reducers/messageGetStatus"; import { ServicePublic } from "../../../../definitions/backend/ServicePublic"; import { ctaFromMessageCTA, unsafeMessageCTAFromInput } from "../utils/ctas"; -import { isIoFIMSLink } from "../../../components/ui/Markdown/handlers/link"; import { extractContentFromMessageSources } from "../utils"; +import { isFIMSLink } from "../../fims/singleSignOn/utils"; export function* handleLoadMessageData( action: ActionType @@ -397,10 +397,10 @@ const computeHasFIMSCTA = ( ); const unsafeMessageCTA = unsafeMessageCTAFromInput(markdownWithCTAs); const cta = ctaFromMessageCTA(unsafeMessageCTA); - if (cta != null && isIoFIMSLink(cta.cta_1.action)) { + if (cta != null && isFIMSLink(cta.cta_1.action)) { return true; } - if (cta?.cta_2 != null && isIoFIMSLink(cta.cta_2.action)) { + if (cta?.cta_2 != null && isFIMSLink(cta.cta_2.action)) { return true; } return false; diff --git a/ts/features/messages/utils/__tests__/ctas.test.ts b/ts/features/messages/utils/__tests__/ctas.test.ts index 2c7f7a49532..15a88d324a4 100644 --- a/ts/features/messages/utils/__tests__/ctas.test.ts +++ b/ts/features/messages/utils/__tests__/ctas.test.ts @@ -1,4 +1,5 @@ import { OrganizationFiscalCode } from "@pagopa/ts-commons/lib/strings"; +import { Linking } from "react-native"; import { CreatedMessageWithContent } from "../../../../../definitions/backend/CreatedMessageWithContent"; import { FiscalCode } from "../../../../../definitions/backend/FiscalCode"; import { MessageBodyMarkdown } from "../../../../../definitions/backend/MessageBodyMarkdown"; @@ -13,6 +14,7 @@ import { getMessageCTA, getRemoteLocale, getServiceCTA, + handleCtaAction, testable, unsafeMessageCTAFromInput } from "../ctas"; @@ -70,7 +72,10 @@ const messageWithContent = { // test "it" as default language beforeAll(() => setLocale("it" as Locales)); -afterEach(() => jest.restoreAllMocks()); +afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); +}); describe("getRemoteLocale", () => { it("should return it if locale is it", () => { @@ -565,78 +570,40 @@ describe("internalRoutePredicates", () => { }); describe("isCtaActionValid", () => { - it("should return true for ioit://whatever", () => { - const cta: CTA = { - action: "ioit://whatever", - text: "CTA text" - }; - - const isValid = testable!.isCtaActionValid(cta); - - expect(isValid).toBe(true); - }); - it("should return false for ioit://services/webview with undefined metadata", () => { - const cta: CTA = { - action: "ioit://services/webview", - text: "CTA text" - }; - - const isValid = testable!.isCtaActionValid(cta); - - expect(isValid).toBe(false); - }); - it("should return false for ioit://services/webview with metadata with undefined token_name", () => { - const cta: CTA = { - action: "ioit://services/webview", - text: "CTA text" - }; - const metadata = {} as ServiceMetadata; - - const isValid = testable!.isCtaActionValid(cta, metadata); - - expect(isValid).toBe(false); - }); - it("should return true for iosso://https://relyingParty.url", () => { - const cta: CTA = { - action: "iosso://https://relyingParty.url", - text: "CTA text" - }; - - const isValid = testable!.isCtaActionValid(cta); - - expect(isValid).toBe(true); - }); - ioHandledLinks.forEach(ioHandledLink => - it(`should return true for ${ioHandledLink}`, () => { + const inputData: ReadonlyArray<[string, boolean]> = [ + ["ioit://whatever", true], + ["iOiT://whatever", true], + ["IOIT://whatever", true], + ["ioit://services/webview", false], + ["iosso://https://relyingParty.url", true], + ["iOsSo://https://relyingParty.url", true], + ["IOSSO://https://relyingParty.url", true], + ["iohandledlink://http://whateverHere", true], + ["iohandledlink://https://whateverHere", true], + ["iOhAnDlEdLiNk://https://whateverHere", true], + ["IOHANDLEDLINK://https://whateverHere", true], + ["iohandledlink://sms://whateverHere", true], + ["iohandledlink://tel://whateverHere", true], + ["iohandledlink://mailto://whateverHere", true], + ["iohandledlink://copy://whateverHere", true], + ["iohandledlink://whatever", false], + ["https://www.google.com", false], + ["https://google.com", false], + ["http://www.google.com", false], + ["http://google.com", false], + ["invalid", false] + ]; + inputData.forEach(tuple => { + const [action, validity] = tuple; + it(`should return '${validity}' for '${action}'`, () => { const cta: CTA = { - action: ioHandledLink, + action, text: "CTA text" }; - const isValid = testable!.isCtaActionValid(cta); - expect(isValid).toBe(true); - }) - ); - it(`should return false for iohandledlink://whatever`, () => { - const cta: CTA = { - action: "iohandledlink://whatever", - text: "CTA text" - }; - - const isValid = testable!.isCtaActionValid(cta); - - expect(isValid).toBe(false); - }); - it(`should return false for invalid action`, () => { - const cta: CTA = { - action: "invalid", - text: "CTA text" - }; - - const isValid = testable!.isCtaActionValid(cta); - - expect(isValid).toBe(false); + expect(isValid).toBe(validity); + }); }); }); @@ -1665,3 +1632,86 @@ describe("getServiceCTA", () => { }); }); }); + +describe("handleCtaAction", () => { + const mockedLinkTo = jest.fn(); // (path: string) => undefined; + const mockedFimsCallback = jest.fn(); // (label: string, url: string) => undefined; + [ + "ioit://messages", + "iOiT://messages", + "IOIT://messages", + "iosso://https://relyingParty.url/login", + "iOsSo://https://relyingParty.url/login", + "IOSSO://https://relyingParty.url/login", + "iohandledlink://mailto:johnsmith@gmail.com", + "iOhAnDlEdLiNk://mailto:johnsmith@gmail.com", + "IOHANDLEDLINK://mailto:johnsmith@gmail.com", + "ioit:/messages", + "ioit:messages", + "ioitmessages", + "iosso:/https://relyingParty.url/login", + "iosso:https://relyingParty.url/login", + "iossohttps://relyingParty.url/login", + "iohandledlink:/mailto:johnsmith@gmail.com", + "iohandledlink:mailto:johnsmith@gmail.com", + "iohandledlinkmailto:johnsmith@gmail.com", + "https://www.google.com", + "https://google.com", + "http://www.google.com", + "http://google.com", + "clipboard://prova", + "clipboard:prova", + "sms://3331234567", + "sms:3331234567", + "tel://3331234567", + "tel:3331234567", + "mailto://johnsmith@gmail.com", + "mailto:johnsmith@gmail.com", + "copy://aValue", + "copy:aValue" + // eslint-disable-next-line sonarjs/cognitive-complexity + ].forEach((anUri, index) => { + const linkToCalled = index < 3; + const fimsCalled = index > 2 && index < 6; + const openUrlCalled = index > 5 && index < 9; + it(`should call '${ + linkToCalled + ? "linkTo" + : fimsCalled + ? "fimsCallback" + : openUrlCalled + ? "Linking.openUrl" + : "nothing" + }' when the CTA's action is ${anUri}`, () => { + const spiedOnMockedOpenURL = jest + .spyOn(Linking, "openURL") + .mockImplementation( + _anUrl => new Promise(resolve => resolve(undefined)) + ); + const cta: CTA = { + action: anUri, + text: "A text" + }; + + handleCtaAction(cta, mockedLinkTo, mockedFimsCallback); + + if (linkToCalled) { + expect(mockedLinkTo).toHaveBeenCalledWith(anUri.substring(6)); + } else { + expect(mockedLinkTo).not.toHaveBeenCalled(); + } + + if (fimsCalled) { + expect(mockedFimsCallback).toHaveBeenCalledWith(cta.text, cta.action); + } else { + expect(mockedFimsCallback).not.toHaveBeenCalled(); + } + + if (openUrlCalled) { + expect(spiedOnMockedOpenURL).toHaveBeenCalledWith(anUri.substring(16)); + } else { + expect(spiedOnMockedOpenURL).not.toHaveBeenCalled(); + } + }); + }); +}); diff --git a/ts/features/messages/utils/ctas.ts b/ts/features/messages/utils/ctas.ts index 03b5b0b29b3..cf2979420b5 100644 --- a/ts/features/messages/utils/ctas.ts +++ b/ts/features/messages/utils/ctas.ts @@ -14,21 +14,18 @@ import { ServiceMetadata } from "../../../../definitions/backend/ServiceMetadata import { Locales } from "../../../../locales/locales"; import { deriveCustomHandledLink, - isIoFIMSLink, - isIoInternalLink, - removeFIMSPrefixFromUrl + isIoInternalLink } from "../../../components/ui/Markdown/handlers/link"; import { trackMessageCTAFrontMatterDecodingError } from "../analytics"; import { localeFallback } from "../../../i18n"; -import NavigationService from "../../../navigation/NavigationService"; import { CTA, CTAS, MessageCTA, MessageCTALocales } from "../types/MessageCTA"; import { getInternalRoute, handleInternalLink } from "../../../utils/internalLink"; import { getLocalePrimaryWithFallback } from "../../../utils/locale"; -import { FIMS_ROUTES } from "../../fims/common/navigation"; import { isTestEnv } from "../../../utils/environment"; +import { isFIMSLink } from "../../fims/singleSignOn/utils"; export type CTAActionType = | "io_handled_link" @@ -39,33 +36,19 @@ export type CTAActionType = export const handleCtaAction = ( cta: CTA, linkTo: (path: string) => void, - preActionCallback?: (actionType: CTAActionType) => void + fimsCallback: (label: string, url: string) => void ) => { if (isIoInternalLink(cta.action)) { - preActionCallback?.("io_internal_link"); const convertedLink = getInternalRoute(cta.action); handleInternalLink(linkTo, `${convertedLink}`); - return; - } else if (isIoFIMSLink(cta.action)) { - preActionCallback?.("fims"); - const url = removeFIMSPrefixFromUrl(cta.action); - NavigationService.navigate(FIMS_ROUTES.MAIN, { - screen: FIMS_ROUTES.CONSENTS, - params: { - ctaText: cta.text, - ctaUrl: url - } - }); - return; + } else if (isFIMSLink(cta.action)) { + fimsCallback(cta.text, cta.action); } else { const maybeHandledAction = deriveCustomHandledLink(cta.action); if (E.isRight(maybeHandledAction)) { - preActionCallback?.("io_handled_link"); Linking.openURL(maybeHandledAction.right.url).catch(() => 0); - return; } } - preActionCallback?.("none"); }; const hasMetadataTokenName = (metadata?: ServiceMetadata): boolean => @@ -221,7 +204,7 @@ const isCtaActionValid = ( O.getOrElse(() => true) ); } - if (isIoFIMSLink(cta.action)) { + if (isFIMSLink(cta.action)) { return pipe( E.tryCatch( () => new URL(cta.action), diff --git a/ts/features/services/details/screens/ServiceDetailsScreen.tsx b/ts/features/services/details/screens/ServiceDetailsScreen.tsx index a34502d17ee..5ac3772d952 100644 --- a/ts/features/services/details/screens/ServiceDetailsScreen.tsx +++ b/ts/features/services/details/screens/ServiceDetailsScreen.tsx @@ -7,12 +7,11 @@ import { IOStackNavigationRouteProps } from "../../../../navigation/params/AppPa import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; import { logosForService } from "../../common/utils"; +import { getServiceCTA, handleCtaAction } from "../../../messages/utils/ctas"; +import { useFIMSFromServiceId } from "../../../fims/common/hooks"; +import { ServiceDetailsScreenComponent } from "../components/ServiceDetailsScreenComponent"; import { CTA } from "../../../messages/types/MessageCTA"; -import { - CTAActionType, - getServiceCTA, - handleCtaAction -} from "../../../messages/utils/ctas"; +import { ServicePublic } from "../../../../../definitions/backend/ServicePublic"; import * as analytics from "../../common/analytics"; import { CtaCategoryType } from "../../common/analytics"; import { ServicesHeaderSection } from "../../common/components/ServicesHeaderSection"; @@ -35,9 +34,6 @@ import { serviceMetadataByIdSelector, serviceMetadataInfoSelector } from "../store/reducers"; -import { trackAuthenticationStart } from "../../../fims/common/analytics"; -import { ServicePublic } from "../../../../../definitions/backend/ServicePublic"; -import { ServiceDetailsScreenComponent } from "../components/ServiceDetailsScreenComponent"; export type ServiceDetailsScreenRouteParams = { serviceId: ServiceId; @@ -91,6 +87,8 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { [serviceMetadata] ); + const { startFIMSAuthenticationFlow } = useFIMSFromServiceId(serviceId); + useOnFirstRender( () => { analytics.trackServiceDetails({ @@ -146,18 +144,9 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { cta_category: ctaType, service_id: serviceId }); - handleCtaAction(cta, linkTo, (type: CTAActionType) => { - if (type === "fims") { - trackAuthenticationStart( - service.service_id, - service.service_name, - service.organization_name, - service.organization_fiscal_code, - cta.text, - "service_detail" - ); - } - }); + handleCtaAction(cta, linkTo, (label, url) => + startFIMSAuthenticationFlow(label, url) + ); }; return ( diff --git a/ts/store/reducers/backendStatus/__tests__/remoteConfig.test.ts b/ts/store/reducers/backendStatus/__tests__/remoteConfig.test.ts index 9e12572950a..e2a1f863ed1 100644 --- a/ts/store/reducers/backendStatus/__tests__/remoteConfig.test.ts +++ b/ts/store/reducers/backendStatus/__tests__/remoteConfig.test.ts @@ -5,14 +5,18 @@ import { GlobalState } from "../../types"; import { absolutePortalLinksSelector, barcodesScannerConfigSelector, + fimsServiceConfiguration, generateDynamicUrlSelector, isPnAppVersionSupportedSelector, isPremiumMessagesOptInOutEnabledSelector, landingScreenBannerOrderSelector } from "../remoteConfig"; import * as appVersion from "../../../../utils/appVersion"; +import { ServiceId } from "../../../../../definitions/backend/ServiceId"; + +describe("remoteConfig", () => { + afterEach(() => jest.restoreAllMocks()); -describe("test selectors", () => { // smoke tests: valid / invalid const noneStore = { remoteConfig: O.none @@ -136,115 +140,179 @@ describe("test selectors", () => { expect(output).toBe("https://ioapp.it/path"); }); }); -}); -describe("isPnAppVersionSupportedSelector", () => { - it("should return false, when 'backendStatus' is O.none", () => { - const state = { - remoteConfig: O.none - } as GlobalState; - const isSupported = isPnAppVersionSupportedSelector(state); - expect(isSupported).toBe(false); - }); - it("should return false, when min_app_version is greater than `getAppVersion`", () => { - const state = { - remoteConfig: O.some({ - pn: { - min_app_version: { - android: "2.0.0.0", - ios: "2.0.0.0" + describe("isPnAppVersionSupportedSelector", () => { + it("should return false, when 'backendStatus' is O.none", () => { + const state = { + remoteConfig: O.none + } as GlobalState; + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(false); + }); + it("should return false, when min_app_version is greater than `getAppVersion`", () => { + const state = { + remoteConfig: O.some({ + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } } - } - }) - } as GlobalState; - jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "1.0.0.0"); - const isSupported = isPnAppVersionSupportedSelector(state); - expect(isSupported).toBe(false); - }); - it("should return true, when min_app_version is equal to `getAppVersion`", () => { - const state = { - remoteConfig: O.some({ - pn: { - min_app_version: { - android: "2.0.0.0", - ios: "2.0.0.0" + }) + } as GlobalState; + jest + .spyOn(appVersion, "getAppVersion") + .mockImplementation(() => "1.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(false); + }); + it("should return true, when min_app_version is equal to `getAppVersion`", () => { + const state = { + remoteConfig: O.some({ + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } } - } - }) - } as GlobalState; - jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "2.0.0.0"); - const isSupported = isPnAppVersionSupportedSelector(state); - expect(isSupported).toBe(true); + }) + } as GlobalState; + jest + .spyOn(appVersion, "getAppVersion") + .mockImplementation(() => "2.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(true); + }); + it("should return true, when min_app_version is less than `getAppVersion`", () => { + const state = { + remoteConfig: O.some({ + pn: { + min_app_version: { + android: "2.0.0.0", + ios: "2.0.0.0" + } + } + }) + } as GlobalState; + jest + .spyOn(appVersion, "getAppVersion") + .mockImplementation(() => "3.0.0.0"); + const isSupported = isPnAppVersionSupportedSelector(state); + expect(isSupported).toBe(true); + }); }); - it("should return true, when min_app_version is less than `getAppVersion`", () => { - const state = { - remoteConfig: O.some({ - pn: { - min_app_version: { - android: "2.0.0.0", - ios: "2.0.0.0" + describe("landingScreenBannerOrderSelector", () => { + const getMock = (priority_order: Array | undefined) => + ({ + remoteConfig: O.some({ + landing_banners: { + priority_order } - } - }) + }) + } as GlobalState); + + const some_priorityOrder = ["id1", "id2", "id3"]; + const customNoneStore = { + remoteConfig: O.none } as GlobalState; - jest.spyOn(appVersion, "getAppVersion").mockImplementation(() => "3.0.0.0"); - const isSupported = isPnAppVersionSupportedSelector(state); - expect(isSupported).toBe(true); - }); -}); -describe("landingScreenBannerOrderSelector", () => { - const getMock = (priority_order: Array | undefined) => - ({ - remoteConfig: O.some({ - landing_banners: { - priority_order - } - }) - } as GlobalState); + const undefinedLandingBannersStore = { + remoteConfig: O.some({}) + } as GlobalState; + const testCases = [ + { + selectorInput: getMock(some_priorityOrder), + expected: some_priorityOrder + }, + { + selectorInput: getMock(undefined), + expected: [] + }, + { + selectorInput: getMock([]), + expected: [] + }, + { + selectorInput: customNoneStore, + expected: [] + }, + { + selectorInput: undefinedLandingBannersStore, + expected: [] + } + ]; - const some_priorityOrder = ["id1", "id2", "id3"]; - const customNoneStore = { - remoteConfig: O.none - } as GlobalState; - const undefinedLandingBannersStore = { - remoteConfig: O.some({}) - } as GlobalState; - const testCases = [ - { - selectorInput: getMock(some_priorityOrder), - expected: some_priorityOrder - }, - { - selectorInput: getMock(undefined), - expected: [] - }, - { - selectorInput: getMock([]), - expected: [] - }, - { - selectorInput: customNoneStore, - expected: [] - }, - { - selectorInput: undefinedLandingBannersStore, - expected: [] - } - ]; - - for (const testCase of testCases) { - it(`should return [${testCase.expected}] for ${JSON.stringify( - pipe( - testCase.selectorInput.remoteConfig, - O.fold( - // eslint-disable-next-line no-underscore-dangle - () => testCase.selectorInput.remoteConfig._tag, - identity + for (const testCase of testCases) { + it(`should return [${testCase.expected}] for ${JSON.stringify( + pipe( + testCase.selectorInput.remoteConfig, + O.fold( + // eslint-disable-next-line no-underscore-dangle + () => testCase.selectorInput.remoteConfig._tag, + identity + ) ) - ) - )}`, () => { - const output = landingScreenBannerOrderSelector(testCase.selectorInput); - expect(output).toStrictEqual(testCase.expected); + )}`, () => { + const output = landingScreenBannerOrderSelector(testCase.selectorInput); + expect(output).toStrictEqual(testCase.expected); + }); + } + }); + + describe("fimsServiceConfiguration", () => { + it("should retrieve configuration for matching id", () => { + const configurationId = "aConfId"; + const organizationFiscalCode = "12345678901"; + const organizationName = "Organization name"; + const serviceId = "01JMHVSD7JGCNJF36TX0JM0JF3" as ServiceId; + const serviceName = "Service name"; + const state = { + remoteConfig: O.some({ + fims: { + services: [ + { + configuration_id: configurationId, + service_id: serviceId, + organization_fiscal_code: organizationFiscalCode, + organization_name: "Organization name", + service_name: "Service name" + } + ] + } + }) + } as GlobalState; + const serviceConfiguration = fimsServiceConfiguration( + state, + configurationId + ); + expect(serviceConfiguration).toEqual({ + configuration_id: configurationId, + service_id: serviceId, + organization_fiscal_code: organizationFiscalCode, + organization_name: organizationName, + service_name: serviceName + }); }); - } + it("should return 'undefined' for unmatching id", () => { + const state = { + remoteConfig: O.some({ + fims: { + services: [ + { + configuration_id: "aConfId", + service_id: "01JMHVSD7JGCNJF36TX0JM0JF3", + organization_fiscal_code: "12345678901", + organization_name: "Organization name", + service_name: "Service name" + } + ] + } + }) + } as GlobalState; + const serviceConfiguration = fimsServiceConfiguration( + state, + "unmatchingConfId" + ); + expect(serviceConfiguration).toBeUndefined(); + }); + }); }); diff --git a/ts/store/reducers/backendStatus/remoteConfig.ts b/ts/store/reducers/backendStatus/remoteConfig.ts index 276b87b78b0..ccad51d6426 100644 --- a/ts/store/reducers/backendStatus/remoteConfig.ts +++ b/ts/store/reducers/backendStatus/remoteConfig.ts @@ -1,4 +1,5 @@ import * as O from "fp-ts/lib/Option"; +import * as RA from "fp-ts/lib/ReadonlyArray"; import { pipe } from "fp-ts/lib/function"; import { Platform } from "react-native"; import { createSelector } from "reselect"; @@ -21,6 +22,7 @@ import { Action } from "../../actions/types"; import { isPropertyWithMinAppVersionEnabled } from "../featureFlagWithMinAppVersionStatus"; import { isIdPayLocallyEnabledSelector } from "../persistedPreferences"; import { GlobalState } from "../types"; +import { FimsServiceConfiguration } from "../../../../definitions/content/FimsServiceConfiguration"; export type RemoteConfigState = O.Option; @@ -119,6 +121,26 @@ export const fimsRequiresAppUpdateSelector = (state: GlobalState) => }) ); +export const fimsServiceConfiguration = createSelector( + [ + remoteConfigSelector, + (_state: GlobalState, configurationId: string) => configurationId + ], + ( + remoteConfig, + configurationId: string + ): FimsServiceConfiguration | undefined => + pipe( + remoteConfig, + O.chainNullableK(config => config.fims.services), + O.map( + RA.findFirst(service => service.configuration_id === configurationId) + ), + O.flatten, + O.toUndefined + ) +); + export const oidcProviderDomainSelector = (state: GlobalState) => pipe( state, diff --git a/ts/utils/__tests__/internalLink.test.ts b/ts/utils/__tests__/internalLink.test.ts index fba09b3501d..1bcd9c1db9f 100644 --- a/ts/utils/__tests__/internalLink.test.ts +++ b/ts/utils/__tests__/internalLink.test.ts @@ -5,10 +5,10 @@ import { testableALLOWED_ROUTE_NAMES } from "../internalLink"; import { - IO_FIMS_LINK_PREFIX, IO_INTERNAL_LINK_PREFIX, IO_UNIVERSAL_LINK_PREFIX } from "../navigation"; +import { IO_FIMS_LINK_PREFIX } from "../../features/fims/singleSignOn/utils"; describe("getInternalRoute", () => { const allowedRoutes = Object.entries(testableALLOWED_ROUTE_NAMES!).map( diff --git a/ts/utils/internalLink.ts b/ts/utils/internalLink.ts index 2af65e5e060..5957b23a079 100644 --- a/ts/utils/internalLink.ts +++ b/ts/utils/internalLink.ts @@ -10,9 +10,9 @@ import { FCI_ROUTES } from "../features/fci/navigation/routes"; import ROUTES from "../navigation/routes"; import { MESSAGES_ROUTES } from "../features/messages/navigation/routes"; import { SERVICES_ROUTES } from "../features/services/common/navigation/routes"; +import { IO_FIMS_LINK_PREFIX } from "../features/fims/singleSignOn/utils"; import { isTestEnv } from "./environment"; import { - IO_FIMS_LINK_PREFIX, IO_INTERNAL_LINK_PREFIX, IO_INTERNAL_LINK_PROTOCOL, IO_UNIVERSAL_LINK_PREFIX diff --git a/ts/utils/navigation.ts b/ts/utils/navigation.ts index 729568fe16c..ce82b41b4af 100644 --- a/ts/utils/navigation.ts +++ b/ts/utils/navigation.ts @@ -5,9 +5,6 @@ import { Platform } from "react-native"; export const IO_INTERNAL_LINK_PROTOCOL = "ioit:"; export const IO_INTERNAL_LINK_PREFIX = IO_INTERNAL_LINK_PROTOCOL + "//"; -export const IO_FIMS_LINK_PROTOCOL = "iosso:"; -export const IO_FIMS_LINK_PREFIX = IO_FIMS_LINK_PROTOCOL + "//"; - export const IO_UNIVERSAL_LINK_PREFIX = "https://continua.io.pagopa.it"; /** diff --git a/ts/utils/url.ts b/ts/utils/url.ts index 992deaf1d28..7bde96a8050 100644 --- a/ts/utils/url.ts +++ b/ts/utils/url.ts @@ -146,7 +146,8 @@ export function extractPathFromURL( `^${escapeStringRegexp(protocol)}(/)*${host .split(".") .map(it => (it === "*" ? "[^/]+" : escapeStringRegexp(it))) - .join("\\.")}` + .join("\\.")}`, + "i" ); const normalizedURL = url.replace(/\/+/g, "/");