diff --git a/app/jest.setup.js b/app/jest.setup.js index 75f4b22b7..5a0f99656 100644 --- a/app/jest.setup.js +++ b/app/jest.setup.js @@ -207,17 +207,28 @@ jest.mock('react-native-nfc-manager', () => ({ })); // Mock react-native-passport-reader -jest.mock('react-native-passport-reader', () => ({ - default: { +jest.mock('react-native-passport-reader', () => { + const mockScanPassport = jest.fn(); + // Mock the parameter count for scanPassport (iOS native method takes 9 parameters) + Object.defineProperty(mockScanPassport, 'length', { value: 9 }); + + const mockPassportReader = { configure: jest.fn(), - scanPassport: jest.fn(), + scanPassport: mockScanPassport, readPassport: jest.fn(), cancelPassportRead: jest.fn(), trackEvent: jest.fn(), flush: jest.fn(), reset: jest.fn(), - }, -})); + }; + + return { + PassportReader: mockPassportReader, + default: mockPassportReader, + reset: jest.fn(), + scan: jest.fn(), + }; +}); const { NativeModules } = require('react-native'); diff --git a/app/src/mocks/react-native-passport-reader.ts b/app/src/mocks/react-native-passport-reader.ts index 1d8c943cb..f553299c1 100644 --- a/app/src/mocks/react-native-passport-reader.ts +++ b/app/src/mocks/react-native-passport-reader.ts @@ -6,17 +6,7 @@ // Mock PassportReader object with analytics methods export const PassportReader = { - configure: ( - token: string, - enableDebug?: boolean, - flushPolicies?: { - flushInterval?: number; - flushCount?: number; - flushOnBackground?: boolean; - flushOnForeground?: boolean; - flushOnNetworkChange?: boolean; - }, - ) => { + configure: (token: string, enableDebug?: boolean) => { // No-op for web return Promise.resolve(); }, @@ -32,7 +22,7 @@ export const PassportReader = { // No-op for web return Promise.resolve(); }, - scan: async () => { + scanPassport: async () => { throw new Error('NFC scanning is not supported on web'); }, }; diff --git a/app/src/providers/selfClientProvider.tsx b/app/src/providers/selfClientProvider.tsx index c54b75d30..09a12dc67 100644 --- a/app/src/providers/selfClientProvider.tsx +++ b/app/src/providers/selfClientProvider.tsx @@ -12,11 +12,10 @@ import { } from '@selfxyz/mobile-sdk-alpha'; import { TrackEventParams } from '@selfxyz/mobile-sdk-alpha'; +import { unsafe_getPrivateKey } from '@/providers/authProvider'; import { selfClientDocumentsAdapter } from '@/providers/passportDataProvider'; import analytics from '@/utils/analytics'; -import { unsafe_getPrivateKey } from './authProvider'; - /** * Provides a configured Self SDK client instance to all descendants. * diff --git a/app/src/types/react-native-passport-reader.d.ts b/app/src/types/react-native-passport-reader.d.ts index cca693342..3a347402d 100644 --- a/app/src/types/react-native-passport-reader.d.ts +++ b/app/src/types/react-native-passport-reader.d.ts @@ -13,21 +13,21 @@ declare module 'react-native-passport-reader' { } interface PassportReader { - configure( - token: string, - enableDebug?: boolean, - flushPolicies?: { - flushInterval?: number; - flushCount?: number; - flushOnBackground?: boolean; - flushOnForeground?: boolean; - flushOnNetworkChange?: boolean; - }, - ): void; + configure?(token: string, enableDebug?: boolean): void; trackEvent?(name: string, properties?: Record): void; flush?(): void; reset(): void; - scan(options: ScanOptions): Promise<{ + scanPassport( + passportNumber: string, + dateOfBirth: string, + dateOfExpiry: string, + canNumber: string, + useCan: boolean, + skipPACE: boolean, + skipCA: boolean, + extendedMode: boolean, + usePacePolling: boolean, + ): Promise<{ mrz: string; eContent: string; encryptedDigest: string; diff --git a/app/src/utils/analytics.ts b/app/src/utils/analytics.ts index 1bf345435..ad7b85a4b 100644 --- a/app/src/utils/analytics.ts +++ b/app/src/utils/analytics.ts @@ -200,18 +200,24 @@ const flushMixpanelEvents = async () => { // --- Mixpanel NFC Analytics --- export const configureNfcAnalytics = async () => { if (!MIXPANEL_NFC_PROJECT_TOKEN || mixpanelConfigured) return; - const enableDebugLogs = JSON.parse(String(ENABLE_DEBUG_LOGS)); - if (PassportReader.configure) { - await Promise.resolve( - PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs, { - flushInterval: 20, - flushCount: 5, - flushOnBackground: true, - flushOnForeground: true, - flushOnNetworkChange: true, - }), - ); + const enableDebugLogs = + String(ENABLE_DEBUG_LOGS ?? '') + .trim() + .toLowerCase() === 'true'; + + // Check if PassportReader and configure method exist (Android doesn't have configure) + if (PassportReader && typeof PassportReader.configure === 'function') { + try { + // iOS configure method only accepts token and enableDebugLogs + // Android doesn't have this method at all + await Promise.resolve( + PassportReader.configure(MIXPANEL_NFC_PROJECT_TOKEN, enableDebugLogs), + ); + } catch (error) { + console.warn('Failed to configure NFC analytics:', error); + } } + setupFlushPolicies(); mixpanelConfigured = true; }; diff --git a/app/src/utils/nfcScanner.ts b/app/src/utils/nfcScanner.ts index 5345cef8c..65cee6e9a 100644 --- a/app/src/utils/nfcScanner.ts +++ b/app/src/utils/nfcScanner.ts @@ -68,13 +68,17 @@ const scanAndroid = async (inputs: Inputs) => { const scanIOS = async (inputs: Inputs) => { return await Promise.resolve( - PassportReader.scan({ - documentNumber: inputs.passportNumber, - dateOfBirth: inputs.dateOfBirth, - dateOfExpiry: inputs.dateOfExpiry, - canNumber: inputs.canNumber ?? '', - useCan: inputs.useCan ?? false, - }), + PassportReader.scanPassport( + inputs.passportNumber, + inputs.dateOfBirth, + inputs.dateOfExpiry, + inputs.canNumber ?? '', + inputs.useCan ?? false, + inputs.skipPACE ?? false, + inputs.skipCA ?? false, + inputs.extendedMode ?? false, + inputs.usePacePolling ?? false, + ), ); }; diff --git a/app/src/utils/proving/provingMachine.ts b/app/src/utils/proving/provingMachine.ts index 8703df152..8b7827f29 100644 --- a/app/src/utils/proving/provingMachine.ts +++ b/app/src/utils/proving/provingMachine.ts @@ -183,6 +183,7 @@ interface ProvingState { endpointType: EndpointType | null; fcmToken: string | null; env: 'prod' | 'stg' | null; + selfClient: SelfClient | null; setFcmToken: (token: string) => void; init: ( selfClient: SelfClient, @@ -330,6 +331,7 @@ export const useProvingStore = create((set, get) => { reason: null, endpointType: null, fcmToken: null, + selfClient: null, setFcmToken: (token: string) => { set({ fcmToken: token }); trackEvent(ProofEvents.FCM_TOKEN_STORED); @@ -632,6 +634,7 @@ export const useProvingStore = create((set, get) => { circuitType, endpointType: null, env: null, + selfClient, }); actor = createActor(provingMachine); @@ -649,7 +652,7 @@ export const useProvingStore = create((set, get) => { const { data: passportData } = selectedDocument; - const secret = await selfClient.getPrivateKey(); + const secret = await get().selfClient?.getPrivateKey(); if (!secret) { console.error('Could not load secret'); trackEvent(ProofEvents.LOAD_SECRET_FAILED); diff --git a/app/tests/src/nativeModules/passportReader.simple.test.ts b/app/tests/src/nativeModules/passportReader.simple.test.ts new file mode 100644 index 000000000..39d6044d0 --- /dev/null +++ b/app/tests/src/nativeModules/passportReader.simple.test.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc. +// SPDX-License-Identifier: BUSL-1.1 +// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. + +/** + * Simple contract tests for PassportReader native module + * These tests verify critical interface requirements without conditional expects + */ + +import { PassportReader } from 'react-native-passport-reader'; + +describe('PassportReader Simple Contract Tests', () => { + describe('Critical Interface Requirements', () => { + it('should have scanPassport method (not scan)', () => { + // This prevents the iOS "scan is undefined" bug + expect(PassportReader.scanPassport).toBeDefined(); + expect(typeof PassportReader.scanPassport).toBe('function'); + }); + + it('should NOT have scan method', () => { + // This was the source of the iOS bug + expect((PassportReader as any).scan).toBeUndefined(); + }); + + it('should have reset method', () => { + // This should always exist + expect(PassportReader.reset).toBeDefined(); + expect(typeof PassportReader.reset).toBe('function'); + }); + + it('should have scanPassport with correct parameter count', () => { + // scanPassport should take exactly 9 parameters + expect(PassportReader.scanPassport.length).toBe(9); + }); + + it('should allow configure to be optional', () => { + // configure might not exist on Android - this should not crash + const configureType = typeof PassportReader.configure; + expect(['function', 'undefined']).toContain(configureType); + }); + + it('should allow trackEvent to be optional', () => { + // trackEvent might not exist on all platforms + const trackEventType = typeof PassportReader.trackEvent; + expect(['function', 'undefined']).toContain(trackEventType); + }); + + it('should allow flush to be optional', () => { + // flush might not exist on all platforms + const flushType = typeof PassportReader.flush; + expect(['function', 'undefined']).toContain(flushType); + }); + }); + + describe('Safe Method Calling Patterns', () => { + it('should be safe to check configure existence', () => { + // This pattern should never crash + expect(() => { + const hasConfigured = Boolean(PassportReader.configure); + return hasConfigured; + }).not.toThrow(); + }); + + it('should be safe to check trackEvent existence', () => { + // This pattern should never crash + expect(() => { + const hasTrackEvent = Boolean(PassportReader.trackEvent); + return hasTrackEvent; + }).not.toThrow(); + }); + + it('should be safe to check flush existence', () => { + // This pattern should never crash + expect(() => { + const hasFlush = Boolean(PassportReader.flush); + return hasFlush; + }).not.toThrow(); + }); + }); + + describe('Method Invocation Safety', () => { + it('should not crash when calling scanPassport', () => { + // Should be callable (might fail due to missing NFC, but should not crash due to undefined) + expect(() => { + PassportReader.scanPassport( + 'test', + 'test', + 'test', + 'test', + false, + false, + false, + false, + false, + ); + }).not.toThrow(TypeError); + }); + + it('should not crash when calling reset', () => { + // Should be callable + expect(() => { + PassportReader.reset(); + }).not.toThrow(TypeError); + }); + }); + + describe('Interface Consistency', () => { + it('should have consistent method naming', () => { + // Ensure we use the correct method names + expect(PassportReader.scanPassport).toBeDefined(); // ✅ Correct + expect((PassportReader as any).scan).toBeUndefined(); // ❌ Wrong (causes iOS crash) + }); + + it('should have proper method types', () => { + // All defined methods should be functions + expect(typeof PassportReader.reset).toBe('function'); + expect(typeof PassportReader.scanPassport).toBe('function'); + + // Optional methods should be function or undefined + expect(['function', 'undefined']).toContain( + typeof PassportReader.configure, + ); + expect(['function', 'undefined']).toContain( + typeof PassportReader.trackEvent, + ); + expect(['function', 'undefined']).toContain(typeof PassportReader.flush); + }); + }); +}); diff --git a/app/tests/utils/nfcScanner.test.ts b/app/tests/utils/nfcScanner.test.ts index e62e9b536..794d9330a 100644 --- a/app/tests/utils/nfcScanner.test.ts +++ b/app/tests/utils/nfcScanner.test.ts @@ -3,8 +3,15 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import { Platform } from 'react-native'; +import { PassportReader } from 'react-native-passport-reader'; -import { parseScanResponse } from '@/utils/nfcScanner'; +import { configureNfcAnalytics } from '@/utils/analytics'; +import { parseScanResponse, scan } from '@/utils/nfcScanner'; + +// Mock the analytics module +jest.mock('@/utils/analytics', () => ({ + configureNfcAnalytics: jest.fn().mockResolvedValue(undefined), +})); describe('parseScanResponse', () => { beforeEach(() => { @@ -123,3 +130,142 @@ describe('parseScanResponse', () => { expect(() => parseScanResponse(response)).toThrow(); }); }); + +describe('scan', () => { + const mockInputs = { + passportNumber: 'L898902C3', + dateOfBirth: '640812', + dateOfExpiry: '251031', + canNumber: '123456', + useCan: false, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('iOS platform', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + }); + + it('should call PassportReader.scanPassport with correct parameters', async () => { + const mockScanPassport = jest.fn().mockResolvedValue({ + mrz: 'test-mrz', + dataGroupHashes: JSON.stringify({}), + }); + + (PassportReader as any).scanPassport = mockScanPassport; + + await scan(mockInputs); + + expect(mockScanPassport).toHaveBeenCalledWith( + 'L898902C3', + '640812', + '251031', + '123456', + false, + false, // skipPACE + false, // skipCA + false, // extendedMode + false, // usePacePolling + ); + }); + + it('should handle missing optional parameters', async () => { + const mockScanPassport = jest.fn().mockResolvedValue({ + mrz: 'test-mrz', + dataGroupHashes: JSON.stringify({}), + }); + + (PassportReader as any).scanPassport = mockScanPassport; + + const minimalInputs = { + passportNumber: 'L898902C3', + dateOfBirth: '640812', + dateOfExpiry: '251031', + }; + + await scan(minimalInputs); + + expect(mockScanPassport).toHaveBeenCalledWith( + 'L898902C3', + '640812', + '251031', + '', // canNumber default + false, // useCan default + false, // skipPACE default + false, // skipCA default + false, // extendedMode default + false, // usePacePolling default + ); + }); + + it('should pass through all optional parameters when provided', async () => { + const mockScanPassport = jest.fn().mockResolvedValue({ + mrz: 'test-mrz', + dataGroupHashes: JSON.stringify({}), + }); + + (PassportReader as any).scanPassport = mockScanPassport; + + const fullInputs = { + ...mockInputs, + useCan: true, + skipPACE: true, + skipCA: true, + extendedMode: true, + usePacePolling: true, + }; + + await scan(fullInputs); + + expect(mockScanPassport).toHaveBeenCalledWith( + 'L898902C3', + '640812', + '251031', + '123456', + true, // useCan + true, // skipPACE + true, // skipCA + true, // extendedMode + true, // usePacePolling + ); + }); + }); + + // Note: Android testing would require mocking the imported scan function + // which is more complex in Jest. The interface tests handle this better. + + describe('Analytics configuration', () => { + beforeEach(() => { + Object.defineProperty(Platform, 'OS', { + value: 'ios', + writable: true, + }); + }); + + it('should configure analytics before scanning', async () => { + const mockScanPassport = jest.fn().mockResolvedValue({ + mrz: 'test-mrz', + dataGroupHashes: JSON.stringify({}), + }); + + const mockConfigureNfcAnalytics = + configureNfcAnalytics as jest.MockedFunction< + typeof configureNfcAnalytics + >; + + (PassportReader as any).scanPassport = mockScanPassport; + + await scan(mockInputs); + + // Should configure analytics before scanning + expect(mockConfigureNfcAnalytics).toHaveBeenCalled(); + expect(mockScanPassport).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/tests/utils/proving/provingMachine.startFetchingData.test.ts b/app/tests/utils/proving/provingMachine.startFetchingData.test.ts index 3ef8b3b03..6ab5f0186 100644 --- a/app/tests/utils/proving/provingMachine.startFetchingData.test.ts +++ b/app/tests/utils/proving/provingMachine.startFetchingData.test.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11 -import { useProtocolStore } from '../../../src/stores/protocolStore'; -import { useProvingStore } from '../../../src/utils/proving/provingMachine'; +import { useProtocolStore } from '@/stores/protocolStore'; +import { useProvingStore } from '@/utils/proving/provingMachine'; + import { actorMock } from './actorMock'; jest.mock('xstate', () => { @@ -23,13 +24,13 @@ jest.mock('xstate', () => { }; }); -jest.mock('../../../src/utils/analytics', () => () => ({ +jest.mock('@/utils/analytics', () => () => ({ trackEvent: jest.fn(), })); -jest.mock('../../../src/providers/passportDataProvider', () => ({ +jest.mock('@/providers/passportDataProvider', () => ({ loadSelectedDocument: jest.fn(), })); -jest.mock('../../../src/providers/authProvider', () => ({ +jest.mock('@/providers/authProvider', () => ({ unsafe_getPrivateKey: jest.fn(), })); @@ -38,7 +39,7 @@ describe('startFetchingData', () => { jest.clearAllMocks(); const { loadSelectedDocument, - } = require('../../../src/providers/passportDataProvider'); + } = require('@/providers/passportDataProvider'); loadSelectedDocument.mockResolvedValue({ data: { documentCategory: 'passport', @@ -46,14 +47,18 @@ describe('startFetchingData', () => { dsc_parsed: { authorityKeyIdentifier: 'key' }, }, }); - const { - unsafe_getPrivateKey, - } = require('../../../src/providers/authProvider'); + const { unsafe_getPrivateKey } = require('@/providers/authProvider'); unsafe_getPrivateKey.mockResolvedValue('secret'); + + // Create mock selfClient + const mockSelfClient = { + getPrivateKey: jest.fn().mockResolvedValue('mock-private-key'), + }; + useProtocolStore.setState({ passport: { fetch_all: jest.fn().mockResolvedValue(undefined) }, } as any); - await useProvingStore.getState().init('register'); + await useProvingStore.getState().init(mockSelfClient as any, 'register'); actorMock.send.mockClear(); useProtocolStore.setState({ passport: { fetch_all: jest.fn() },