From 716eb9b60ab62bf6771a9a28c409d487f039ad02 Mon Sep 17 00:00:00 2001 From: Gianluca Spada Date: Tue, 3 Sep 2024 17:39:37 +0200 Subject: [PATCH] chore(IT Wallet): [SIW-1299] Credentials status attestation at app startup (#6138) > [!WARNING] > This PR depends on #6128 ## Short description This PR introduces status attestations for credentials in the wallet. At app startup, **if the wallet is valid**, a saga checks each credential with the following logic: - It fetches the status attestation if the credential does not have one or there was an unexpected error during the previous attempt; - It fetches the status attestation if the previous one is expired (the current date is compared to the `exp` field ); - It does not fetch the status attestation if the previous one is not expired yet; - It does not fetch the status attestation if the credential is invalid/revoked. ## List of changes proposed in this pull request - Added `storedStatusAttestation` key to `StoredCredential` - Added `checkCredentialsStatusAttestation` saga, called after `checkWalletInstanceStateSaga` - Created `getCredentialStatus` function to get the overall status of a credential, using the status attestation first and then the expiration date - Modified `DateClaimItem` component to receive the credential status from outside and display the badge "Valid", "Not valid", "Expiring" accordingly - Passed the credential's status to its wallet card representation to be consistent ## How to test There are different cases to test. #### Credential without status attestation 1. Get a new credential and restart the app 2. Check the network for the status attestation HTTP call 3. Check the Redux store, the credential should contain `storedStatusAttestation` with a valid attestation #### Credential with valid status attestation 1. Restart the app with a credential that has a valid status attestation 2. Check the network, no status attestation HTTP call should appear 3. Check the Redux store, the credential should contain `storedStatusAttestation` with a valid attestation #### Credential with expired status attestation 1. Restart the app with a credential that has an expired status attestation (one way to fake the expiration is to modify the `new Date()` in `shouldRequestStatusAttestation` with a date after `exp`) 2. Check the network for the status attestation HTTP call 3. Check the Redux store, the credential should contain `storedStatusAttestation` with the new attestation #### Revoked credential 1. Restart the app with a credential that has an expired status attestation (fake an expired status attestation to force a refresh) 2. Check the network for the status attestation HTTP call (use an HTTP proxy to force a 404 response) 3. Check the Redux store, the credential should contain `storedStatusAttestation` with `credentialStatus: invalid` 4. Check the cards in the wallet to see the invalid credential 5. Check the credential detail to see the "Not valid" badge 7. Restart the app, no further status attestation HTTP calls should appear because the credential is invalid --------- Co-authored-by: LazyAfternoons --- locales/en/index.yml | 3 +- locales/it/index.yml | 3 +- .../itwallet/__mocks__/statusAttestation.json | 15 +++ .../common/components/ItwCredentialClaim.tsx | 56 ++++++----- .../components/ItwCredentialClaimList.tsx | 8 +- ts/features/itwallet/common/saga/index.ts | 12 ++- .../itwallet/common/store/reducers/index.ts | 37 +++++++- ...twCredentialStatusAttestationUtils.test.ts | 86 +++++++++++++++++ .../itwallet/common/utils/itwClaimsUtils.ts | 32 +++---- .../itwCredentialStatusAttestationUtils.ts | 56 +++++++++++ .../itwallet/common/utils/itwMocksUtils.ts | 7 +- .../itwallet/common/utils/itwTypesUtils.ts | 16 ++++ ...handleWalletCredentialsRehydration.test.ts | 47 +++++++-- .../saga/checkCredentialsStatusAttestation.ts | 95 +++++++++++++++++++ .../handleWalletCredentialsRehydration.ts | 4 +- .../credentials/store/actions/index.ts | 11 ++- .../store/reducers/__tests__/index.test.ts | 47 ++++++++- .../credentials/store/reducers/index.ts | 23 ++++- .../ItwPresentationAlertsSection.tsx | 4 +- .../ItwPresentationCredentialDetailScreen.tsx | 6 +- 20 files changed, 499 insertions(+), 69 deletions(-) create mode 100644 ts/features/itwallet/__mocks__/statusAttestation.json create mode 100644 ts/features/itwallet/common/utils/__tests__/itwCredentialStatusAttestationUtils.test.ts create mode 100644 ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts create mode 100644 ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts diff --git a/locales/en/index.yml b/locales/en/index.yml index 99500282352..88421cd2f92 100644 --- a/locales/en/index.yml +++ b/locales/en/index.yml @@ -3264,7 +3264,8 @@ features: hiddenClaim: "Hidden" status: valid: Valid - expired: Expired + expired: Not valid + expiring: Expiring actions: removeFromWallet: "Remove from Wallet" requestAssistance: "Something wrong? Contact {{authSource}}" diff --git a/locales/it/index.yml b/locales/it/index.yml index 63566cb8cf6..c8f3abe5462 100644 --- a/locales/it/index.yml +++ b/locales/it/index.yml @@ -3264,7 +3264,8 @@ features: hiddenClaim: "Nascosto" status: valid: Valida - expired: Scaduta + expired: Non valida + expiring: In scadenza actions: removeFromWallet: "Rimuovi dal Portafoglio" requestAssistance: "Qualcosa non torna? Contatta {{authSource}}" diff --git a/ts/features/itwallet/__mocks__/statusAttestation.json b/ts/features/itwallet/__mocks__/statusAttestation.json new file mode 100644 index 00000000000..54a5f747a0e --- /dev/null +++ b/ts/features/itwallet/__mocks__/statusAttestation.json @@ -0,0 +1,15 @@ +{ + "iat": 1724913816, + "exp": 1725000216, + "cnf": { + "jwk": { + "x": "ASVPcUncSZ_N1dxen8AbV1Q_hID3ZFA8n1wL27YO8D8B", + "y": "TgIEMB8-1SqHMFheJWcI0oFmslC32Ef1zGwCKv4lM1c", + "kty": "EC", + "kid": "RAeSKpaAt8kagQMnScq6lM53aaRG7jIzQIhFdtMRW40", + "crv": "P-256" + } + }, + "credential_hash": "07157f9a54a5d50929daab85a79dc622ffc991c3b52099ba7058f672c651497a", + "credential_hash_alg": "S256" +} \ No newline at end of file diff --git a/ts/features/itwallet/common/components/ItwCredentialClaim.tsx b/ts/features/itwallet/common/components/ItwCredentialClaim.tsx index 492d8f139cc..f7817d7535e 100644 --- a/ts/features/itwallet/common/components/ItwCredentialClaim.tsx +++ b/ts/features/itwallet/common/components/ItwCredentialClaim.tsx @@ -6,7 +6,6 @@ import { pipe } from "fp-ts/lib/function"; import React, { useMemo } from "react"; import { Image } from "react-native"; import I18n from "../../../../i18n"; -import { getExpireStatus } from "../../../../utils/dates"; import { useIOBottomSheetAutoresizableModal } from "../../../../utils/hooks/bottomSheet"; import { localeDateFormat } from "../../../../utils/locale"; import { useItwInfoBottomSheet } from "../hooks/useItwInfoBottomSheet"; @@ -14,7 +13,6 @@ import { BoolClaim, ClaimDisplayFormat, ClaimValue, - DateClaimConfig, DrivingPrivilegeClaimType, DrivingPrivilegesClaim, EmptyStringClaim, @@ -24,11 +22,11 @@ import { PlaceOfBirthClaim, PlaceOfBirthClaimType, StringClaim, - dateClaimsConfig, extractFiscalCode, - getSafeText, - previewDateClaimsConfig + isExpirationDateClaim, + getSafeText } from "../utils/itwClaimsUtils"; +import { ItwCredentialStatus } from "./ItwCredentialCard"; const HIDDEN_CLAIM = "******"; @@ -104,33 +102,42 @@ const PlainTextClaimItem = ({ const DateClaimItem = ({ label, claim, - expirationBadgeVisible + status }: { label: string; claim: Date; -} & DateClaimConfig) => { + status?: ItwCredentialStatus; +}) => { const value = localeDateFormat( claim, I18n.t("global.dateFormats.shortFormat") ); const endElement: ListItemInfo["endElement"] = useMemo(() => { - if (!expirationBadgeVisible) { + if (!status || status === "pending") { return; } - const isExpired = getExpireStatus(claim) === "EXPIRED"; + + const credentialStatusProps = { + expired: { + badge: "error", + text: "features.itWallet.presentation.credentialDetails.status.expired" + }, + expiring: { + badge: "warning", + text: "features.itWallet.presentation.credentialDetails.status.expiring" + }, + valid: { + badge: "success", + text: "features.itWallet.presentation.credentialDetails.status.valid" + } + } as const; + const { badge, text } = credentialStatusProps[status]; return { type: "badge", - componentProps: { - variant: isExpired ? "error" : "success", - text: I18n.t( - `features.itWallet.presentation.credentialDetails.status.${ - isExpired ? "expired" : "valid" - }` - ) - } + componentProps: { variant: badge, text: I18n.t(text) } }; - }, [expirationBadgeVisible, claim]); + }, [status]); return ( pipe( claim.value, @@ -331,14 +340,15 @@ export const ItwCredentialClaim = ({ if (PlaceOfBirthClaim.is(decoded)) { return ; } else if (DateFromString.is(decoded)) { - const dateClaimProps = isPreview - ? previewDateClaimsConfig - : dateClaimsConfig[claim.id]; return ( ); } else if (EvidenceClaim.is(decoded)) { diff --git a/ts/features/itwallet/common/components/ItwCredentialClaimList.tsx b/ts/features/itwallet/common/components/ItwCredentialClaimList.tsx index 919b03c018d..a91d664d537 100644 --- a/ts/features/itwallet/common/components/ItwCredentialClaimList.tsx +++ b/ts/features/itwallet/common/components/ItwCredentialClaimList.tsx @@ -1,7 +1,11 @@ import React from "react"; import { View } from "react-native"; import { Divider } from "@pagopa/io-app-design-system"; -import { parseClaims, WellKnownClaim } from "../utils/itwClaimsUtils"; +import { + getCredentialStatus, + parseClaims, + WellKnownClaim +} from "../utils/itwClaimsUtils"; import { StoredCredential } from "../utils/itwTypesUtils"; import { ItwCredentialClaim } from "./ItwCredentialClaim"; import { ItwReleaserName } from "./ItwReleaserName"; @@ -20,6 +24,7 @@ export const ItwCredentialClaimsList = ({ isPreview?: boolean; isHidden?: boolean; }) => { + const credentialStatus = getCredentialStatus(data); const claims = parseClaims(data.parsedCredential, { exclude: [WellKnownClaim.unique_id, WellKnownClaim.link_qr_code] }); @@ -33,6 +38,7 @@ export const ItwCredentialClaimsList = ({ claim={elem} isPreview={isPreview} hidden={isHidden} + credentialStatus={credentialStatus} /> ))} diff --git a/ts/features/itwallet/common/saga/index.ts b/ts/features/itwallet/common/saga/index.ts index 6a273de2bc9..bcd33064cd9 100644 --- a/ts/features/itwallet/common/saga/index.ts +++ b/ts/features/itwallet/common/saga/index.ts @@ -1,5 +1,5 @@ import { SagaIterator } from "redux-saga"; -import { fork, put } from "typed-redux-saga/macro"; +import { fork, put, call } from "typed-redux-saga/macro"; import { trialSystemActivationStatus } from "../../../trialSystem/store/actions"; import { watchItwIdentificationSaga } from "../../identification/saga"; import { checkWalletInstanceStateSaga } from "../../lifecycle/saga/checkWalletInstanceStateSaga"; @@ -7,9 +7,17 @@ import { handleWalletCredentialsRehydration } from "../../credentials/saga/handl import { itwTrialId } from "../../../../config"; import { itwCieIsSupported } from "../../identification/store/actions"; import { watchItwLifecycleSaga } from "../../lifecycle/saga"; +import { checkCredentialsStatusAttestation } from "../../credentials/saga/checkCredentialsStatusAttestation"; + +function* checkWalletInstanceAndCredentialsValiditySaga() { + // Status attestations of credentials are checked only in case of a valid wallet instance. + // For this reason, these sagas must be called sequentially. + yield* call(checkWalletInstanceStateSaga); + yield* call(checkCredentialsStatusAttestation); +} export function* watchItwSaga(): SagaIterator { - yield* fork(checkWalletInstanceStateSaga); + yield* fork(checkWalletInstanceAndCredentialsValiditySaga); yield* fork(handleWalletCredentialsRehydration); yield* fork(watchItwIdentificationSaga); yield* fork(watchItwLifecycleSaga); diff --git a/ts/features/itwallet/common/store/reducers/index.ts b/ts/features/itwallet/common/store/reducers/index.ts index cefe8e96a8c..4b667756fbf 100644 --- a/ts/features/itwallet/common/store/reducers/index.ts +++ b/ts/features/itwallet/common/store/reducers/index.ts @@ -1,6 +1,14 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { PersistConfig, PersistPartial, persistReducer } from "redux-persist"; +import * as O from "fp-ts/lib/Option"; +import { + createMigrate, + MigrationManifest, + PersistConfig, + PersistPartial, + persistReducer +} from "redux-persist"; import { combineReducers } from "redux"; +import { pipe } from "fp-ts/lib/function"; import { Action } from "../../../../../store/actions/types"; import identificationReducer, { ItwIdentificationState @@ -15,6 +23,8 @@ import itwCredentialsReducer, { ItwCredentialsState } from "../../../credentials/store/reducers"; import itwCreateCredentialsStorage from "../storages/itwCredentialsStorage"; +import { StoredCredential } from "../../utils/itwTypesUtils"; +import { isDevEnv } from "../../../../../utils/environment"; export type ItWalletState = { identification: ItwIdentificationState; @@ -26,7 +36,27 @@ export type ItWalletState = { export type PersistedItWalletState = ReturnType; const CURRENT_REDUX_ITW_STORE_VERSION = -1; -const CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION = -1; +const CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION = 0; +export const itwCredentialsStateMigrations: MigrationManifest = { + "0": (state): ItwCredentialsState & PersistPartial => { + // Version 0 + // Add optional `storedStatusAttestation` field + const addStoredStatusAttestation = ( + credential: StoredCredential + ): StoredCredential => ({ + ...credential, + storedStatusAttestation: undefined + }); + const prevState = state as ItwCredentialsState & PersistPartial; + return { + ...prevState, + eid: pipe(prevState.eid, O.map(addStoredStatusAttestation)), + credentials: prevState.credentials.map(credential => + pipe(credential, O.map(addStoredStatusAttestation)) + ) + }; + } +}; const itwPersistConfig: PersistConfig = { key: "itWallet", @@ -38,7 +68,8 @@ const itwPersistConfig: PersistConfig = { const itwCredentialsPersistConfig: PersistConfig = { key: "itWalletCredentials", storage: itwCreateCredentialsStorage(), - version: CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION + version: CURRENT_REDUX_ITW_CREDENTIALS_STORE_VERSION, + migrate: createMigrate(itwCredentialsStateMigrations, { debug: isDevEnv }) }; const itwReducer = combineReducers({ diff --git a/ts/features/itwallet/common/utils/__tests__/itwCredentialStatusAttestationUtils.test.ts b/ts/features/itwallet/common/utils/__tests__/itwCredentialStatusAttestationUtils.test.ts new file mode 100644 index 00000000000..3693921853f --- /dev/null +++ b/ts/features/itwallet/common/utils/__tests__/itwCredentialStatusAttestationUtils.test.ts @@ -0,0 +1,86 @@ +import { shouldRequestStatusAttestation } from "../itwCredentialStatusAttestationUtils"; +import { CredentialType, ItwStatusAttestationMocks } from "../itwMocksUtils"; +import { StoredCredential } from "../itwTypesUtils"; + +describe("isStatusAttestationMissingOrExpired", () => { + const baseMockCredential: StoredCredential = { + credential: "", + credentialType: CredentialType.DRIVING_LICENSE, + format: "vc+sd-jwt", + keyTag: "9020c6f8-01be-4236-9b6f-834af9dcbc63", + issuerConf: {} as StoredCredential["issuerConf"], + parsedCredential: {} + }; + + it("return true when the status attestation is missing", () => { + expect(shouldRequestStatusAttestation(baseMockCredential)).toEqual(true); + }); + + it("return true when the parsed status attestation is null", () => { + const mockCredential: StoredCredential = { + ...baseMockCredential, + storedStatusAttestation: { + credentialStatus: "unknown" + } + }; + expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true); + }); + + it("return true when the status attestation is expired", () => { + jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00")); + + const mockCredential: StoredCredential = { + ...baseMockCredential, + storedStatusAttestation: { + credentialStatus: "valid", + statusAttestation: "abc", + parsedStatusAttestation: { + ...ItwStatusAttestationMocks.mdl, + exp: 1724752800 // 2024-08-27T10:00:00+00:00 + } + } + }; + expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true); + }); + + it("return false when the status attestation is still valid", () => { + jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00")); + + const mockCredential: StoredCredential = { + ...baseMockCredential, + storedStatusAttestation: { + credentialStatus: "valid", + statusAttestation: "abc", + parsedStatusAttestation: { + ...ItwStatusAttestationMocks.mdl, + exp: 1724781600 // 2024-08-27T18:00:00+00:00, + } + } + }; + expect(shouldRequestStatusAttestation(mockCredential)).toEqual(false); + }); + + it("return false when the credential status is invalid", () => { + jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00")); + + const mockCredential: StoredCredential = { + ...baseMockCredential, + storedStatusAttestation: { + credentialStatus: "invalid" + } + }; + expect(shouldRequestStatusAttestation(mockCredential)).toEqual(false); + }); + + it("return true when the credential status is unknown", () => { + jest.useFakeTimers().setSystemTime(new Date("2024-08-27T10:30:00+00:00")); + + const mockCredential: StoredCredential = { + ...baseMockCredential, + storedStatusAttestation: { + credentialStatus: "unknown" + } + }; + expect(shouldRequestStatusAttestation(mockCredential)).toEqual(true); + }); +}); diff --git a/ts/features/itwallet/common/utils/itwClaimsUtils.ts b/ts/features/itwallet/common/utils/itwClaimsUtils.ts index 8f9eaedff2f..d0204e53a94 100644 --- a/ts/features/itwallet/common/utils/itwClaimsUtils.ts +++ b/ts/features/itwallet/common/utils/itwClaimsUtils.ts @@ -271,22 +271,6 @@ export const ClaimValue = t.union([ EmptyStringClaim ]); -export type DateClaimConfig = Partial<{ - iconVisible: boolean; - expirationBadgeVisible: boolean; -}>; - -export const dateClaimsConfig: Record = { - issue_date: { iconVisible: true }, - expiry_date: { iconVisible: true, expirationBadgeVisible: true }, - expiration_date: { iconVisible: true, expirationBadgeVisible: true } -}; - -export const previewDateClaimsConfig: DateClaimConfig = { - iconVisible: false, - expirationBadgeVisible: false -}; - /** * * @@ -354,6 +338,19 @@ export const getCredentialExpireStatus = ( : "expired"; }; +/** + * Get the overall status of the credential, taking into account + * the status attestation if present and the credential's own expiration date. + */ +export const getCredentialStatus = ( + credential: StoredCredential +): ItwCredentialStatus | undefined => { + if (credential.storedStatusAttestation?.credentialStatus === "invalid") { + return "expired"; + } + return getCredentialExpireStatus(credential.parsedCredential); +}; + const FISCAL_CODE_REGEX = /([A-Z]{6}[0-9LMNPQRSTUV]{2}[ABCDEHLMPRST][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z])/g; @@ -384,3 +381,6 @@ export const getFiscalCodeFromCredential = ( * Truncate long strings to avoid performance issues when rendering claims. */ export const getSafeText = (text: string) => truncate(text, { length: 128 }); + +export const isExpirationDateClaim = (claim: ClaimDisplayFormat) => + claim.id === WellKnownClaim.expiry_date; diff --git a/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts b/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts new file mode 100644 index 00000000000..df51ad080d5 --- /dev/null +++ b/ts/features/itwallet/common/utils/itwCredentialStatusAttestationUtils.ts @@ -0,0 +1,56 @@ +import { + createCryptoContextFor, + Credential +} from "@pagopa/io-react-native-wallet"; +import { isAfter } from "date-fns"; +import { StoredCredential } from "./itwTypesUtils"; + +export const getCredentialStatusAttestation = async ( + credential: StoredCredential +) => { + const credentialCryptoContext = createCryptoContextFor(credential.keyTag); + + const rawStatusAttestation = await Credential.Status.statusAttestation( + credential.issuerConf, + credential.credential, + credentialCryptoContext + ); + + const { parsedStatusAttestation } = + await Credential.Status.verifyAndParseStatusAttestation( + credential.issuerConf, + rawStatusAttestation, + { credentialCryptoContext } + ); + + return { + statusAttestation: rawStatusAttestation.statusAttestation, + parsedStatusAttestation + }; +}; + +export const shouldRequestStatusAttestation = ({ + storedStatusAttestation +}: StoredCredential) => { + // When no status attestation is present, request a new one + if (!storedStatusAttestation) { + return true; + } + + switch (storedStatusAttestation.credentialStatus) { + // We could not determine the status, try to request another attestation + case "unknown": + return true; + // The credential is invalid, no need to request another attestation + case "invalid": + return false; + // When the status attestation is expired request a new one + case "valid": + return isAfter( + new Date(), + new Date(storedStatusAttestation.parsedStatusAttestation.exp * 1000) + ); + default: + throw new Error("Unexpected credential status"); + } +}; diff --git a/ts/features/itwallet/common/utils/itwMocksUtils.ts b/ts/features/itwallet/common/utils/itwMocksUtils.ts index 05fa60f7b11..20d06602d60 100644 --- a/ts/features/itwallet/common/utils/itwMocksUtils.ts +++ b/ts/features/itwallet/common/utils/itwMocksUtils.ts @@ -4,7 +4,8 @@ import eid from "../../__mocks__/eid.json"; import mdlCredential from "../../__mocks__/mdl"; import mdl from "../../__mocks__/mdl.json"; import ts from "../../__mocks__/ts.json"; -import { StoredCredential } from "./itwTypesUtils"; +import statusAttestation from "../../__mocks__/statusAttestation.json"; +import { ParsedStatusAttestation, StoredCredential } from "./itwTypesUtils"; export const ISSUER_MOCK_NAME = "Istituto Poligrafico e Zecca dello Stato"; @@ -97,3 +98,7 @@ export const ItwStoredCredentialsMocks = { export const ItwRawCredentialsMocks = { mdl: mdlCredential }; + +export const ItwStatusAttestationMocks = { + mdl: statusAttestation as unknown as ParsedStatusAttestation +}; diff --git a/ts/features/itwallet/common/utils/itwTypesUtils.ts b/ts/features/itwallet/common/utils/itwTypesUtils.ts index c0f358dd7a0..47aa87afec7 100644 --- a/ts/features/itwallet/common/utils/itwTypesUtils.ts +++ b/ts/features/itwallet/common/utils/itwTypesUtils.ts @@ -36,6 +36,21 @@ export type ParsedCredential = Awaited< ReturnType >["parsedCredential"]; +/** + * Alias for the ParsedStatusAttestation type + */ +export type ParsedStatusAttestation = Awaited< + ReturnType +>["parsedStatusAttestation"]["payload"]; + +export type StoredStatusAttestation = + | { + credentialStatus: "valid"; + statusAttestation: string; + parsedStatusAttestation: ParsedStatusAttestation; + } + | { credentialStatus: "invalid" | "unknown" }; + /** * Type for a stored credential. */ @@ -46,4 +61,5 @@ export type StoredCredential = { parsedCredential: ParsedCredential; credentialType: string; issuerConf: IssuerConfiguration; + storedStatusAttestation?: StoredStatusAttestation; }; diff --git a/ts/features/itwallet/credentials/saga/__tests__/handleWalletCredentialsRehydration.test.ts b/ts/features/itwallet/credentials/saga/__tests__/handleWalletCredentialsRehydration.test.ts index f70464516a9..52f68fbc7ab 100644 --- a/ts/features/itwallet/credentials/saga/__tests__/handleWalletCredentialsRehydration.test.ts +++ b/ts/features/itwallet/credentials/saga/__tests__/handleWalletCredentialsRehydration.test.ts @@ -6,8 +6,21 @@ import { CredentialType } from "../../../common/utils/itwMocksUtils"; import { handleWalletCredentialsRehydration } from "../handleWalletCredentialsRehydration"; import { walletAddCards } from "../../../../newWallet/store/actions/cards"; import { ItwLifecycleState } from "../../../lifecycle/store/reducers"; +import { StoredCredential } from "../../../common/utils/itwTypesUtils"; describe("ITW handleWalletCredentialsRehydration saga", () => { + const expirationClaim = { value: "2100-09-04", name: "exp" }; + const mockedEid: StoredCredential = { + credential: "", + credentialType: CredentialType.PID, + parsedCredential: { + expiry_date: expirationClaim + }, + format: "vc+sd-jwt", + keyTag: "1", + issuerConf: {} as StoredCredential["issuerConf"] + }; + it("rehydrates the eID when the wallet is valid", () => { const store: DeepPartial = { features: { @@ -15,7 +28,7 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID, issuance: { integrityKeyTag: O.some("key-tag") }, credentials: { - eid: O.some({ keyTag: "1", credentialType: CredentialType.PID }), + eid: O.some(mockedEid), credentials: [] } } @@ -30,7 +43,8 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { key: "1", type: "itw", category: "itw", - credentialType: CredentialType.PID + credentialType: CredentialType.PID, + status: "valid" } ]) ) @@ -44,16 +58,22 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { lifecycle: ItwLifecycleState.ITW_LIFECYCLE_VALID, issuance: { integrityKeyTag: O.some("key-tag") }, credentials: { - eid: O.some({ keyTag: "1", credentialType: CredentialType.PID }), + eid: O.some(mockedEid), credentials: [ O.none, O.some({ keyTag: "2", - credentialType: CredentialType.DRIVING_LICENSE + credentialType: CredentialType.DRIVING_LICENSE, + parsedCredential: { + expiry_date: expirationClaim + } }), O.some({ keyTag: "3", - credentialType: CredentialType.EUROPEAN_DISABILITY_CARD + credentialType: CredentialType.EUROPEAN_DISABILITY_CARD, + parsedCredential: { + expiry_date: expirationClaim + } }) ] } @@ -69,19 +89,22 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { key: "1", type: "itw", category: "itw", - credentialType: CredentialType.PID + credentialType: CredentialType.PID, + status: "valid" }, { key: "2", type: "itw", category: "itw", - credentialType: CredentialType.DRIVING_LICENSE + credentialType: CredentialType.DRIVING_LICENSE, + status: "valid" }, { key: "3", type: "itw", category: "itw", - credentialType: CredentialType.EUROPEAN_DISABILITY_CARD + credentialType: CredentialType.EUROPEAN_DISABILITY_CARD, + status: "valid" } ]) ) @@ -99,7 +122,10 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { credentials: [ O.some({ keyTag: "2", - credentialType: CredentialType.DRIVING_LICENSE + credentialType: CredentialType.DRIVING_LICENSE, + parsedCredential: { + expiry_date: expirationClaim + } }) ] } @@ -115,7 +141,8 @@ describe("ITW handleWalletCredentialsRehydration saga", () => { key: "2", type: "itw", category: "itw", - credentialType: CredentialType.DRIVING_LICENSE + credentialType: CredentialType.DRIVING_LICENSE, + status: "valid" } ]) ) diff --git a/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts b/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts new file mode 100644 index 00000000000..1701d6848f7 --- /dev/null +++ b/ts/features/itwallet/credentials/saga/checkCredentialsStatusAttestation.ts @@ -0,0 +1,95 @@ +import { select, call, all, put } from "typed-redux-saga/macro"; +import { pipe } from "fp-ts/lib/function"; +import * as RA from "fp-ts/ReadonlyArray"; +import * as O from "fp-ts/Option"; +import { Errors } from "@pagopa/io-react-native-wallet"; +import { itwCredentialsSelector } from "../store/selectors"; +import { StoredCredential } from "../../common/utils/itwTypesUtils"; +import { CredentialType } from "../../common/utils/itwMocksUtils"; +import { + shouldRequestStatusAttestation, + getCredentialStatusAttestation +} from "../../common/utils/itwCredentialStatusAttestationUtils"; +import { itwCredentialsMultipleUpdate } from "../store/actions"; +import { ReduxSagaEffect } from "../../../../types/utils"; +import { itwLifecycleIsValidSelector } from "../../lifecycle/store/selectors"; +import { walletAddCards } from "../../../newWallet/store/actions/cards"; +import { getCredentialStatus } from "../../common/utils/itwClaimsUtils"; + +const canGetStatusAttestation = (credential: StoredCredential) => + credential.credentialType === CredentialType.DRIVING_LICENSE; + +export function* updateCredentialStatusAttestationSaga( + credential: StoredCredential +): Generator { + try { + const { parsedStatusAttestation, statusAttestation } = yield* call( + getCredentialStatusAttestation, + credential + ); + return { + ...credential, + storedStatusAttestation: { + credentialStatus: "valid", + statusAttestation, + parsedStatusAttestation: parsedStatusAttestation.payload + } + }; + } catch (error) { + return { + ...credential, + storedStatusAttestation: { + credentialStatus: + error instanceof Errors.StatusAttestationInvalid + ? "invalid" // The credential was revoked + : "unknown" // We do not have enough information on the status, the error was unexpected + } + }; + } +} + +/** + * This saga is responsible to check the status attestation for each credential in the wallet. + */ +export function* checkCredentialsStatusAttestation() { + const isWalletValid = yield* select(itwLifecycleIsValidSelector); + + // Credentials can be requested only when the wallet is valid, i.e. the eID was issued + if (!isWalletValid) { + return; + } + + const { credentials } = yield* select(itwCredentialsSelector); + + const credentialsToCheck = pipe( + credentials, + RA.filterMap( + O.filter( + x => canGetStatusAttestation(x) && shouldRequestStatusAttestation(x) + ) + ) + ); + + if (credentialsToCheck.length === 0) { + return; + } + + const updatedCredentials = yield* all( + credentialsToCheck.map(credential => + call(updateCredentialStatusAttestationSaga, credential) + ) + ); + + yield* put(itwCredentialsMultipleUpdate(updatedCredentials)); + yield* put( + walletAddCards( + updatedCredentials.map(c => ({ + key: c.keyTag, + type: "itw", + category: "itw", + credentialType: c.credentialType, + status: getCredentialStatus(c) + })) + ) + ); +} diff --git a/ts/features/itwallet/credentials/saga/handleWalletCredentialsRehydration.ts b/ts/features/itwallet/credentials/saga/handleWalletCredentialsRehydration.ts index 9b7b0ef15fa..2cfd6b272db 100644 --- a/ts/features/itwallet/credentials/saga/handleWalletCredentialsRehydration.ts +++ b/ts/features/itwallet/credentials/saga/handleWalletCredentialsRehydration.ts @@ -8,6 +8,7 @@ import { WalletCard } from "../../../newWallet/types"; import { CredentialType } from "../../common/utils/itwMocksUtils"; import { walletAddCards } from "../../../newWallet/store/actions/cards"; import { itwLifecycleIsValidSelector } from "../../lifecycle/store/selectors"; +import { getCredentialStatus } from "../../common/utils/itwClaimsUtils"; const mapCredentialsToWalletCards = ( credentials: Array @@ -16,7 +17,8 @@ const mapCredentialsToWalletCards = ( key: credential.keyTag, type: "itw", category: "itw", - credentialType: credential.credentialType as CredentialType + credentialType: credential.credentialType as CredentialType, + status: getCredentialStatus(credential) })); /** diff --git a/ts/features/itwallet/credentials/store/actions/index.ts b/ts/features/itwallet/credentials/store/actions/index.ts index 12e97287484..0558bd99eac 100644 --- a/ts/features/itwallet/credentials/store/actions/index.ts +++ b/ts/features/itwallet/credentials/store/actions/index.ts @@ -9,6 +9,15 @@ export const itwCredentialsRemove = createStandardAction( "ITW_CREDENTIALS_REMOVE" )(); +/** + * This action updates multiple credentials using their type as key. + * The new credential completely overwrites the previous one. + */ +export const itwCredentialsMultipleUpdate = createStandardAction( + "ITW_CREDENTIALS_MULTIPLE_UPDATE" +)>(); + export type ItwCredentialsActions = | ActionType - | ActionType; + | ActionType + | ActionType; diff --git a/ts/features/itwallet/credentials/store/reducers/__tests__/index.test.ts b/ts/features/itwallet/credentials/store/reducers/__tests__/index.test.ts index cea8a655ac5..fb05b957d62 100644 --- a/ts/features/itwallet/credentials/store/reducers/__tests__/index.test.ts +++ b/ts/features/itwallet/credentials/store/reducers/__tests__/index.test.ts @@ -3,8 +3,15 @@ import { pipe } from "fp-ts/lib/function"; import { applicationChangeState } from "../../../../../../store/actions/application"; import { appReducer } from "../../../../../../store/reducers"; import { CredentialType } from "../../../../common/utils/itwMocksUtils"; -import { StoredCredential } from "../../../../common/utils/itwTypesUtils"; -import { itwCredentialsRemove, itwCredentialsStore } from "../../actions"; +import { + ParsedStatusAttestation, + StoredCredential +} from "../../../../common/utils/itwTypesUtils"; +import { + itwCredentialsMultipleUpdate, + itwCredentialsRemove, + itwCredentialsStore +} from "../../actions"; import { Action } from "../../../../../../store/actions/types"; import { GlobalState } from "../../../../../../store/reducers/types"; import { itwLifecycleStoresReset } from "../../../../lifecycle/store/actions"; @@ -31,6 +38,15 @@ const mockedCredential: StoredCredential = { issuerConf: {} as StoredCredential["issuerConf"] }; +const mockedCredential2: StoredCredential = { + credential: "", + credentialType: CredentialType.EUROPEAN_DISABILITY_CARD, + parsedCredential: {}, + format: "vc+sd-jwt", + keyTag: "07ccc69a-d1b5-4c3c-9955-6a436d0c3710", + issuerConf: {} as StoredCredential["issuerConf"] +}; + describe("ITW credentials reducer", () => { it("should add the eID", () => { const targetSate = pipe( @@ -106,4 +122,31 @@ describe("ITW credentials reducer", () => { credentials: [] }); }); + + it("should update selected credentials", () => { + const credentialUpdate: StoredCredential = { + ...mockedCredential2, + storedStatusAttestation: { + credentialStatus: "valid" as const, + statusAttestation: "abc", + parsedStatusAttestation: { exp: 1000 } as ParsedStatusAttestation + } + }; + + const updatedCredential = { ...mockedCredential2, ...credentialUpdate }; + + const targetSate = pipe( + undefined, + curriedAppReducer(applicationChangeState("active")), + curriedAppReducer(itwCredentialsStore(mockedEid)), + curriedAppReducer(itwCredentialsStore(mockedCredential)), + curriedAppReducer(itwCredentialsStore(mockedCredential2)), + curriedAppReducer(itwCredentialsMultipleUpdate([credentialUpdate])) + ); + + expect(targetSate.features.itWallet.credentials.credentials).toEqual([ + O.some(mockedCredential), + O.some(updatedCredential) + ]); + }); }); diff --git a/ts/features/itwallet/credentials/store/reducers/index.ts b/ts/features/itwallet/credentials/store/reducers/index.ts index d14d8ba5722..4b30dbd6c19 100644 --- a/ts/features/itwallet/credentials/store/reducers/index.ts +++ b/ts/features/itwallet/credentials/store/reducers/index.ts @@ -1,7 +1,11 @@ import * as O from "fp-ts/lib/Option"; import { getType } from "typesafe-actions"; import { Action } from "../../../../../store/actions/types"; -import { itwCredentialsRemove, itwCredentialsStore } from "../actions"; +import { + itwCredentialsRemove, + itwCredentialsStore, + itwCredentialsMultipleUpdate +} from "../actions"; import { StoredCredential } from "../../../common/utils/itwTypesUtils"; import { CredentialType } from "../../../common/utils/itwMocksUtils"; import { itwLifecycleStoresReset } from "../../../lifecycle/store/actions"; @@ -52,6 +56,23 @@ const reducer = ( }; } + case getType(itwCredentialsMultipleUpdate): { + const credentialsToUpdateByType = action.payload.reduce( + (acc, c) => ({ ...acc, [c.credentialType]: c }), + {} as { [K in CredentialType]?: StoredCredential } + ); + return { + ...state, + credentials: state.credentials.map( + O.map(c => { + const updatedCredential = + credentialsToUpdateByType[c.credentialType as CredentialType]; + return updatedCredential ?? c; + }) + ) + }; + } + case getType(itwLifecycleStoresReset): return { ...itwCredentialsInitialState }; diff --git a/ts/features/itwallet/presentation/components/ItwPresentationAlertsSection.tsx b/ts/features/itwallet/presentation/components/ItwPresentationAlertsSection.tsx index 70f6e62e12f..c4f58be6fb8 100644 --- a/ts/features/itwallet/presentation/components/ItwPresentationAlertsSection.tsx +++ b/ts/features/itwallet/presentation/components/ItwPresentationAlertsSection.tsx @@ -4,7 +4,7 @@ import { useIOBottomSheetAutoresizableModal } from "../../../../utils/hooks/bott import ItwMarkdown from "../../common/components/ItwMarkdown"; import { getCredentialExpireDays, - getCredentialExpireStatus + getCredentialStatus } from "../../common/utils/itwClaimsUtils"; import { CredentialType } from "../../common/utils/itwMocksUtils"; import { StoredCredential } from "../../common/utils/itwTypesUtils"; @@ -36,7 +36,7 @@ export const ItwPresentationAlertsSection = ({ credential }: Props) => { ) }); - const expireStatus = getCredentialExpireStatus(credential.parsedCredential); + const expireStatus = getCredentialStatus(credential); const expireDays = getCredentialExpireDays(credential.parsedCredential); const isExpired = expireStatus === "expired"; const isExpiring = expireStatus === "expiring"; diff --git a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx index 44a8d0e8fe2..e02761833ef 100644 --- a/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx +++ b/ts/features/itwallet/presentation/screens/ItwPresentationCredentialDetailScreen.tsx @@ -17,7 +17,6 @@ import { useIOSelector } from "../../../../store/hooks"; import { ItwCredentialCard } from "../../common/components/ItwCredentialCard"; import { ItwGenericErrorContent } from "../../common/components/ItwGenericErrorContent"; import { getHumanReadableParsedCredential } from "../../common/utils/debug"; -import { getCredentialExpireStatus } from "../../common/utils/itwClaimsUtils"; import { CredentialType } from "../../common/utils/itwMocksUtils"; import { getThemeColorByCredentialType } from "../../common/utils/itwStyleUtils"; import { StoredCredential } from "../../common/utils/itwTypesUtils"; @@ -26,6 +25,7 @@ import { ItwParamsList } from "../../navigation/ItwParamsList"; import { ItwPresentationAlertsSection } from "../components/ItwPresentationAlertsSection"; import { ItwPresentationClaimsSection } from "../components/ItwPresentationClaimsSection"; import { ItwPresentationDetailFooter } from "../components/ItwPresentationDetailFooter"; +import { getCredentialStatus } from "../../common/utils/itwClaimsUtils"; // TODO: use the real credential update time const today = new Date(); @@ -76,9 +76,7 @@ const ContentView = ({ credential }: { credential: StoredCredential }) => { ) }); - const credentialStatus = getCredentialExpireStatus( - credential.parsedCredential - ); + const credentialStatus = getCredentialStatus(credential); return ( <>