Reorganize mobile app /src and /tests folders (#1357)

* Refactor mobile app utilities into new modules

* prettier

* update lock, feedback from codex

* fix path

* keep some files in utils

* fix tests

* update paths

* remove old docs

* cr feedback

* flatten inefficient paths

* better structure

* update test folder structure

* migrate images

* fix import

* fix Sentry path

* update ignore

* save wip migration

* more updates

* standardize component names

* rename assets

* fix linting

* add barrel exports. final refactor commit

* fix formatting

* fix nav bar

* reduce bundle size

* remove dupe license

* fix test

* fix merge issues

* add refactor doc so we can track what was imporoved

* cr feedback

* feedback
This commit is contained in:
Justin Hernandez
2025-11-20 17:56:44 -03:00
committed by GitHub
parent cadd7ae5b7
commit 551067a48e
270 changed files with 1545 additions and 725 deletions

View File

@@ -1,40 +0,0 @@
// 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.
/**
* @jest-environment node
*/
import { readFileSync } from 'fs';
import { join } from 'path';
describe('Android build.gradle Configuration', () => {
const gradlePath = join(__dirname, '../../android/app/build.gradle');
const rootGradlePath = join(__dirname, '../../android/build.gradle');
let gradleContent: string;
let rootGradleContent: string;
beforeAll(() => {
gradleContent = readFileSync(gradlePath, 'utf8');
rootGradleContent = readFileSync(rootGradlePath, 'utf8');
});
it('references SDK versions from the root project', () => {
expect(gradleContent).toMatch(
/minSdkVersion\s+rootProject\.ext\.minSdkVersion/,
);
expect(gradleContent).toMatch(
/targetSdkVersion\s+rootProject\.ext\.targetSdkVersion/,
);
});
it('sets the expected SDK version numbers', () => {
expect(rootGradleContent).toMatch(/minSdkVersion\s*=\s*24/);
expect(rootGradleContent).toMatch(/targetSdkVersion\s*=\s*36/);
});
it('includes Firebase messaging dependency', () => {
expect(gradleContent).toContain('com.google.firebase:firebase-messaging');
});
});

View File

@@ -1,185 +0,0 @@
// 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.
/**
* @jest-environment node
*/
import { readFileSync } from 'fs';
import { join } from 'path';
describe('Android Manifest Configuration', () => {
const manifestPath = join(
__dirname,
'../../android/app/src/main/AndroidManifest.xml',
);
let manifestContent: string;
beforeAll(() => {
// Read the manifest file
manifestContent = readFileSync(manifestPath, 'utf8');
});
describe('Critical Deeplink Configuration', () => {
it('should contain the redirect.self.xyz deeplink intent filter', () => {
// This is the configuration that was accidentally deleted
expect(manifestContent).toContain('android:host="redirect.self.xyz"');
expect(manifestContent).toContain('android:autoVerify="true"');
expect(manifestContent).toContain('android.intent.action.VIEW');
expect(manifestContent).toContain('android.intent.category.BROWSABLE');
expect(manifestContent).toContain('android:scheme="https"');
});
it('should have the deeplink intent filter in the MainActivity', () => {
// Ensure the deeplink is properly configured in the main activity
const mainActivityMatch = manifestContent.match(
/<activity[^>]*android:name="\.MainActivity"[^>]*>(.*?)<\/activity>/s,
);
expect(mainActivityMatch).toBeTruthy();
expect(mainActivityMatch![1]).toContain('redirect.self.xyz');
expect(mainActivityMatch![1]).toContain('android:autoVerify="true"');
});
});
describe('Firebase Configuration', () => {
it('should have Firebase Messaging Service configured', () => {
expect(manifestContent).toContain(
'com.google.firebase.messaging.FirebaseMessagingService',
);
expect(manifestContent).toContain('com.google.firebase.MESSAGING_EVENT');
});
it('should have Firebase metadata configurations', () => {
const firebaseMetaConfigs = [
'com.google.firebase.messaging.default_notification_channel_id',
'com.google.firebase.messaging.default_notification_icon',
'com.google.firebase.messaging.default_notification_color',
];
firebaseMetaConfigs.forEach(config => {
expect(manifestContent).toContain(`android:name="${config}"`);
});
});
it('should have Firebase service properly exported', () => {
// Firebase service should not be exported for security
const serviceMatch = manifestContent.match(
/<service[^>]*android:name="com\.google\.firebase\.messaging\.FirebaseMessagingService"[^>]*>/,
);
expect(serviceMatch).toBeTruthy();
expect(serviceMatch![0]).toContain('android:exported="false"');
});
});
describe('OAuth/AppAuth Configuration', () => {
it('should have AppAuth RedirectUriReceiverActivity configured', () => {
expect(manifestContent).toContain(
'net.openid.appauth.RedirectUriReceiverActivity',
);
expect(manifestContent).toContain('${appAuthRedirectScheme}');
expect(manifestContent).toContain('oauth2redirect');
});
it('should have OAuth activity properly exported', () => {
const oauthActivityMatch = manifestContent.match(
/<activity[^>]*android:name="net\.openid\.appauth\.RedirectUriReceiverActivity"[^>]*>/,
);
expect(oauthActivityMatch).toBeTruthy();
expect(oauthActivityMatch![0]).toContain('android:exported="true"');
});
});
describe('NFC Configuration', () => {
it('should have NFC permission', () => {
expect(manifestContent).toContain('android.permission.NFC');
});
it('should have NFC tech discovery metadata', () => {
expect(manifestContent).toContain('android.nfc.action.TECH_DISCOVERED');
expect(manifestContent).toContain('@xml/nfc_tech_filter');
});
});
describe('Required Permissions', () => {
const criticalPermissions = [
'android.permission.INTERNET',
'android.permission.CAMERA',
'android.permission.NFC',
'android.permission.VIBRATE',
'android.permission.POST_NOTIFICATIONS',
'android.permission.ACCESS_SURFACE_FLINGER',
'android.permission.RECEIVE_BOOT_COMPLETED',
];
criticalPermissions.forEach(permission => {
it(`should contain ${permission} permission`, () => {
expect(manifestContent).toContain(`android:name="${permission}"`);
});
});
});
describe('Main Activity Configuration', () => {
it('should have MainActivity properly configured', () => {
expect(manifestContent).toContain('android:name=".MainActivity"');
expect(manifestContent).toContain('android:exported="true"');
expect(manifestContent).toContain('android:launchMode="singleTop"');
// Orientation locks removed to support large screens
expect(manifestContent).not.toContain('android:screenOrientation');
});
it('should have main launcher intent filter', () => {
expect(manifestContent).toContain('android.intent.action.MAIN');
expect(manifestContent).toContain('android.intent.category.LAUNCHER');
});
it('should have proper config changes handled', () => {
const configChanges = [
'keyboard',
'keyboardHidden',
'orientation',
'screenLayout',
'screenSize',
'smallestScreenSize',
'uiMode',
];
configChanges.forEach(change => {
expect(manifestContent).toContain(change);
});
});
});
describe('Application Configuration', () => {
it('should have MainApplication configured', () => {
expect(manifestContent).toContain('android:name=".MainApplication"');
expect(manifestContent).toContain('android:largeHeap="true"');
expect(manifestContent).toContain('android:supportsRtl="true"');
});
it('should have proper theme and icons configured', () => {
expect(manifestContent).toContain('@style/AppTheme');
expect(manifestContent).toContain('@mipmap/ic_launcher');
});
});
describe('Manifest Structure Validation', () => {
it('should be valid XML structure', () => {
// Basic XML validation - ensure it has proper opening/closing tags
expect(manifestContent).toMatch(/^<manifest[^>]*>/);
expect(manifestContent).toContain('</manifest>');
expect(manifestContent).toContain('<application');
expect(manifestContent).toContain('</application>');
});
it('should have required namespaces', () => {
expect(manifestContent).toContain(
'xmlns:android="http://schemas.android.com/apk/res/android"',
);
expect(manifestContent).toContain(
'xmlns:tools="http://schemas.android.com/tools"',
);
});
});
});

View File

@@ -6,15 +6,15 @@ import type { ReactNode } from 'react';
import { render } from '@testing-library/react-native';
import ErrorBoundary from '@/components/ErrorBoundary';
import { captureException } from '@/Sentry';
import { flushAllAnalytics, trackNfcEvent } from '@/utils/analytics';
import { captureException } from '@/config/sentry';
import { flushAllAnalytics, trackNfcEvent } from '@/services/analytics';
jest.mock('@/utils/analytics', () => ({
jest.mock('@/services/analytics', () => ({
trackNfcEvent: jest.fn(),
flushAllAnalytics: jest.fn(),
}));
jest.mock('@/Sentry', () => ({
jest.mock('@/config/sentry', () => ({
captureException: jest.fn(),
}));
@@ -25,7 +25,6 @@ const MockText = ({
children?: ReactNode;
testID?: string;
}) => <mock-text testID={testID}>{children}</mock-text>;
const ProblemChild = () => {
throw new Error('boom');
};

View File

@@ -13,7 +13,7 @@ import {
getFeatureFlag,
getLocalOverrides,
setLocalOverride,
} from '@/RemoteConfig';
} from '@/config/remoteConfig';
// Mock AsyncStorage with a default export
jest.mock('@react-native-async-storage/async-storage', () => ({

View File

@@ -31,7 +31,7 @@ jest.mock('@/utils/modalCallbackRegistry', () => ({
registerModalCallbacks: jest.fn().mockReturnValue(1),
}));
jest.mock('@/utils/analytics', () => () => ({
jest.mock('@/services/analytics', () => () => ({
trackEvent: jest.fn(),
}));

View File

@@ -9,14 +9,14 @@ import { useSelfClient } from '@selfxyz/mobile-sdk-alpha';
import { useEarnPointsFlow } from '@/hooks/useEarnPointsFlow';
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
import useUserStore from '@/stores/userStore';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
import {
hasUserAnIdentityDocumentRegistered,
hasUserDoneThePointsDisclosure,
POINT_VALUES,
pointsSelfApp,
} from '@/utils/points';
} from '@/services/points';
import useUserStore from '@/stores/userStore';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
@@ -30,7 +30,7 @@ jest.mock('@/hooks/useRegisterReferral', () => ({
useRegisterReferral: jest.fn(),
}));
jest.mock('@/utils/points', () => ({
jest.mock('@/services/points', () => ({
hasUserAnIdentityDocumentRegistered: jest.fn(),
hasUserDoneThePointsDisclosure: jest.fn(),
pointsSelfApp: jest.fn(),

View File

@@ -6,13 +6,17 @@ import { useNavigation } from '@react-navigation/native';
import { act, renderHook } from '@testing-library/react-native';
import useHapticNavigation from '@/hooks/useHapticNavigation';
import { impactLight, impactMedium, selectionChange } from '@/utils/haptic';
import {
impactLight,
impactMedium,
selectionChange,
} from '@/integrations/haptics';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
jest.mock('@/utils/haptic', () => ({
jest.mock('@/integrations/haptics', () => ({
impactLight: jest.fn(),
impactMedium: jest.fn(),
selectionChange: jest.fn(),

View File

@@ -5,9 +5,9 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useRegisterReferral } from '@/hooks/useRegisterReferral';
import { recordReferralPointEvent } from '@/utils/points';
import { recordReferralPointEvent } from '@/services/points';
jest.mock('@/utils/points', () => ({
jest.mock('@/services/points', () => ({
recordReferralPointEvent: jest.fn(),
}));

View File

@@ -0,0 +1,294 @@
// 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.
// Mock Platform without requiring react-native to avoid memory issues
// Use a simple object that can be modified directly
import { Buffer } from 'buffer';
import { parseScanResponse, scan } from '@/integrations/nfc/nfcScanner';
import { PassportReader } from '@/integrations/nfc/passportReader';
const Platform = {
OS: 'ios', // Default to iOS
Version: 14,
};
jest.mock('react-native', () => ({
Platform,
}));
// Ensure the Node Buffer implementation is available to the module under test
global.Buffer = Buffer;
describe('parseScanResponse', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset Platform.OS to default before each test to prevent pollution
Platform.OS = 'ios';
});
it('parses iOS response', () => {
// Platform.OS is already mocked as 'ios' by default
const mrz =
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
const response = JSON.stringify({
dataGroupHashes: JSON.stringify({
DG1: { sodHash: 'abcd' },
DG2: { sodHash: '1234' },
}),
eContentBase64: Buffer.from('ec').toString('base64'),
signedAttributes: Buffer.from('sa').toString('base64'),
passportMRZ: mrz,
signatureBase64: Buffer.from([1, 2]).toString('base64'),
dataGroupsPresent: [1, 2],
passportPhoto: 'photo',
documentSigningCertificate: JSON.stringify({ PEM: 'CERT' }),
});
const result = parseScanResponse(response);
expect(result.mrz).toBe(mrz);
expect(result.documentType).toBe('passport');
// 'abcd' in hex: ab = 171, cd = 205
expect(result.dg1Hash).toEqual([171, 205]);
// '1234' in hex: 12 = 18, 34 = 52
expect(result.dg2Hash).toEqual([18, 52]);
});
it('parses Android response', () => {
// Temporarily override Platform.OS for this test
const originalOS = Platform.OS;
Platform.OS = 'android';
const mrz =
'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<L898902C<3UTO6908061F9406236ZE184226B<<<<<14';
const response = {
mrz,
eContent: JSON.stringify([4, 5]),
encryptedDigest: JSON.stringify([6, 7]),
encapContent: JSON.stringify([8, 9]),
documentSigningCertificate: 'CERT',
// Android format: '1' and '2' are hex strings, not arrays
dataGroupHashes: JSON.stringify({ '1': 'abcd', '2': '1234' }),
} as any;
const result = parseScanResponse(response);
expect(result.documentType).toBe('passport');
expect(result.mrz).toBe(mrz);
// 'abcd' in hex: ab = 171, cd = 205
expect(result.dg1Hash).toEqual([171, 205]);
// dg2Hash should be parsed from hex string '1234': 12 = 18, 34 = 52
expect(result.dg2Hash).toEqual([18, 52]);
expect(result.dgPresents).toEqual([1, 2]);
// Restore original value
Platform.OS = originalOS;
});
it('handles malformed iOS response', () => {
// Platform.OS is already mocked as 'ios' by default
const response = '{"invalid": "json"';
expect(() => parseScanResponse(response)).toThrow();
});
it('handles malformed Android response', () => {
const originalOS = Platform.OS;
Platform.OS = 'android';
const response = {
mrz: 'valid_mrz',
eContent: 'invalid_json_string',
dataGroupHashes: JSON.stringify({ '1': 'abcd' }),
};
expect(() => parseScanResponse(response)).toThrow();
// Restore original value
Platform.OS = originalOS;
});
it('handles missing required fields', () => {
// Platform.OS is already mocked as 'ios' by default
const response = JSON.stringify({
// Providing minimal data but missing critical passportMRZ field
dataGroupHashes: JSON.stringify({
DG1: { sodHash: '00' }, // Minimal valid hex
DG2: { sodHash: '00' }, // Minimal valid hex
}),
eContentBase64: Buffer.from('').toString('base64'),
signedAttributes: Buffer.from('').toString('base64'),
signatureBase64: Buffer.from('').toString('base64'),
dataGroupsPresent: [],
documentSigningCertificate: JSON.stringify({ PEM: 'CERT' }),
// Missing passportMRZ which should cause an error
});
expect(() => parseScanResponse(response)).toThrow();
});
it('handles invalid hex data in dataGroupHashes', () => {
// Platform.OS is already mocked as 'ios' by default
const response = JSON.stringify({
dataGroupHashes: JSON.stringify({
DG1: { sodHash: 'invalid_hex' },
}),
passportMRZ: 'valid_mrz',
});
expect(() => parseScanResponse(response)).toThrow();
});
});
describe('scan', () => {
const mockInputs = {
passportNumber: 'L898902C3',
dateOfBirth: '640812',
dateOfExpiry: '251031',
canNumber: '123456',
useCan: false,
sessionId: 'test-session',
};
beforeEach(() => {
jest.clearAllMocks();
// Reset Platform.OS to default before each test to prevent pollution
Platform.OS = 'ios';
// Reset PassportReader mock before each test
// The implementation checks for scanPassport property, so we need to ensure it exists
Object.defineProperty(PassportReader, 'scanPassport', {
writable: true,
configurable: true,
value: undefined,
});
});
describe('iOS platform', () => {
// Platform.OS is already mocked as 'ios' by default, no additional setup needed
it('should call PassportReader.scanPassport with correct parameters', async () => {
const mockScanPassport = jest.fn().mockResolvedValue({
mrz: 'test-mrz',
dataGroupHashes: JSON.stringify({}),
});
// Set the mock function directly on PassportReader
Object.defineProperty(PassportReader, 'scanPassport', {
writable: true,
configurable: true,
value: mockScanPassport,
});
await scan(mockInputs);
expect(mockScanPassport).toHaveBeenCalledWith(
'L898902C3',
'640812',
'251031',
'123456',
false,
false, // skipPACE
false, // skipCA
false, // extendedMode
false, // usePacePolling
'test-session',
);
});
it('should handle missing optional parameters', async () => {
const mockScanPassport = jest.fn().mockResolvedValue({
mrz: 'test-mrz',
dataGroupHashes: JSON.stringify({}),
});
Object.defineProperty(PassportReader, 'scanPassport', {
writable: true,
configurable: true,
value: mockScanPassport,
});
const minimalInputs = {
passportNumber: 'L898902C3',
dateOfBirth: '640812',
dateOfExpiry: '251031',
sessionId: 'test-session',
};
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
'test-session',
);
});
it('should pass through all optional parameters when provided', async () => {
const mockScanPassport = jest.fn().mockResolvedValue({
mrz: 'test-mrz',
dataGroupHashes: JSON.stringify({}),
});
Object.defineProperty(PassportReader, 'scanPassport', {
writable: true,
configurable: true,
value: 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
'test-session',
);
});
});
// 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', () => {
// Platform.OS is already mocked as 'ios' by default, no additional setup needed
it('should configure analytics before scanning', async () => {
const mockScanPassport = jest.fn().mockResolvedValue({
mrz: 'test-mrz',
dataGroupHashes: JSON.stringify({}),
});
Object.defineProperty(PassportReader, 'scanPassport', {
writable: true,
configurable: true,
value: mockScanPassport,
});
await scan(mockInputs);
expect(mockScanPassport).toHaveBeenCalled();
});
});
});

View File

@@ -7,7 +7,7 @@
* These tests verify critical interface requirements without conditional expects
*/
import { PassportReader } from '@/utils/passportReader';
import { PassportReader } from '@/integrations/nfc/passportReader';
describe('PassportReader Simple Contract Tests', () => {
describe('Critical Interface Requirements', () => {

View File

@@ -1,36 +0,0 @@
// 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.
/**
* @jest-environment node
*/
import { readFileSync } from 'fs';
import { join } from 'path';
describe('iOS Info.plist Configuration', () => {
const plistPath = join(__dirname, '../../ios/OpenPassport/Info.plist');
let plistContent: string;
beforeAll(() => {
plistContent = readFileSync(plistPath, 'utf8');
});
it('contains the proofofpassport URL scheme', () => {
const regex =
/<key>CFBundleURLSchemes<\/key>\s*<array>\s*<string>proofofpassport<\/string>/s;
expect(plistContent).toMatch(regex);
});
it('has NFC and camera usage descriptions', () => {
expect(plistContent).toContain('<key>NFCReaderUsageDescription</key>');
expect(plistContent).toContain('<key>NSCameraUsageDescription</key>');
});
it('lists required fonts', () => {
expect(plistContent).toContain('<string>Advercase-Regular.otf</string>');
expect(plistContent).toContain('<string>DINOT-Bold.otf</string>');
expect(plistContent).toContain('<string>DINOT-Medium.otf</string>');
});
});

View File

@@ -1,42 +0,0 @@
// 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.
/**
* @jest-environment node
*/
import { readFileSync } from 'fs';
import { join } from 'path';
describe('iOS project.pbxproj Configuration', () => {
const projectPath = join(
__dirname,
'../../ios/Self.xcodeproj/project.pbxproj',
);
let projectContent: string;
beforeAll(() => {
try {
projectContent = readFileSync(projectPath, 'utf8');
} catch (error) {
throw new Error(
`Failed to read iOS project file at ${projectPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
});
it('uses the correct bundle identifier', () => {
expect(projectContent).toMatch(
/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*com\.warroom\.proofofpassport;/,
);
});
it('has the expected development team set', () => {
expect(projectContent).toMatch(/DEVELOPMENT_TEAM\s*=\s*5B29R5LYHQ;/);
});
it('includes GoogleService-Info.plist in resources', () => {
expect(projectContent).toContain('GoogleService-Info.plist in Resources');
});
});

View File

@@ -0,0 +1,600 @@
// 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.
import type { SelfClient } from '@selfxyz/mobile-sdk-alpha';
import {
handleUrl,
parseAndValidateUrlParams,
setupUniversalLinkListenerInNavigation,
} from '@/navigation/deeplinks';
jest.mock('react-native', () => {
const mockLinking = {
addEventListener: jest.fn(),
getInitialURL: jest.fn(),
};
return {
Linking: mockLinking,
Platform: { OS: 'ios' },
};
});
const mockLinking = jest.requireMock('react-native').Linking as jest.Mocked<{
addEventListener: jest.Mock;
getInitialURL: jest.Mock;
}>;
const mockPlatform = jest.requireMock('react-native').Platform as {
OS: string;
};
jest.mock('@/navigation', () => ({
navigationRef: {
navigate: jest.fn(),
isReady: jest.fn(() => true),
reset: jest.fn(),
},
}));
jest.mock('@/stores/userStore', () => {
const mockUserStore = { default: { getState: jest.fn() } };
return {
__esModule: true,
...mockUserStore,
};
});
const mockUserStore = jest.requireMock('@/stores/userStore') as {
default: { getState: jest.Mock };
};
let setDeepLinkUserDetails: jest.Mock;
describe('deeplinks', () => {
beforeEach(() => {
jest.clearAllMocks();
setDeepLinkUserDetails = jest.fn();
mockLinking.getInitialURL.mockReset();
mockLinking.addEventListener.mockReset();
mockLinking.getInitialURL.mockResolvedValue(null as any);
mockLinking.addEventListener.mockReturnValue({ remove: jest.fn() } as any);
mockUserStore.default.getState.mockReturnValue({
setDeepLinkUserDetails,
});
mockPlatform.OS = 'ios';
});
describe('handleUrl', () => {
it('handles selfApp parameter', () => {
const selfApp = { sessionId: 'abc' };
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(selfApp))}`;
const mockSetSelfApp = jest.fn();
const mockStartAppListener = jest.fn();
handleUrl(
{
getSelfAppState: () => ({
setSelfApp: mockSetSelfApp,
startAppListener: mockStartAppListener,
}),
} as unknown as SelfClient,
url,
);
expect(mockSetSelfApp).toHaveBeenCalledWith(selfApp);
expect(mockStartAppListener).toHaveBeenCalledWith('abc');
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
});
it('handles sessionId parameter', () => {
const url = 'scheme://open?sessionId=123';
const mockCleanSelfApp = jest.fn();
const mockStartAppListener = jest.fn();
handleUrl(
{
getSelfAppState: () => ({
setSelfApp: jest.fn(),
startAppListener: mockStartAppListener,
cleanSelfApp: mockCleanSelfApp,
}),
} as unknown as SelfClient,
url,
);
expect(mockCleanSelfApp).toHaveBeenCalledWith();
expect(mockStartAppListener).toHaveBeenCalledWith('123');
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).toHaveBeenCalledWith('Prove');
});
it('handles mock_passport parameter', () => {
const mockData = { name: 'John', surname: 'Doe' };
const url = `scheme://open?mock_passport=${encodeURIComponent(JSON.stringify(mockData))}`;
handleUrl({} as SelfClient, url);
expect(setDeepLinkUserDetails).toHaveBeenCalledWith({
name: 'John',
surname: 'Doe',
nationality: undefined,
birthDate: undefined,
gender: undefined,
});
const { navigationRef } = require('@/navigation');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'MockDataDeepLink' }],
});
});
it('handles referrer parameter and navigates to HomeScreen for confirmation', () => {
const referrer = '0x1234567890123456789012345678901234567890';
const url = `scheme://open?referrer=${referrer}`;
const mockSetDeepLinkReferrer = jest.fn();
mockUserStore.default.getState.mockReturnValue({
setDeepLinkReferrer: mockSetDeepLinkReferrer,
});
handleUrl({} as SelfClient, url);
expect(mockSetDeepLinkReferrer).toHaveBeenCalledWith(referrer);
const { navigationRef } = require('@/navigation');
// Should navigate to HomeScreen, which will show confirmation modal
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 0,
routes: [{ name: 'Home' }],
});
});
it('navigates to QRCodeTrouble for invalid data', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'scheme://open?selfApp=%7Binvalid';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error parsing selfApp:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('handles sessionId with invalid characters', () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'scheme://open?sessionId=abc<script>alert("xss")</script>';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Parameter sessionId failed validation:',
'abc<script>alert("xss")</script>',
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'No sessionId, selfApp or valid OAuth parameters found in the data',
);
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it('rejects URLs with malformed parameters', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'scheme://open?sessionId=%ZZ'; // Invalid URL encoding
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
consoleErrorSpy.mockRestore();
});
it('handles valid Turnkey OAuth redirect with code and state', () => {
const consoleLogSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
// Turnkey OAuth should return silently without navigation
expect(navigationRef.navigate).not.toHaveBeenCalled();
expect(navigationRef.reset).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Deeplinks] Turnkey OAuth redirect received with valid parameters',
);
consoleLogSpy.mockRestore();
});
it('navigates to QRCodeTrouble when only code is present (missing id_token)', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
// With just code and id_token validation removed, this should be accepted as valid OAuth
expect(navigationRef.navigate).not.toHaveBeenCalled();
expect(navigationRef.reset).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
it('handles valid Turnkey OAuth with only id_token (implicit flow)', () => {
const consoleLogSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.navigate).not.toHaveBeenCalled();
expect(navigationRef.reset).not.toHaveBeenCalled();
expect(consoleLogSpy).toHaveBeenCalledWith(
'[Deeplinks] Turnkey OAuth redirect received with valid parameters',
);
consoleLogSpy.mockRestore();
});
it('navigates to QRCodeTrouble when neither code nor id_token is present', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#scope=email%20profile';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
expect(navigationRef.reset).toHaveBeenCalledWith({
index: 1,
routes: [{ name: 'Home' }, { name: 'QRCodeTrouble' }],
});
consoleErrorSpy.mockRestore();
});
it('rejects Turnkey OAuth with invalid id_token format', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// id_token with invalid characters (XSS attempt) - should be rejected
// code is valid, but since id_token is invalid and rejected, code alone shouldn't trigger OAuth
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93&id_token=<script>alert("xss")</script>';
handleUrl({} as SelfClient, url);
const { navigationRef } = require('@/navigation');
// Code without valid id_token should still be accepted as valid OAuth (authorization code flow)
// So this should NOT navigate to QRCodeTrouble
expect(navigationRef.navigate).not.toHaveBeenCalled();
expect(navigationRef.reset).not.toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('parseAndValidateUrlParams', () => {
it('returns valid sessionId parameter', () => {
const url = 'scheme://open?sessionId=abc123';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ sessionId: 'abc123' });
});
it('returns valid selfApp parameter', () => {
const selfApp = { sessionId: 'abc' };
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(selfApp))}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ selfApp: JSON.stringify(selfApp) });
});
it('returns valid mock_passport parameter', () => {
const mockData = { name: 'John', surname: 'Doe' };
const url = `scheme://open?mock_passport=${encodeURIComponent(JSON.stringify(mockData))}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ mock_passport: JSON.stringify(mockData) });
});
it('filters out unexpected parameters', () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const url =
'scheme://open?sessionId=abc123&maliciousParam=evil&anotherBad=param';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ sessionId: 'abc123' });
// Check both warnings were called, regardless of order
expect(consoleWarnSpy).toHaveBeenCalledTimes(2);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unexpected or invalid parameter ignored: maliciousParam',
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unexpected or invalid parameter ignored: anotherBad',
);
consoleWarnSpy.mockRestore();
});
it('rejects sessionId with invalid characters', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'scheme://open?sessionId=abc<script>alert("xss")</script>';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Parameter sessionId failed validation:',
'abc<script>alert("xss")</script>',
);
consoleErrorSpy.mockRestore();
});
it('handles URL-encoded characters correctly', () => {
const sessionId = 'abc-123_TEST';
const url = `scheme://open?sessionId=${encodeURIComponent(sessionId)}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ sessionId });
});
it('handles complex JSON in selfApp parameter', () => {
const complexSelfApp = {
sessionId: 'abc123',
nested: { data: 'value', numbers: [1, 2, 3] },
special: 'chars with spaces and symbols',
};
const url = `scheme://open?selfApp=${encodeURIComponent(JSON.stringify(complexSelfApp))}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ selfApp: JSON.stringify(complexSelfApp) });
});
it('handles malformed URL encoding gracefully', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'scheme://open?sessionId=%ZZ'; // Invalid URL encoding
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error decoding parameter sessionId:',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
it('ignores empty parameter values', () => {
const url = 'scheme://open?sessionId=&selfApp=validValue';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ selfApp: 'validValue' });
});
it('handles duplicate keys correctly', () => {
// Test what actually happens with duplicate keys in query-string library
const url = 'scheme://open?sessionId=valid1&sessionId=valid2';
const result = parseAndValidateUrlParams(url);
// query-string typically handles duplicates by taking the last value or creating an array
// We'll accept either a valid sessionId or empty object if it creates an array
expect(
result.sessionId === undefined || typeof result.sessionId === 'string',
).toBe(true);
});
it('handles completely malformed URLs', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const url = 'not-a-valid-url-at-all';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
consoleErrorSpy.mockRestore();
});
it('handles URLs with no query parameters', () => {
const url = 'scheme://open';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
});
it('handles URLs with empty query string', () => {
const url = 'scheme://open?';
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
});
it('validates sessionId with allowed characters', () => {
const validSessionIds = [
'abc123',
'ABC_123',
'test-value',
'123456789',
'a_b-c_123',
];
validSessionIds.forEach(sessionId => {
const url = `scheme://open?sessionId=${sessionId}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({ sessionId });
});
});
it('rejects sessionId with disallowed characters', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
const invalidSessionIds = [
'abc@123',
'test value',
'test#value',
'test$%^&*()',
];
invalidSessionIds.forEach(sessionId => {
const url = `scheme://open?sessionId=${encodeURIComponent(sessionId)}`;
const result = parseAndValidateUrlParams(url);
expect(result).toEqual({});
});
consoleErrorSpy.mockRestore();
});
it('handles non-string parameter values', () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
// This might happen if query-string returns an array for duplicate keys
const mockParseUrl = jest.fn().mockReturnValue({
query: { sessionId: ['value1', 'value2'] },
});
// Temporarily mock the parseUrl import
jest.doMock('query-string', () => ({ parseUrl: mockParseUrl }));
// Re-require to get the mocked version
jest.resetModules();
const {
parseAndValidateUrlParams: mockedParser,
} = require('@/navigation/deeplinks');
const url = 'scheme://open?sessionId=duplicate&sessionId=values';
const result = mockedParser(url);
expect(result).toEqual({});
consoleWarnSpy.mockRestore();
});
});
describe('Turnkey OAuth parameter validation', () => {
it('returns valid code and state parameters', () => {
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7&state=state_abc';
const result = parseAndValidateUrlParams(url);
expect(result.code).toBe('4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7');
expect(result.state).toBe('state_abc');
});
it('returns id_token and scope parameters', () => {
const url =
'https://redirect.self.xyz?scheme=https#id_token=eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMDQwMTAwODA2NDc2NTA5MzU5MzgiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.signature&scope=email%20profile';
const result = parseAndValidateUrlParams(url);
expect(result.id_token).toBeTruthy();
expect(result.scope).toBe('email profile');
});
it('handles code with forward slashes (Google OAuth format)', () => {
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7CMFt3YS0RKh9yreKIqdMg4qZh6MaIkfonjNlJFw';
const result = parseAndValidateUrlParams(url);
expect(result.code).toBe(
'4/0Ab32j93MfuUU-vJKJth_t0fnnPkg1O7CMFt3YS0RKh9yreKIqdMg4qZh6MaIkfonjNlJFw',
);
});
it('rejects id_token with invalid characters (XSS attempt)', () => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// URL with only an invalid id_token - this should reject the id_token
const url =
'https://redirect.self.xyz#id_token=<script>alert("xss")</script>';
const result = parseAndValidateUrlParams(url);
// The invalid id_token should be rejected
expect(result.id_token).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Parameter id_token failed validation:',
expect.any(String),
);
consoleErrorSpy.mockRestore();
});
it('filters out unexpected OAuth-related parameters', () => {
const consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
const url =
'https://redirect.self.xyz?scheme=https#code=4/0Ab32j93&state=state_abc&error=access_denied&error_description=user_denied';
const result = parseAndValidateUrlParams(url);
expect(result.code).toBe('4/0Ab32j93');
expect(result.state).toBe('state_abc');
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unexpected or invalid parameter ignored: error',
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Unexpected or invalid parameter ignored: error_description',
);
consoleWarnSpy.mockRestore();
});
});
it('setup listener registers and cleans up', () => {
const remove = jest.fn();
mockLinking.getInitialURL.mockResolvedValue(undefined as any);
mockLinking.addEventListener.mockReturnValue({ remove });
const cleanup = setupUniversalLinkListenerInNavigation();
expect(mockLinking.addEventListener).toHaveBeenCalled();
cleanup();
expect(remove).toHaveBeenCalled();
});
});

View File

@@ -11,15 +11,15 @@ import { useEffect } from 'react';
import { render, screen } from '@testing-library/react-native';
import { LoggerProvider, useLogger } from '@/providers/loggerProvider';
import { AppLogger, NfcLogger } from '@/utils/logger';
import { AppLogger, NfcLogger } from '@/services/logging';
// Mock the native logger bridge
jest.mock('@/utils/logger/nativeLoggerBridge', () => ({
jest.mock('@/services/logging/logger/nativeLoggerBridge', () => ({
cleanup: jest.fn(),
}));
// Mock the logger utilities
jest.mock('@/utils/logger', () => ({
jest.mock('@/services/logging', () => ({
AppLogger: {
debug: jest.fn(),
info: jest.fn(),

View File

@@ -18,7 +18,7 @@ import {
usePassport,
} from '@/providers/passportDataProvider';
import { mockAdapters } from '../../utils/selfClientProvider';
import { mockAdapters } from '../../__setup__/selfClientProvider';
const listeners = new Map();

View File

@@ -5,14 +5,14 @@
import type { ReactNode } from 'react';
import { render, waitFor } from '@testing-library/react-native';
import { initRemoteConfig } from '@/config/remoteConfig';
import {
RemoteConfigProvider,
useRemoteConfig,
} from '@/providers/remoteConfigProvider';
import { initRemoteConfig } from '@/RemoteConfig';
// Mock the RemoteConfig module
jest.mock('@/RemoteConfig', () => ({
jest.mock('@/config/remoteConfig', () => ({
initRemoteConfig: jest.fn(),
}));

View File

@@ -6,8 +6,8 @@
import type { ReactNode } from 'react';
import { renderHook } from '@testing-library/react-native';
jest.mock('@/images/512w.png', () => 'mock-512w-image');
jest.mock('@/images/nfc.png', () => 'mock-nfc-image');
jest.mock('@/assets/images/512w.png', () => 'mock-512w-image');
jest.mock('@/assets/images/nfc.png', () => 'mock-nfc-image');
jest.mock('react-native-localize', () => {
const getLocales = jest.fn(() => [
{

View File

@@ -0,0 +1,231 @@
// 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.
import type { ProvingStateType } from '@selfxyz/mobile-sdk-alpha';
import {
getLoadingScreenText,
getProvingTimeEstimate,
} from '@/proving/loadingScreenStateText';
describe('stateLoadingScreenText', () => {
// Default values for basic tests
const defaultSignatureAlgorithm = 'RSA';
const defaultCurveOrExponent = '';
const defaultType = 'register' as const;
// Helper function to test a state has a response
const testStateHasResponse = (state: ProvingStateType) => {
it(`should return a response for ${state} state`, () => {
const result = getLoadingScreenText(
state,
defaultSignatureAlgorithm,
defaultCurveOrExponent,
defaultType,
);
expect(result).toBeDefined();
expect(result.actionText).toBeDefined();
expect(result.actionText.length).toBeGreaterThan(0);
expect(result.estimatedTime).toBeDefined();
expect(result.estimatedTime.length).toBeGreaterThan(0);
});
};
// Test all possible states
const states: ProvingStateType[] = [
'account_recovery_choice',
'completed',
'error',
'failure',
'fetching_data',
'idle',
'init_tee_connexion',
'listening_for_status',
'passport_data_not_found',
'passport_not_supported',
'post_proving',
'proving',
'ready_to_prove',
'validating_document',
];
describe('All states should have a response', () => {
states.forEach(state => {
testStateHasResponse(state);
});
});
// Test edge cases
describe('Edge cases', () => {
it('should handle undefined state', () => {
const result = getLoadingScreenText(
undefined as ProvingStateType,
defaultSignatureAlgorithm,
defaultCurveOrExponent,
defaultType,
);
expect(result).toBeDefined();
expect(result.actionText).toBeDefined();
expect(result.estimatedTime).toBeDefined();
});
it('should handle unknown state', () => {
const result = getLoadingScreenText(
'unknown' as ProvingStateType,
defaultSignatureAlgorithm,
defaultCurveOrExponent,
defaultType,
);
expect(result).toBeDefined();
expect(result.actionText).toBeDefined();
expect(result.estimatedTime).toBeDefined();
});
it('should handle undefined metadata', () => {
const result = getLoadingScreenText('proving', '', '', defaultType);
expect(result).toBeDefined();
expect(result.actionText).toBeDefined();
expect(result.estimatedTime).toBe('30 - 90 SECONDS'); // Should use default time estimate
});
});
describe('getLoadingScreenText with passport metadata', () => {
const rsaSignatureAlgorithm = 'RSA';
const rsaCurveOrExponent = '65537';
it('should use algorithm information to estimate proving time', () => {
const result = getLoadingScreenText(
'proving',
rsaSignatureAlgorithm,
rsaCurveOrExponent,
defaultType,
);
// Should use RSA (4 SECONDS)
expect(result.estimatedTime).toBe('4 SECONDS');
});
});
describe('getProvingTimeEstimate', () => {
it('should return default time when parameters are undefined', () => {
const result = getProvingTimeEstimate('', '', 'register');
expect(result).toBe('30 - 90 SECONDS');
});
describe('RSA algorithms', () => {
it.each([
['RSA', '65537', 'register', '4 SECONDS'], // Common RSA exponent
['RSA', '3', 'register', '4 SECONDS'], // Another common RSA exponent
['RSA', '65537', 'dsc', '2 SECONDS'], // DSC proof
['RSA', '3', 'dsc', '2 SECONDS'], // DSC proof
])(
'should return correct time for %s with exponent %s and type %s',
(algorithm, exponent, type, expectedTime) => {
const result = getProvingTimeEstimate(
algorithm,
exponent,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
it.each([
['RSAPSS', '65537', 'register', '6 SECONDS'],
['RSAPSS', '3', 'register', '6 SECONDS'],
['RSAPSS', '65537', 'dsc', '3 SECONDS'],
['RSAPSS', '3', 'dsc', '3 SECONDS'],
])(
'should return correct time for %s with exponent %s and type %s',
(algorithm, exponent, type, expectedTime) => {
const result = getProvingTimeEstimate(
algorithm,
exponent,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
});
describe('ECDSA curves', () => {
it.each([
['secp224r1', 'register', '50 SECONDS'],
['brainpoolP224r1', 'register', '50 SECONDS'],
['secp224r1', 'dsc', '25 SECONDS'],
['brainpoolP224r1', 'dsc', '25 SECONDS'],
])(
'should return correct time for 224-bit curve %s with type %s',
(curve, type, expectedTime) => {
const result = getProvingTimeEstimate(
'ECDSA',
curve,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
it.each([
['secp256r1', 'register', '50 SECONDS'],
['brainpoolP256r1', 'register', '50 SECONDS'],
['secp256r1', 'dsc', '25 SECONDS'],
['brainpoolP256r1', 'dsc', '25 SECONDS'],
])(
'should return correct time for 256-bit curve %s with type %s',
(curve, type, expectedTime) => {
const result = getProvingTimeEstimate(
'ECDSA',
curve,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
it.each([
['secp384r1', 'register', '90 SECONDS'],
['brainpoolP384r1', 'register', '90 SECONDS'],
['secp384r1', 'dsc', '45 SECONDS'],
['brainpoolP384r1', 'dsc', '45 SECONDS'],
])(
'should return correct time for 384-bit curve %s with type %s',
(curve, type, expectedTime) => {
const result = getProvingTimeEstimate(
'ECDSA',
curve,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
it.each([
['secp521r1', 'register', '200 SECONDS'],
['brainpoolP512r1', 'register', '200 SECONDS'],
['secp521r1', 'dsc', '100 SECONDS'],
['brainpoolP512r1', 'dsc', '100 SECONDS'],
])(
'should return correct time for 512/521-bit curve %s with type %s',
(curve, type, expectedTime) => {
const result = getProvingTimeEstimate(
'ECDSA',
curve,
type as 'dsc' | 'register',
);
expect(result).toBe(expectedTime);
},
);
});
it('should return default time when algorithm is not recognized', () => {
const result = getProvingTimeEstimate(
'UNKNOWN_ALGORITHM',
'',
'register',
);
expect(result).toBe('30 - 90 SECONDS');
});
});
});

View File

@@ -0,0 +1,86 @@
// 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.
import forge from 'node-forge';
import { encryptAES256GCM, getPayload, getWSDbRelayerUrl } from '@/proving';
describe('provingUtils', () => {
it('encryptAES256GCM encrypts and decrypts correctly', () => {
const key = forge.random.getBytesSync(32);
const plaintext = 'hello world';
const encrypted = encryptAES256GCM(plaintext, forge.util.createBuffer(key));
const decipher = forge.cipher.createDecipher(
'AES-GCM',
forge.util.createBuffer(key),
);
decipher.start({
iv: forge.util.createBuffer(
Buffer.from(encrypted.nonce).toString('binary'),
),
tagLength: 128,
tag: forge.util.createBuffer(
Buffer.from(encrypted.auth_tag).toString('binary'),
),
});
decipher.update(
forge.util.createBuffer(
Buffer.from(encrypted.cipher_text).toString('binary'),
),
);
const success = decipher.finish();
const decrypted = decipher.output.toString();
expect(success).toBe(true);
expect(decrypted).toBe(plaintext);
});
it('getPayload returns disclose payload', () => {
const inputs = { foo: 'bar' };
const payload = getPayload(
inputs,
'disclose',
'vc_and_disclose',
'https',
'https://example.com',
2,
'0xabc',
);
expect(payload).toEqual({
type: 'disclose',
endpointType: 'https',
endpoint: 'https://example.com',
onchain: false,
circuit: { name: 'vc_and_disclose', inputs: JSON.stringify(inputs) },
version: 2,
userDefinedData: '0xabc',
selfDefinedData: '',
});
});
it('getPayload returns register payload', () => {
const payload = getPayload(
{ a: 1 },
'register',
'register_circuit',
'celo',
'https://self.xyz',
);
expect(payload).toEqual({
type: 'register',
onchain: true,
endpointType: 'celo',
circuit: { name: 'register_circuit', inputs: JSON.stringify({ a: 1 }) },
});
});
it('getWSDbRelayerUrl handles endpoint types', () => {
expect(getWSDbRelayerUrl('celo')).toContain('websocket.self.xyz');
expect(getWSDbRelayerUrl('https')).toContain('websocket.self.xyz');
expect(getWSDbRelayerUrl('staging_celo')).toContain(
'websocket.staging.self.xyz',
);
});
});

View File

@@ -10,20 +10,18 @@ import { DocumentEvents } from '@selfxyz/mobile-sdk-alpha/constants/analytics';
import {
checkAndUpdateRegistrationStates,
getAlternativeCSCA,
} from '@/utils/proving/validateDocument';
} from '@/proving/validateDocument';
import analytics from '@/services/analytics';
// Mock the analytics module to avoid side effects in tests
jest.mock('@/utils/analytics', () => {
jest.mock('@/services/analytics', () => {
// Create mock inside factory to avoid temporal dead zone
const mockTrackEvent = jest.fn();
return () => ({
return jest.fn(() => ({
trackEvent: mockTrackEvent,
});
}));
});
const mockTrackEvent = jest.requireMock('@/utils/analytics')()
.trackEvent as jest.Mock;
// Mock the passport data provider to avoid database operations
const mockGetAllDocumentsDirectlyFromKeychain = jest.fn();
const mockLoadSelectedDocumentDirectlyFromKeychain = jest.fn();
@@ -148,9 +146,15 @@ jest.mock('@selfxyz/common/utils/passports/validate', () => ({
),
}));
// Get reference to the mocked trackEvent function
let mockTrackEvent: jest.Mock;
describe('getAlternativeCSCA', () => {
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked trackEvent from the analytics module
const mockAnalytics = jest.mocked(analytics);
mockTrackEvent = mockAnalytics().trackEvent as jest.Mock;
});
it('should return public keys in Record format for Aadhaar with valid public keys', () => {
@@ -240,6 +244,10 @@ describe('checkAndUpdateRegistrationStates', () => {
beforeEach(() => {
jest.clearAllMocks();
// Get the mocked trackEvent from the analytics module
const mockAnalytics = jest.mocked(analytics);
mockTrackEvent = mockAnalytics().trackEvent as jest.Mock;
mockGetState.mockReturnValue(
buildState({
passportAlt: { csca1: 'cert1' },

View File

@@ -96,8 +96,8 @@ jest.mock('@selfxyz/mobile-sdk-alpha/components', () => ({
),
}));
jest.mock('@/images/icons/arrow_left.svg', () => 'ArrowLeft');
jest.mock('@/images/icons/logo_white.svg', () => 'LogoWhite');
jest.mock('@/assets/icons/arrow_left.svg', () => 'ArrowLeft');
jest.mock('@/assets/icons/logo_white.svg', () => 'LogoWhite');
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation

View File

@@ -48,7 +48,7 @@ jest.mock('@react-navigation/native', () => ({
useFocusEffect: jest.fn(),
}));
jest.mock('@/components/NavBar/WebViewNavBar', () => ({
jest.mock('@/components/navbar/WebViewNavBar', () => ({
WebViewNavBar: ({ children, onBackPress, ...props }: any) => (
<mock-webview-navbar {...props}>
<mock-pressable testID="icon-x" onPress={onBackPress} />

View File

@@ -0,0 +1,234 @@
// 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.
import analytics from '@/services/analytics';
// Mock the Segment client
jest.mock('@/config/segment', () => ({
createSegmentClient: jest.fn(() => ({
track: jest.fn(),
screen: jest.fn(),
})),
}));
describe('analytics', () => {
const { trackEvent, trackScreenView } = analytics();
beforeEach(() => {
jest.clearAllMocks();
});
describe('trackEvent', () => {
it('should handle basic event tracking without properties', () => {
expect(() => trackEvent('test_event')).not.toThrow();
});
it('should handle event tracking with valid properties', () => {
const properties = {
reason: 'test_reason',
duration_seconds: 1.5,
attempt_count: 3,
string_prop: 'test',
number_prop: 42,
boolean_prop: true,
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle event tracking with null properties', () => {
expect(() => trackEvent('test_event', null)).not.toThrow();
});
it('should handle event tracking with undefined properties', () => {
expect(() => trackEvent('test_event', undefined)).not.toThrow();
});
it('should filter out non-JSON-compatible values', () => {
const properties = {
valid_string: 'test',
valid_number: 42,
valid_boolean: true,
valid_null: null,
function_prop: () => {},
undefined_prop: undefined,
symbol_prop: Symbol('test'),
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle nested objects and arrays', () => {
const properties = {
nested_object: {
string: 'test',
number: 42,
boolean: true,
null_value: null,
},
array_prop: ['string', 42, true, null],
nested_array: [
{ id: 1, name: 'test' },
{ id: 2, name: 'test2' },
],
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle duration formatting correctly', () => {
const properties = {
duration_seconds: 1.23456789,
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle invalid duration values gracefully', () => {
const properties = {
duration_seconds: 'not_a_number',
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle complex nested structures', () => {
const properties = {
user: {
id: 123,
name: 'John Doe',
preferences: {
theme: 'dark',
notifications: true,
settings: {
language: 'en',
timezone: 'UTC',
},
},
},
metadata: {
timestamp: Date.now(),
version: '1.0.0',
tags: ['test', 'analytics'],
},
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle arrays with mixed types', () => {
const properties = {
mixed_array: [
'string',
42,
true,
null,
{ nested: 'object' },
[1, 2, 3],
],
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle empty objects and arrays', () => {
const properties = {
empty_object: {},
empty_array: [],
nested_empty: {
empty_obj: {},
empty_arr: [],
},
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
});
describe('trackScreenView', () => {
it('should handle screen tracking without properties', () => {
expect(() => trackScreenView('test_screen')).not.toThrow();
});
it('should handle screen tracking with properties', () => {
const properties = {
reason: 'navigation',
duration_seconds: 5.2,
user_id: 123,
};
expect(() => trackScreenView('test_screen', properties)).not.toThrow();
});
it('should handle screen tracking with complex properties', () => {
const properties = {
navigation: {
from: 'home',
to: 'settings',
method: 'button_click',
},
user_context: {
is_logged_in: true,
subscription_tier: 'premium',
},
};
expect(() => trackScreenView('test_screen', properties)).not.toThrow();
});
});
describe('edge cases', () => {
it('should handle circular references gracefully', () => {
const circularObj: any = { name: 'test' };
circularObj.self = circularObj;
const properties = {
circular_reference: circularObj,
valid_prop: 'test',
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle very large numbers', () => {
const properties = {
large_number: Number.MAX_SAFE_INTEGER,
small_number: Number.MIN_SAFE_INTEGER,
float_number: 3.14159265359,
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle special string values', () => {
const properties = {
empty_string: '',
unicode_string: '🚀🌟💫',
special_chars: '!@#$%^&*()',
newlines: 'line1\nline2\r\nline3',
tabs: 'col1\tcol2\tcol3',
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
it('should handle deeply nested structures', () => {
const deepObj: any = {};
let current = deepObj;
// Create a deeply nested object
for (let i = 0; i < 10; i++) {
current.nested = { level: i };
current = current.nested;
}
const properties = {
deep_structure: deepObj,
simple_prop: 'test',
};
expect(() => trackEvent('test_event', properties)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,497 @@
// 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.
import { ethers } from 'ethers';
import { CloudStorage } from 'react-native-cloud-storage';
// Import after mocks
import { GDrive } from '@robinbobin/react-native-google-drive-api-wrapper';
import { renderHook } from '@testing-library/react-native';
import { useBackupMnemonic } from '@/services/cloud-backup';
import { createGDrive } from '@/services/cloud-backup/google';
type SupportedPlatforms = 'ios' | 'android';
jest.mock('react-native', () => {
const mockPlatform: { OS: SupportedPlatforms; select: jest.Mock } = {
OS: 'ios',
select: jest.fn(() => 'ios'),
};
const mockAppState = {
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
removeEventListener: jest.fn(),
};
const mockNativeModules = {
NativeLoggerBridge: {},
};
const MockNativeEventEmitter = jest.fn(() => ({
addListener: jest.fn(),
removeAllListeners: jest.fn(),
}));
return {
Platform: mockPlatform,
AppState: mockAppState,
NativeModules: mockNativeModules,
NativeEventEmitter: MockNativeEventEmitter,
};
});
jest.mock('react-native-biometrics', () => ({
__esModule: true,
default: jest.fn(() => ({
simplePrompt: jest.fn(async () => ({ success: true })),
isSensorAvailable: jest.fn(async () => ({
available: true,
biometryType: 'TouchID',
})),
})),
}));
const mockPlatform = jest.requireMock('react-native').Platform as {
OS: SupportedPlatforms;
select: jest.Mock;
};
// Mock dependencies
jest.mock('react-native-cloud-storage', () => ({
CloudStorage: {
setProviderOptions: jest.fn(),
mkdir: jest.fn(),
writeFile: jest.fn(),
exists: jest.fn(),
readFile: jest.fn(),
rmdir: jest.fn(),
},
CloudStorageScope: {
AppData: 'AppData',
},
}));
jest.mock('@robinbobin/react-native-google-drive-api-wrapper', () => ({
GDrive: jest.fn(),
APP_DATA_FOLDER_ID: 'mock-app-data-folder',
MIME_TYPES: {
application: {
json: 'application/json',
},
},
}));
jest.mock('@/services/cloud-backup/google', () => ({
createGDrive: jest.fn(),
}));
jest.mock('ethers', () => ({
ethers: {
Mnemonic: {
isValidMnemonic: jest.fn(),
},
},
}));
// Mock implementations
const mockGDriveInstance = {
accessToken: '',
files: {
newMultipartUploader: jest.fn().mockReturnValue({
setData: jest.fn().mockReturnThis(),
setDataMimeType: jest.fn().mockReturnThis(),
setRequestBody: jest.fn().mockReturnThis(),
execute: jest.fn(),
}),
list: jest.fn(),
getText: jest.fn(),
delete: jest.fn(),
},
};
const mockMnemonic = {
phrase:
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
password: '',
wordlist: { locale: 'en' },
entropy: '0x00000000000000000000000000000000',
};
describe('cloudBackup', () => {
let originalPlatform: SupportedPlatforms;
let consoleSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
originalPlatform = mockPlatform.OS;
// Suppress console.error during tests to avoid cluttering output
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
(GDrive as jest.Mock).mockImplementation(() => mockGDriveInstance);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(true);
});
afterEach(() => {
mockPlatform.OS = originalPlatform;
consoleSpy.mockRestore();
});
describe('useBackupMnemonic hook', () => {
it('should return upload, download, and disableBackup functions', () => {
const { result } = renderHook(() => useBackupMnemonic());
expect(result.current).toHaveProperty('upload');
expect(result.current).toHaveProperty('download');
expect(result.current).toHaveProperty('disableBackup');
expect(typeof result.current.upload).toBe('function');
expect(typeof result.current.download).toBe('function');
expect(typeof result.current.disableBackup).toBe('function');
});
});
describe('upload function - iOS', () => {
beforeEach(() => {
mockPlatform.OS = 'ios';
});
it('should upload mnemonic to iCloud successfully', async () => {
(CloudStorage.mkdir as jest.Mock).mockResolvedValue(undefined);
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(CloudStorage.mkdir).toHaveBeenCalledWith('/@selfxyz/mobile-app');
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
JSON.stringify(mockMnemonic),
);
});
it('should handle folder already exists error gracefully', async () => {
const folderExistsError = new Error('folder already exists');
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(folderExistsError);
(CloudStorage.writeFile as jest.Mock).mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(CloudStorage.writeFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
JSON.stringify(mockMnemonic),
);
});
it('should throw error for empty mnemonic', async () => {
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload({
phrase: '',
password: '',
wordlist: { locale: 'en' },
entropy: '',
}),
).rejects.toThrow(
'Mnemonic not set yet. Did the user see the recovery phrase?',
);
});
it('should throw error for null mnemonic', async () => {
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(null as any)).rejects.toThrow(
'Mnemonic not set yet. Did the user see the recovery phrase?',
);
});
it('should throw error when mkdir fails with non-existing folder error', async () => {
const permissionError = new Error('permission denied');
(CloudStorage.mkdir as jest.Mock).mockRejectedValue(permissionError);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
'permission denied',
);
});
});
describe('upload function - Android', () => {
beforeEach(() => {
mockPlatform.OS = 'android';
});
it('should upload mnemonic to Google Drive successfully', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files
.newMultipartUploader()
.execute.mockResolvedValue({});
const { result } = renderHook(() => useBackupMnemonic());
await expect(
result.current.upload(mockMnemonic),
).resolves.toBeUndefined();
expect(createGDrive).toHaveBeenCalled();
expect(
mockGDriveInstance.files.newMultipartUploader().setData,
).toHaveBeenCalledWith(JSON.stringify(mockMnemonic));
expect(
mockGDriveInstance.files.newMultipartUploader().execute,
).toHaveBeenCalled();
});
it('should throw error when user cancels Google sign-in', async () => {
(createGDrive as jest.Mock).mockResolvedValue(null);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.upload(mockMnemonic)).rejects.toThrow(
'User canceled Google sign-in',
);
});
});
describe('download function - iOS', () => {
beforeEach(() => {
mockPlatform.OS = 'ios';
});
it('should download and parse mnemonic from iCloud successfully', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(mockMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
const downloaded = await result.current.download();
expect(CloudStorage.exists).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
);
expect(CloudStorage.readFile).toHaveBeenCalledWith(
'//@selfxyz/mobile-app/encrypted-private-key',
);
expect(downloaded).toEqual(mockMnemonic);
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
mockMnemonic.phrase,
);
});
it('should throw error when backup file does not exist', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Couldnt find the encrypted backup, did you back it up previously?',
);
});
it('should throw error for malformed mnemonic JSON', async () => {
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue('invalid json');
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
);
});
it('should throw error for invalid mnemonic phrase', async () => {
const invalidMnemonic = { ...mockMnemonic, phrase: 'invalid phrase' };
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(invalidMnemonic),
);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
);
});
it('should throw error for missing mnemonic properties', async () => {
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
(CloudStorage.exists as jest.Mock).mockResolvedValue(true);
(CloudStorage.readFile as jest.Mock).mockResolvedValue(
JSON.stringify(incompleteMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
);
});
});
describe('download function - Android', () => {
beforeEach(() => {
mockPlatform.OS = 'android';
});
it('should download and parse mnemonic from Google Drive successfully', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify(mockMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
const downloaded = await result.current.download();
expect(createGDrive).toHaveBeenCalled();
expect(mockGDriveInstance.files.list).toHaveBeenCalledWith({
spaces: 'mock-app-data-folder',
q: "name = 'encrypted-private-key'",
});
expect(mockGDriveInstance.files.getText).toHaveBeenCalledWith('file-id');
expect(downloaded).toEqual(mockMnemonic);
expect(ethers.Mnemonic.isValidMnemonic).toHaveBeenCalledWith(
mockMnemonic.phrase,
);
});
it('should throw error when user cancels Google sign-in', async () => {
(createGDrive as jest.Mock).mockResolvedValue(null);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'User canceled Google sign-in',
);
});
it('should throw error when backup file does not exist', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [],
});
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Couldnt find the encrypted backup, did you back it up previously?',
);
});
it('should throw error for malformed mnemonic JSON', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue('invalid json');
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid JSON format in mnemonic backup',
);
});
it('should throw error for invalid mnemonic phrase', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify({ ...mockMnemonic, phrase: 'invalid phrase' }),
);
(ethers.Mnemonic.isValidMnemonic as jest.Mock).mockReturnValue(false);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic phrase: not a valid BIP39 mnemonic',
);
});
it('should throw error for missing mnemonic properties', async () => {
const incompleteMnemonic = { phrase: 'valid phrase', password: '' }; // missing wordlist and entropy
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id', name: 'encrypted-private-key' }],
});
mockGDriveInstance.files.getText.mockResolvedValue(
JSON.stringify(incompleteMnemonic),
);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.download()).rejects.toThrow(
'Failed to parse mnemonic backup: Invalid mnemonic structure: missing required properties (phrase, password, wordlist, entropy)',
);
});
});
describe('disableBackup function - iOS', () => {
beforeEach(() => {
mockPlatform.OS = 'ios';
});
it('should remove backup folder from iCloud', async () => {
(CloudStorage.rmdir as jest.Mock).mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.disableBackup()).resolves.toBeUndefined();
expect(CloudStorage.rmdir).toHaveBeenCalledWith('/@selfxyz/mobile-app', {
recursive: true,
});
});
});
describe('disableBackup function - Android', () => {
beforeEach(() => {
mockPlatform.OS = 'android';
});
it('should delete backup files from Google Drive', async () => {
(createGDrive as jest.Mock).mockResolvedValue(mockGDriveInstance);
mockGDriveInstance.files.list.mockResolvedValue({
files: [{ id: 'file-id' }, { id: 'file-id2' }],
});
mockGDriveInstance.files.delete.mockResolvedValue(undefined);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.disableBackup()).resolves.toBeUndefined();
expect(mockGDriveInstance.files.list).toHaveBeenCalledWith({
spaces: 'mock-app-data-folder',
q: "name = 'encrypted-private-key'",
});
expect(mockGDriveInstance.files.delete).toHaveBeenNthCalledWith(
1,
'file-id',
);
expect(mockGDriveInstance.files.delete).toHaveBeenNthCalledWith(
2,
'file-id2',
);
});
it('should resolve when user cancels Google sign-in', async () => {
(createGDrive as jest.Mock).mockResolvedValue(null);
const { result } = renderHook(() => useBackupMnemonic());
await expect(result.current.disableBackup()).resolves.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,132 @@
// 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.
jest.unmock('@/services/notifications/notificationService');
// Mock Platform and PermissionsAndroid without requiring react-native to avoid memory issues
// Prefix with 'mock' so Jest allows referencing them in the mock factory
const mockPlatform = {
OS: 'ios',
Version: 14,
};
const mockPermissionsAndroid = {
request: jest.fn(),
PERMISSIONS: {
POST_NOTIFICATIONS: 'android.permission.POST_NOTIFICATIONS',
},
RESULTS: {
GRANTED: 'granted',
DENIED: 'denied',
NEVER_ASK_AGAIN: 'never_ask_again',
},
};
jest.mock('react-native', () => ({
Platform: mockPlatform,
PermissionsAndroid: mockPermissionsAndroid,
}));
jest.mock('@react-native-firebase/messaging', () => {
const instance = {
requestPermission: jest.fn(),
getToken: jest.fn(),
};
const mockFn = () => instance;
mockFn._instance = instance;
mockFn.AuthorizationStatus = { AUTHORIZED: 1, PROVISIONAL: 2 };
return { __esModule: true, default: mockFn };
});
let messagingMock: {
requestPermission: jest.Mock;
getToken: jest.Mock;
};
global.fetch = jest.fn();
describe('notificationService', () => {
let service: any; // Using any here since we're dynamically requiring the module in tests
beforeEach(() => {
jest.clearAllMocks();
// Reset Platform to default iOS values (using the mock objects directly)
mockPlatform.OS = 'ios';
mockPlatform.Version = 14;
messagingMock = require('@react-native-firebase/messaging').default
._instance;
messagingMock.requestPermission.mockReset();
messagingMock.getToken.mockReset();
service = require('@/services/notifications/notificationService');
(fetch as jest.Mock).mockResolvedValue({ ok: true, text: jest.fn() });
messagingMock.requestPermission.mockResolvedValue(1);
messagingMock.getToken.mockResolvedValue('token');
mockPermissionsAndroid.request.mockClear();
});
describe('requestNotificationPermission', () => {
it('grants permission on Android', async () => {
mockPlatform.OS = 'android';
mockPlatform.Version = 34;
mockPermissionsAndroid.request.mockResolvedValue('granted');
const result = await service.requestNotificationPermission();
expect(result).toBe(true);
expect(messagingMock.requestPermission).toHaveBeenCalled();
});
it('handles denied permission on Android', async () => {
mockPlatform.OS = 'android';
mockPlatform.Version = 34;
mockPermissionsAndroid.request.mockResolvedValue('denied');
const result = await service.requestNotificationPermission();
expect(result).toBe(false);
});
it('handles never_ask_again permission on Android', async () => {
mockPlatform.OS = 'android';
mockPlatform.Version = 34;
mockPermissionsAndroid.request.mockResolvedValue('never_ask_again');
const result = await service.requestNotificationPermission();
expect(result).toBe(false);
});
it('returns false on error', async () => {
mockPlatform.OS = 'ios';
messagingMock.requestPermission.mockRejectedValueOnce(new Error('fail'));
const result = await service.requestNotificationPermission();
expect(result).toBe(false);
});
});
describe('getFCMToken', () => {
it('returns token', async () => {
messagingMock.getToken.mockResolvedValueOnce('abc');
const token = await service.getFCMToken();
expect(token).toBe('abc');
});
it('returns null when error', async () => {
messagingMock.getToken.mockRejectedValueOnce(new Error('err'));
const token = await service.getFCMToken();
expect(token).toBeNull();
});
});
describe('registerDeviceToken', () => {
it('posts token', async () => {
mockPlatform.OS = 'ios';
const response = { ok: true, text: jest.fn() };
(fetch as jest.Mock).mockResolvedValue(response);
await service.registerDeviceToken('123', 'tok', true);
expect(fetch).toHaveBeenCalledWith(
'https://notification.staging.self.xyz/register-token',
expect.objectContaining({ method: 'POST' }),
);
});
});
});

View File

@@ -0,0 +1,57 @@
// 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.
// Register crypto polyfills
import { ethers } from 'ethers';
import '@/utils/crypto/ethers';
describe('ethers crypto polyfills', () => {
it('randomBytes returns requested length and unique values', () => {
const a = ethers.randomBytes(16);
const b = ethers.randomBytes(16);
expect(a).toHaveLength(16);
expect(b).toHaveLength(16);
expect(ethers.hexlify(a)).not.toBe(ethers.hexlify(b));
});
it('computeHmac matches known vector', () => {
const result = ethers.computeHmac(
'sha256',
ethers.toUtf8Bytes('key'),
ethers.toUtf8Bytes('data'),
);
expect(ethers.hexlify(result)).toBe(
'0x5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0',
);
});
it('pbkdf2 derives expected key', () => {
const derived = ethers.pbkdf2(
ethers.toUtf8Bytes('password'),
ethers.toUtf8Bytes('salt'),
1000,
32,
'sha256',
);
expect(ethers.hexlify(derived)).toBe(
'0x632c2812e46d4604102ba7618e9d6d7d2f8128f6266b4a03264d2a0460b7dcb3',
);
});
it('sha256 hashes data correctly', () => {
const digest = ethers.sha256(ethers.toUtf8Bytes('hello'));
expect(ethers.hexlify(digest)).toBe(
'0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
);
});
it('sha512 hashes data correctly', () => {
const digest = ethers.sha512(ethers.toUtf8Bytes('hello'));
expect(ethers.hexlify(digest)).toBe(
'0x9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043',
);
});
});

View File

@@ -0,0 +1,48 @@
// 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.
import {
getModalCallbacks,
registerModalCallbacks,
unregisterModalCallbacks,
} from '@/utils/modalCallbackRegistry';
describe('modalCallbackRegistry', () => {
const registeredIds: number[] = [];
afterEach(() => {
registeredIds.forEach(id => unregisterModalCallbacks(id));
registeredIds.length = 0;
});
it('should register and retrieve callbacks', () => {
const callbacks = { onButtonPress: jest.fn(), onModalDismiss: jest.fn() };
const id = registerModalCallbacks(callbacks);
registeredIds.push(id);
expect(getModalCallbacks(id)).toBe(callbacks);
});
it('should unregister callbacks', () => {
const callbacks = { onButtonPress: jest.fn(), onModalDismiss: jest.fn() };
const id = registerModalCallbacks(callbacks);
unregisterModalCallbacks(id);
expect(getModalCallbacks(id)).toBeUndefined();
});
it('should generate unique ids', () => {
const id1 = registerModalCallbacks({
onButtonPress: jest.fn(),
onModalDismiss: jest.fn(),
});
const id2 = registerModalCallbacks({
onButtonPress: jest.fn(),
onModalDismiss: jest.fn(),
});
registeredIds.push(id1, id2);
expect(id1).not.toBe(id2);
});
});

View File

@@ -10,9 +10,9 @@ import {
unsafe_getPointsPrivateKey,
unsafe_getPrivateKey,
} from '@/providers/authProvider';
import { isSuccessfulStatus, makeApiRequest } from '@/utils/points/api';
import { POINTS_API_BASE_URL } from '@/utils/points/constants';
import { getPointsAddress } from '@/utils/points/utils';
import { isSuccessfulStatus, makeApiRequest } from '@/services/points/api';
import { POINTS_API_BASE_URL } from '@/services/points/constants';
import { getPointsAddress } from '@/services/points/utils';
// Mock dependencies
jest.mock('axios');
@@ -20,7 +20,7 @@ jest.mock('@/providers/authProvider', () => ({
unsafe_getPrivateKey: jest.fn(),
unsafe_getPointsPrivateKey: jest.fn(),
}));
jest.mock('@/utils/points/utils', () => ({
jest.mock('@/services/points/utils', () => ({
getPointsAddress: jest.fn(),
}));
jest.mock('ethers', () => ({

View File

@@ -2,16 +2,16 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { recordReferralPointEvent } from '@/utils/points/recordEvents';
import { registerReferralPoints } from '@/utils/points/registerEvents';
import { getPointsAddress } from '@/utils/points/utils';
import { recordReferralPointEvent } from '@/services/points/recordEvents';
import { registerReferralPoints } from '@/services/points/registerEvents';
import { getPointsAddress } from '@/services/points/utils';
// Mock dependencies
jest.mock('@/utils/points/registerEvents', () => ({
jest.mock('@/services/points/registerEvents', () => ({
registerReferralPoints: jest.fn(),
}));
jest.mock('@/utils/points/utils', () => ({
jest.mock('@/services/points/utils', () => ({
getPointsAddress: jest.fn(),
}));
@@ -30,11 +30,11 @@ jest.mock('@/stores/pointEventStore', () => ({
},
}));
jest.mock('@/utils/points/eventPolling', () => ({
jest.mock('@/services/points/eventPolling', () => ({
pollEventProcessingStatus: jest.fn(() => Promise.resolve('completed')),
}));
jest.mock('@/utils/points/api', () => ({
jest.mock('@/services/points/api', () => ({
isSuccessfulStatus: jest.fn(
(status: number) => status === 200 || status === 202,
),

View File

@@ -2,11 +2,11 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { makeApiRequest } from '@/utils/points/api';
import { registerReferralPoints } from '@/utils/points/registerEvents';
import { makeApiRequest } from '@/services/points/api';
import { registerReferralPoints } from '@/services/points/registerEvents';
// Mock the API module
jest.mock('@/utils/points/api', () => ({
jest.mock('@/services/points/api', () => ({
makeApiRequest: jest.fn(),
}));