mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
SELF-808: Implement document camera MRZ demo screen (#1204)
This commit is contained in:
@@ -117,7 +117,7 @@
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sort-exports": "^0.9.1",
|
||||
"jsdom": "^24.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -126,7 +126,7 @@
|
||||
"tamagui": "1.126.14",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^1.6.0"
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { DocumentCatalog, DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { loadSelectedDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import HomeScreen from './src/screens/HomeScreen';
|
||||
|
||||
@@ -1,183 +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.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { computeHmac, pbkdf2, randomBytes, sha256, sha512 } from '../src/utils/ethers';
|
||||
|
||||
describe('Crypto Polyfills', () => {
|
||||
describe('randomBytes', () => {
|
||||
it('should generate random bytes of specified length', () => {
|
||||
const bytes = randomBytes(32);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should generate different random bytes on each call', () => {
|
||||
const bytes1 = randomBytes(16);
|
||||
const bytes2 = randomBytes(16);
|
||||
expect(bytes1).not.toEqual(bytes2);
|
||||
});
|
||||
|
||||
it('should handle different lengths', () => {
|
||||
const bytes8 = randomBytes(8);
|
||||
const bytes64 = randomBytes(64);
|
||||
expect(bytes8.length).toBe(8);
|
||||
expect(bytes64.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sha256', () => {
|
||||
it('should hash data correctly', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash = sha256(data);
|
||||
|
||||
expect(hash).toBeInstanceOf(Uint8Array);
|
||||
expect(hash.length).toBe(32); // SHA-256 produces 32 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent hashes for same input', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash1 = sha256(data);
|
||||
const hash2 = sha256(data);
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
|
||||
const hash1 = sha256(data1);
|
||||
const hash2 = sha256(data2);
|
||||
|
||||
expect(hash1).not.toEqual(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sha512', () => {
|
||||
it('should hash data correctly', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash = sha512(data);
|
||||
|
||||
expect(hash).toBeInstanceOf(Uint8Array);
|
||||
expect(hash.length).toBe(64); // SHA-512 produces 64 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent hashes for same input', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash1 = sha512(data);
|
||||
const hash2 = sha512(data);
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
|
||||
const hash1 = sha512(data1);
|
||||
const hash2 = sha512(data2);
|
||||
|
||||
expect(hash1).not.toEqual(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHmac', () => {
|
||||
it('should compute HMAC-SHA256 correctly', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac = computeHmac('sha256', key, data);
|
||||
|
||||
expect(hmac).toBeInstanceOf(Uint8Array);
|
||||
expect(hmac.length).toBe(32); // HMAC-SHA256 produces 32 bytes
|
||||
});
|
||||
|
||||
it('should compute HMAC-SHA512 correctly', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac = computeHmac('sha512', key, data);
|
||||
|
||||
expect(hmac).toBeInstanceOf(Uint8Array);
|
||||
expect(hmac.length).toBe(64); // HMAC-SHA512 produces 64 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent HMAC for same inputs', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac1 = computeHmac('sha256', key, data);
|
||||
const hmac2 = computeHmac('sha256', key, data);
|
||||
|
||||
expect(hmac1).toEqual(hmac2);
|
||||
});
|
||||
|
||||
it('should produce different HMAC for different keys', () => {
|
||||
const key1 = new Uint8Array([1, 2, 3, 4]);
|
||||
const key2 = new Uint8Array([1, 2, 3, 5]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac1 = computeHmac('sha256', key1, data);
|
||||
const hmac2 = computeHmac('sha256', key2, data);
|
||||
|
||||
expect(hmac1).not.toEqual(hmac2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pbkdf2', () => {
|
||||
it('should derive key using PBKDF2-SHA256', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
|
||||
expect(key).toBeInstanceOf(Uint8Array);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should derive key using PBKDF2-SHA512', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key = pbkdf2(password, salt, 1000, 64, 'sha512');
|
||||
|
||||
expect(key).toBeInstanceOf(Uint8Array);
|
||||
expect(key.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should produce consistent keys for same inputs', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
it('should produce different keys for different salts', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt1 = new Uint8Array([5, 6, 7, 8]);
|
||||
const salt2 = new Uint8Array([5, 6, 7, 9]);
|
||||
const key1 = pbkdf2(password, salt1, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt2, 1000, 32, 'sha256');
|
||||
|
||||
expect(key1).not.toEqual(key2);
|
||||
});
|
||||
|
||||
it('should handle different iteration counts', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt, 2000, 32, 'sha256');
|
||||
|
||||
expect(key1).not.toEqual(key2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ethers integration', () => {
|
||||
it('should have ethers.randomBytes registered', () => {
|
||||
// This test verifies that ethers.js is using our polyfill
|
||||
const { ethers } = require('ethers');
|
||||
expect(typeof ethers.randomBytes).toBe('function');
|
||||
|
||||
const bytes = ethers.randomBytes(16);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(16);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "mobile-sdk-demo",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"analyze:bundle:android": "yarn prebuild && node scripts/bundle-analyze-ci.cjs android",
|
||||
@@ -9,7 +10,7 @@
|
||||
"preandroid": "yarn prebuild",
|
||||
"android": "react-native run-android --verbose",
|
||||
"prebuild": "yarn workspace @selfxyz/mobile-sdk-alpha build",
|
||||
"build": "tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"build": "yarn prebuild && tsc -p tsconfig.json --noEmit --pretty false",
|
||||
"clean": "rm -rf ios/build android/app/build android/build && cd android && ./gradlew clean && cd ..",
|
||||
"format": "prettier --write .",
|
||||
"ia": "yarn install-app",
|
||||
@@ -23,7 +24,7 @@
|
||||
"start": "react-native start",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"types": "tsc --noEmit"
|
||||
"types": "yarn prebuild && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.3",
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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 React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { buildValidationRows, humanizeDocumentType, type NormalizedMRZResult } from '../utils/camera';
|
||||
|
||||
interface Props {
|
||||
result: NormalizedMRZResult;
|
||||
}
|
||||
|
||||
export default function DocumentScanResultCard({ result }: Props) {
|
||||
const validationRows = useMemo(() => buildValidationRows(result.info.validation), [result]);
|
||||
|
||||
return (
|
||||
<View style={styles.resultCard} accessible accessibilityRole="summary">
|
||||
<Text style={styles.resultTitle}>Scan summary</Text>
|
||||
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={styles.resultLabel}>Document number</Text>
|
||||
<Text style={styles.resultValue}>{result.info.documentNumber}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={styles.resultLabel}>Document type</Text>
|
||||
<Text style={styles.resultValue}>{humanizeDocumentType(result.info.documentType)}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={styles.resultLabel}>Issuing country</Text>
|
||||
<Text style={styles.resultValue}>{result.info.issuingCountry || 'Unknown'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={styles.resultLabel}>Date of birth</Text>
|
||||
<Text style={styles.resultValue}>{result.readableBirthDate}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.resultRow}>
|
||||
<Text style={styles.resultLabel}>Expiry date</Text>
|
||||
<Text style={styles.resultValue}>{result.readableExpiryDate}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.validationSection}>
|
||||
<Text style={styles.validationTitle}>Validation checks</Text>
|
||||
{validationRows ? (
|
||||
validationRows.map(row => (
|
||||
<View key={row.label} style={styles.validationRow}>
|
||||
<Text style={styles.validationLabel}>{row.label}</Text>
|
||||
<Text
|
||||
style={[styles.validationBadge, row.value ? styles.validationPass : styles.validationFail]}
|
||||
accessibilityRole="text"
|
||||
>
|
||||
{row.value ? '✓ Pass' : '✗ Fail'}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.validationPlaceholder}>Validation details are not available for this scan yet.</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
resultCard: {
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
marginBottom: 16,
|
||||
shadowColor: '#0f172a',
|
||||
shadowOpacity: 0.08,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#0f172a',
|
||||
marginBottom: 12,
|
||||
},
|
||||
resultRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
resultLabel: {
|
||||
color: '#334155',
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
},
|
||||
resultValue: {
|
||||
color: '#0f172a',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
validationSection: {
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e2e8f0',
|
||||
},
|
||||
validationTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#0f172a',
|
||||
marginBottom: 8,
|
||||
},
|
||||
validationRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
validationLabel: {
|
||||
color: '#1f2937',
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
marginRight: 12,
|
||||
},
|
||||
validationBadge: {
|
||||
minWidth: 90,
|
||||
textAlign: 'center',
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: 8,
|
||||
borderRadius: 999,
|
||||
fontWeight: '600',
|
||||
fontSize: 12,
|
||||
color: '#ffffff',
|
||||
},
|
||||
validationPass: {
|
||||
backgroundColor: '#16a34a',
|
||||
},
|
||||
validationFail: {
|
||||
backgroundColor: '#b91c1c',
|
||||
},
|
||||
validationPlaceholder: {
|
||||
color: '#475569',
|
||||
fontSize: 13,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { updateAfterDelete } from '../lib/catalog';
|
||||
|
||||
182
packages/mobile-sdk-demo/src/hooks/useMRZScanner.ts
Normal file
182
packages/mobile-sdk-demo/src/hooks/useMRZScanner.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AccessibilityInfo, PermissionsAndroid, Platform } from 'react-native';
|
||||
|
||||
import type { MRZInfo } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { useReadMRZ } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
|
||||
|
||||
import { normalizeMRZPayload, type NormalizedMRZResult } from '../utils/camera';
|
||||
|
||||
type PermissionState = 'loading' | 'granted' | 'denied';
|
||||
type ScanState = 'idle' | 'scanning' | 'success' | 'error';
|
||||
|
||||
function announceForAccessibility(message: string) {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AccessibilityInfo.announceForAccessibility?.(message);
|
||||
} catch {
|
||||
// Intentionally swallow to avoid crashing accessibility users on announce failures.
|
||||
}
|
||||
}
|
||||
|
||||
export interface DocumentScannerCopy {
|
||||
instructions: string;
|
||||
success: string;
|
||||
error: string;
|
||||
permissionDenied: string;
|
||||
resetAnnouncement: string;
|
||||
}
|
||||
|
||||
export interface DocumentScannerState {
|
||||
permissionStatus: PermissionState;
|
||||
scanState: ScanState;
|
||||
mrzResult: NormalizedMRZResult | null;
|
||||
error: string | null;
|
||||
requestPermission: () => Promise<void>;
|
||||
handleMRZDetected: (payload: MRZInfo) => void;
|
||||
handleScannerError: (error: string) => void;
|
||||
handleScanAgain: () => void;
|
||||
}
|
||||
|
||||
export function useMRZScanner(copy: DocumentScannerCopy): DocumentScannerState {
|
||||
const [permissionStatus, setPermissionStatus] = useState<PermissionState>('loading');
|
||||
const [scanState, setScanState] = useState<ScanState>('idle');
|
||||
const [mrzResult, setMrzResult] = useState<NormalizedMRZResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scanStartTimeRef = useRef<number>(Date.now());
|
||||
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
setPermissionStatus('loading');
|
||||
setError(null);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA, {
|
||||
title: 'Camera permission',
|
||||
message: 'We need your permission to access the camera for MRZ scanning.',
|
||||
buttonPositive: 'Allow',
|
||||
buttonNegative: 'Cancel',
|
||||
buttonNeutral: 'Ask me later',
|
||||
});
|
||||
|
||||
if (result === PermissionsAndroid.RESULTS.GRANTED) {
|
||||
setPermissionStatus('granted');
|
||||
} else {
|
||||
setPermissionStatus('denied');
|
||||
}
|
||||
} catch {
|
||||
setPermissionStatus('denied');
|
||||
setError('Camera permission request failed. Please try again.');
|
||||
}
|
||||
} else {
|
||||
setPermissionStatus('granted');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionStatus === 'granted') {
|
||||
announceForAccessibility(copy.instructions);
|
||||
setScanState(current => {
|
||||
if (current === 'success') {
|
||||
return current;
|
||||
}
|
||||
scanStartTimeRef.current = Date.now();
|
||||
return 'scanning';
|
||||
});
|
||||
} else if (permissionStatus === 'denied') {
|
||||
announceForAccessibility(copy.permissionDenied);
|
||||
setScanState('idle');
|
||||
}
|
||||
}, [copy.instructions, copy.permissionDenied, permissionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanState === 'success') {
|
||||
announceForAccessibility(copy.success);
|
||||
} else if (scanState === 'error') {
|
||||
announceForAccessibility(copy.error);
|
||||
}
|
||||
}, [copy.error, copy.success, scanState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
announceForAccessibility(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleMRZDetected = useCallback(
|
||||
(payload: MRZInfo) => {
|
||||
setError(null);
|
||||
|
||||
setScanState(current => {
|
||||
if (current === 'success') {
|
||||
return current;
|
||||
}
|
||||
return 'scanning';
|
||||
});
|
||||
|
||||
try {
|
||||
const normalized = normalizeMRZPayload(payload);
|
||||
setMrzResult(normalized);
|
||||
setScanState('success');
|
||||
onPassportRead(null, normalized.info);
|
||||
} catch {
|
||||
setScanState('error');
|
||||
setError('Unable to validate the MRZ data from the scan.');
|
||||
}
|
||||
},
|
||||
[onPassportRead],
|
||||
);
|
||||
|
||||
const handleScannerError = useCallback((scannerError: string) => {
|
||||
setScanState('error');
|
||||
setError(scannerError || 'An unexpected camera error occurred.');
|
||||
}, []);
|
||||
|
||||
const handleScanAgain = useCallback(() => {
|
||||
if (permissionStatus === 'denied') {
|
||||
requestPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
scanStartTimeRef.current = Date.now();
|
||||
setMrzResult(null);
|
||||
setError(null);
|
||||
setScanState('scanning');
|
||||
announceForAccessibility(copy.resetAnnouncement);
|
||||
}, [copy.resetAnnouncement, permissionStatus, requestPermission]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
permissionStatus,
|
||||
scanState,
|
||||
mrzResult,
|
||||
error,
|
||||
requestPermission,
|
||||
handleMRZDetected,
|
||||
handleScannerError,
|
||||
handleScanAgain,
|
||||
}),
|
||||
[
|
||||
error,
|
||||
handleMRZDetected,
|
||||
handleScanAgain,
|
||||
handleScannerError,
|
||||
mrzResult,
|
||||
permissionStatus,
|
||||
requestPermission,
|
||||
scanState,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { SdkEvents, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
export type RegistrationState = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
|
||||
|
||||
export function updateAfterDelete(catalog: DocumentCatalog, deletedId: string): DocumentCatalog {
|
||||
const remaining = (catalog.documents || []).filter(doc => doc.id !== deletedId);
|
||||
|
||||
@@ -2,26 +2,233 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import PlaceholderScreen from '../components/PlaceholderScreen';
|
||||
import { MRZScannerView } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
|
||||
|
||||
import ScreenLayout from '../components/ScreenLayout';
|
||||
import DocumentScanResultCard from '../components/DocumentScanResultCard';
|
||||
import { useMRZScanner } from '../hooks/useMRZScanner';
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
const instructionsText = 'Align the machine-readable text with the frame and hold steady while we scan.';
|
||||
|
||||
const successMessage = 'Document scan successful. Review the details below.';
|
||||
const errorMessage = 'We could not read your document. Adjust lighting and try again.';
|
||||
const permissionDeniedMessage = 'Camera access was denied. Enable permissions to scan your document.';
|
||||
|
||||
export default function DocumentCamera({ onBack }: Props) {
|
||||
const scannerCopy = {
|
||||
instructions: instructionsText,
|
||||
success: successMessage,
|
||||
error: errorMessage,
|
||||
permissionDenied: permissionDeniedMessage,
|
||||
resetAnnouncement: 'Ready to scan again. Align the document in the viewfinder.',
|
||||
} as const;
|
||||
|
||||
const {
|
||||
permissionStatus,
|
||||
scanState,
|
||||
mrzResult,
|
||||
error,
|
||||
requestPermission,
|
||||
handleMRZDetected,
|
||||
handleScannerError,
|
||||
handleScanAgain,
|
||||
} = useMRZScanner(scannerCopy);
|
||||
|
||||
const handleSaveDocument = useCallback(() => {
|
||||
if (!mrzResult) {
|
||||
Alert.alert('Save Document', 'Scan a document before attempting to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Save Document',
|
||||
'Document storage will be available in a future release. Your scan is ready when you need it.',
|
||||
);
|
||||
}, [mrzResult]);
|
||||
|
||||
const renderPermissionDenied = () => (
|
||||
<View style={styles.centeredState}>
|
||||
<Text style={styles.permissionText}>{permissionDeniedMessage}</Text>
|
||||
<TouchableOpacity accessibilityRole="button" style={styles.secondaryButton} onPress={requestPermission}>
|
||||
<Text style={styles.secondaryButtonText}>Request Permission</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<View style={styles.centeredState}>
|
||||
<ActivityIndicator accessibilityLabel="Loading camera" color="#0f172a" />
|
||||
<Text style={styles.statusText}>Preparing camera…</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlaceholderScreen
|
||||
<ScreenLayout
|
||||
title="Document Camera"
|
||||
onBack={onBack}
|
||||
description="This screen would handle camera-based document scanning for passports and ID cards."
|
||||
features={[
|
||||
'Camera integration for document scanning',
|
||||
'MRZ (Machine Readable Zone) detection',
|
||||
'Document validation and parsing',
|
||||
'Real-time feedback and guidance',
|
||||
]}
|
||||
/>
|
||||
onBack={() => {
|
||||
onBack();
|
||||
}}
|
||||
contentStyle={styles.screenContent}
|
||||
rightAction={
|
||||
<TouchableOpacity accessibilityRole="button" onPress={handleSaveDocument}>
|
||||
<Text style={styles.headerAction}>Save Document</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
>
|
||||
{permissionStatus === 'loading' && renderLoading()}
|
||||
{permissionStatus === 'denied' && renderPermissionDenied()}
|
||||
|
||||
{permissionStatus === 'granted' && (
|
||||
<View style={styles.contentWrapper}>
|
||||
<View style={styles.cameraWrapper}>
|
||||
<MRZScannerView style={styles.scanner} onMRZDetected={handleMRZDetected} onError={handleScannerError} />
|
||||
<View style={styles.overlay} accessibilityLiveRegion="polite" pointerEvents="none">
|
||||
<Text style={styles.overlayTitle}>Position your document</Text>
|
||||
<Text style={styles.overlayText}>{instructionsText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
{scanState === 'scanning' && !error && (
|
||||
<View style={styles.statusRow}>
|
||||
<ActivityIndicator accessibilityLabel="Scanning" color="#2563eb" size="small" />
|
||||
<Text style={styles.statusText}>Scanning for MRZ data…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{scanState === 'success' && mrzResult && (
|
||||
<Text style={[styles.statusText, styles.successText]}>{successMessage}</Text>
|
||||
)}
|
||||
|
||||
{scanState === 'error' && error && <Text style={[styles.statusText, styles.errorText]}>{error}</Text>}
|
||||
</View>
|
||||
|
||||
{mrzResult && <DocumentScanResultCard result={mrzResult} />}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={handleScanAgain} style={styles.secondaryButton}>
|
||||
<Text style={styles.secondaryButtonText}>Scan Again</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity accessibilityRole="button" onPress={handleSaveDocument} style={styles.primaryButton}>
|
||||
<Text style={styles.primaryButtonText}>Save Document</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screenContent: {
|
||||
gap: 16,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
cameraWrapper: {
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
minHeight: 260,
|
||||
marginBottom: 16,
|
||||
},
|
||||
scanner: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
overlay: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.75)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
overlayTitle: {
|
||||
color: '#f8fafc',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
overlayText: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 14,
|
||||
},
|
||||
statusContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
statusText: {
|
||||
color: '#0f172a',
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
successText: {
|
||||
color: '#15803d',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#b91c1c',
|
||||
fontWeight: '600',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
primaryButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0f172a',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#0f172a',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerAction: {
|
||||
color: '#2563eb',
|
||||
fontWeight: '600',
|
||||
},
|
||||
centeredState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
permissionText: {
|
||||
color: '#0f172a',
|
||||
textAlign: 'center',
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
|
||||
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
|
||||
import { extractNameFromDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import ScreenLayout from '../components/ScreenLayout';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ActivityIndicator, Alert, Button, Platform, StyleSheet, Switch, Text, T
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { calculateContentHash, inferDocumentCategory, isMRZDocument } from '@selfxyz/common';
|
||||
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { generateMockDocument, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import SafeAreaScrollView from '../components/SafeAreaScrollView';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, Alert, Button, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, IDDocument } from '@selfxyz/common/utils/types';
|
||||
import { extractNameFromDocument, getAllDocuments, useSelfClient } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { PickerField } from '../components/PickerField';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, DocumentMetadata, IDDocument } from '@selfxyz/common/utils/types';
|
||||
|
||||
export type ScreenId = 'generate' | 'register' | 'prove' | 'camera' | 'nfc' | 'documents';
|
||||
|
||||
|
||||
120
packages/mobile-sdk-demo/src/utils/camera.ts
Normal file
120
packages/mobile-sdk-demo/src/utils/camera.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// 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 MRZInfo, type MRZValidation, extractMRZInfo } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
export type MRZPayload = MRZInfo | (MRZInfo & { rawMRZ?: string; raw?: string; mrzString?: string }) | string;
|
||||
|
||||
export interface NormalizedMRZResult {
|
||||
info: MRZInfo;
|
||||
readableBirthDate: string;
|
||||
readableExpiryDate: string;
|
||||
}
|
||||
|
||||
export interface ValidationRow {
|
||||
label: string;
|
||||
value: boolean | undefined | null;
|
||||
}
|
||||
|
||||
export function humanizeDocumentType(documentType: string): string {
|
||||
if (documentType === 'P') {
|
||||
return 'Passport';
|
||||
}
|
||||
|
||||
if (documentType === 'I') {
|
||||
return 'ID Card';
|
||||
}
|
||||
|
||||
if (!documentType) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
return documentType.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function buildValidationRows(validation?: MRZValidation): ValidationRow[] | null {
|
||||
if (!validation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Format', value: validation.format },
|
||||
{ label: 'Document number checksum', value: validation.passportNumberChecksum },
|
||||
{ label: 'Date of birth checksum', value: validation.dateOfBirthChecksum },
|
||||
{ label: 'Expiry date checksum', value: validation.dateOfExpiryChecksum },
|
||||
{ label: 'Composite checksum', value: validation.compositeChecksum },
|
||||
{ label: 'Overall validation', value: validation.overall },
|
||||
];
|
||||
}
|
||||
|
||||
export function formatMRZDate(mrzDate: string, locale: string = 'default'): string {
|
||||
if (!/^\d{6}$/.test(mrzDate)) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const yearPart = parseInt(mrzDate.slice(0, 2), 10);
|
||||
const monthPart = parseInt(mrzDate.slice(2, 4), 10);
|
||||
const dayPart = parseInt(mrzDate.slice(4, 6), 10);
|
||||
|
||||
if (Number.isNaN(yearPart) || monthPart < 1 || monthPart > 12 || dayPart < 1 || dayPart > 31) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear() % 100;
|
||||
const century = yearPart > currentYear ? 1900 : 2000;
|
||||
const fullYear = century + yearPart;
|
||||
|
||||
const date = new Date(Date.UTC(fullYear, monthPart - 1, dayPart));
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof Intl !== 'undefined') {
|
||||
const localeOption = locale === 'default' ? undefined : locale;
|
||||
return new Intl.DateTimeFormat(localeOption, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to ISO-like format below.
|
||||
}
|
||||
|
||||
const monthString = `${monthPart}`.padStart(2, '0');
|
||||
const dayString = `${dayPart}`.padStart(2, '0');
|
||||
return `${fullYear}-${monthString}-${dayString}`;
|
||||
}
|
||||
|
||||
export function normalizeMRZPayload(payload: MRZPayload): NormalizedMRZResult {
|
||||
let info: MRZInfo;
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
info = extractMRZInfo(payload);
|
||||
} else {
|
||||
const withRaw = payload as MRZInfo & { rawMRZ?: string; raw?: string; mrzString?: string };
|
||||
const rawCandidate = withRaw.rawMRZ ?? withRaw.raw ?? withRaw.mrzString;
|
||||
|
||||
if (typeof rawCandidate === 'string' && rawCandidate.trim().length > 0) {
|
||||
info = extractMRZInfo(rawCandidate);
|
||||
} else {
|
||||
info = {
|
||||
documentNumber: withRaw.documentNumber,
|
||||
dateOfBirth: withRaw.dateOfBirth,
|
||||
dateOfExpiry: withRaw.dateOfExpiry,
|
||||
issuingCountry: withRaw.issuingCountry,
|
||||
documentType: withRaw.documentType,
|
||||
validation: withRaw.validation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
info,
|
||||
readableBirthDate: formatMRZDate(info.dateOfBirth),
|
||||
readableExpiryDate: formatMRZDate(info.dateOfExpiry),
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentMetadata } from '@selfxyz/common/utils/types';
|
||||
|
||||
export function humanizeDocumentType(documentType: string): string {
|
||||
const toTitle = (s: string) => s.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import type { DocumentsAdapter } from '@selfxyz/mobile-sdk-alpha';
|
||||
import type { DocumentCatalog, IDDocument, PassportData } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog, IDDocument, PassportData } from '@selfxyz/common/utils/types';
|
||||
import { getSKIPEM, initPassportDataParsing } from '@selfxyz/common';
|
||||
|
||||
const CATALOG_KEY = '@self_demo:document_catalog';
|
||||
|
||||
@@ -1,242 +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.
|
||||
|
||||
/**
|
||||
* Tests for crypto-polyfill.js demonstrating functional bugs:
|
||||
* 1. Method chaining breaks due to incorrect `this` binding
|
||||
* 2. RNG import fails - react-native-get-random-values doesn't export getRandomValues
|
||||
* 3. Buffer polyfill missing
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Preserve original crypto
|
||||
const originalCrypto = global.crypto;
|
||||
|
||||
// Mock crypto.getRandomValues in jsdom environment
|
||||
if (typeof global.crypto === 'undefined' || !global.crypto.getRandomValues) {
|
||||
const mockGetRandomValues = vi.fn((array: Uint8Array) => {
|
||||
// Fill with predictable values for testing
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
});
|
||||
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
getRandomValues: mockGetRandomValues,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock Buffer globally to simulate React Native environment where Buffer is undefined
|
||||
const originalBuffer = global.Buffer;
|
||||
|
||||
describe('Crypto Polyfill Functional Bugs', () => {
|
||||
let crypto: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear module cache to get fresh instance
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore Buffer if we removed it
|
||||
global.Buffer = originalBuffer;
|
||||
// Restore crypto (use Object.defineProperty for read-only properties)
|
||||
if (originalCrypto) {
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: originalCrypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Method Chaining Bug', () => {
|
||||
it('should allow method chaining with update() calls', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// This should work but currently fails due to `this` binding issue
|
||||
expect(() => {
|
||||
const hasher = crypto.createHash('sha256');
|
||||
const result = hasher.update('Hello ').update('World').digest('hex');
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64); // SHA256 hex length
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return the hasher instance from update() for chaining', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const hasher = crypto.createHash('sha256');
|
||||
const updateResult = hasher.update('test');
|
||||
|
||||
// This should be the same object for chaining
|
||||
expect(updateResult).toBe(hasher);
|
||||
expect(updateResult.update).toBeInstanceOf(Function);
|
||||
expect(updateResult.digest).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should produce the same result for chained vs separate calls', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Chained approach
|
||||
const chainedResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
|
||||
|
||||
// Separate calls approach
|
||||
const hasher = crypto.createHash('sha256');
|
||||
hasher.update('Hello ');
|
||||
hasher.update('World');
|
||||
const separateResult = hasher.digest('hex');
|
||||
|
||||
expect(chainedResult).toBe(separateResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RNG Import Bug', () => {
|
||||
it('should not try to destructure getRandomValues from react-native-get-random-values', async () => {
|
||||
// Mock the require to simulate the actual package behavior
|
||||
vi.doMock('react-native-get-random-values', () => {
|
||||
// This package doesn't export getRandomValues - it just polyfills globalThis.crypto
|
||||
global.crypto = global.crypto || ({} as typeof crypto);
|
||||
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
}) as any;
|
||||
return {}; // Empty export
|
||||
});
|
||||
|
||||
// Should now work because we use globalThis.crypto.getRandomValues, not destructuring
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
const result = crypto.randomBytes(16);
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBe(16);
|
||||
});
|
||||
|
||||
it('should throw helpful error when crypto.getRandomValues is not available', async () => {
|
||||
// Clear module cache and remove crypto polyfill
|
||||
vi.resetModules();
|
||||
vi.doMock('react-native-get-random-values', () => {
|
||||
// Mock a broken polyfill that doesn't install crypto
|
||||
return {};
|
||||
});
|
||||
|
||||
// Remove crypto to simulate missing polyfill
|
||||
const originalCrypto = global.crypto;
|
||||
delete (global as any).crypto;
|
||||
|
||||
await expect(async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
crypto.randomBytes(16);
|
||||
}).rejects.toThrow(/globalThis.crypto.getRandomValues is not available/);
|
||||
|
||||
global.crypto = originalCrypto;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer Polyfill Bug', () => {
|
||||
it('should gracefully handle Buffer availability check', async () => {
|
||||
// This test verifies that the crypto polyfill checks for Buffer availability
|
||||
// Note: We can't actually delete Buffer because Vitest's worker threads need it
|
||||
// Instead, we verify that the polyfill works correctly with and without Buffer
|
||||
|
||||
// Import the polyfill normally
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Test that randomBytes returns a typed array
|
||||
const result = crypto.randomBytes(32);
|
||||
|
||||
// The result should be either a Buffer or Uint8Array (Buffer extends Uint8Array)
|
||||
// Buffer is available in Node.js environment, so we expect Buffer here
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBe(32);
|
||||
|
||||
// Verify it's a valid typed array with proper values
|
||||
expect(result.every((byte: number) => byte >= 0 && byte <= 255)).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with Buffer polyfill imported', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Simulate proper Buffer polyfill
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const result = crypto.createHash('sha256').update('test').digest('hex');
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle different data types correctly with Buffer polyfill', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const hasher = crypto.createHash('sha256');
|
||||
|
||||
// Should handle strings
|
||||
hasher.update('string data');
|
||||
|
||||
// Should handle Uint8Array
|
||||
hasher.update(new Uint8Array([1, 2, 3, 4]));
|
||||
|
||||
// Should handle Buffer
|
||||
hasher.update(Buffer.from('buffer data'));
|
||||
|
||||
const result = hasher.digest('hex');
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should work end-to-end with all fixes applied', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Set up proper environment
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
global.crypto = global.crypto || ({} as typeof crypto);
|
||||
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
}) as any;
|
||||
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Test hash chaining
|
||||
const hashResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
|
||||
|
||||
expect(typeof hashResult).toBe('string');
|
||||
expect(hashResult.length).toBe(64);
|
||||
|
||||
// Test randomBytes
|
||||
const randomResult = crypto.randomBytes(32);
|
||||
expect(randomResult).toBeInstanceOf(Buffer);
|
||||
expect(randomResult.length).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
421
packages/mobile-sdk-demo/tests/cryptoPolyfills.test.ts
Normal file
421
packages/mobile-sdk-demo/tests/cryptoPolyfills.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Comprehensive tests for crypto polyfills including:
|
||||
* 1. Functional bug tests for crypto-polyfill.js
|
||||
* 2. Ethers integration tests for crypto utilities
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeHmac, pbkdf2, randomBytes, sha256, sha512 } from '../src/utils/ethers';
|
||||
|
||||
// Preserve original crypto
|
||||
const originalCrypto = global.crypto;
|
||||
|
||||
// Mock crypto.getRandomValues in jsdom environment
|
||||
if (typeof global.crypto === 'undefined' || !global.crypto.getRandomValues) {
|
||||
const mockGetRandomValues = vi.fn((array: Uint8Array) => {
|
||||
// Fill with predictable values for testing
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
});
|
||||
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: {
|
||||
getRandomValues: mockGetRandomValues,
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Mock Buffer globally to simulate React Native environment where Buffer is undefined
|
||||
const originalBuffer = global.Buffer;
|
||||
|
||||
describe('Crypto Polyfills', () => {
|
||||
let crypto: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear module cache to get fresh instance
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore Buffer if we removed it
|
||||
global.Buffer = originalBuffer;
|
||||
// Restore crypto (use Object.defineProperty for read-only properties)
|
||||
if (originalCrypto) {
|
||||
Object.defineProperty(global, 'crypto', {
|
||||
value: originalCrypto,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Ethers Crypto Utilities', () => {
|
||||
describe('randomBytes', () => {
|
||||
it('should generate random bytes of specified length', () => {
|
||||
const bytes = randomBytes(32);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should generate different random bytes on each call', () => {
|
||||
const bytes1 = randomBytes(16);
|
||||
const bytes2 = randomBytes(16);
|
||||
expect(bytes1).not.toEqual(bytes2);
|
||||
});
|
||||
|
||||
it('should handle different lengths', () => {
|
||||
const bytes8 = randomBytes(8);
|
||||
const bytes64 = randomBytes(64);
|
||||
expect(bytes8.length).toBe(8);
|
||||
expect(bytes64.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sha256', () => {
|
||||
it('should hash data correctly', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash = sha256(data);
|
||||
|
||||
expect(hash).toBeInstanceOf(Uint8Array);
|
||||
expect(hash.length).toBe(32); // SHA-256 produces 32 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent hashes for same input', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash1 = sha256(data);
|
||||
const hash2 = sha256(data);
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
|
||||
const hash1 = sha256(data1);
|
||||
const hash2 = sha256(data2);
|
||||
|
||||
expect(hash1).not.toEqual(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sha512', () => {
|
||||
it('should hash data correctly', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash = sha512(data);
|
||||
|
||||
expect(hash).toBeInstanceOf(Uint8Array);
|
||||
expect(hash.length).toBe(64); // SHA-512 produces 64 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent hashes for same input', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const hash1 = sha512(data);
|
||||
const hash2 = sha512(data);
|
||||
|
||||
expect(hash1).toEqual(hash2);
|
||||
});
|
||||
|
||||
it('should produce different hashes for different inputs', () => {
|
||||
const data1 = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
const data2 = new Uint8Array([1, 2, 3, 4, 6]);
|
||||
const hash1 = sha512(data1);
|
||||
const hash2 = sha512(data2);
|
||||
|
||||
expect(hash1).not.toEqual(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHmac', () => {
|
||||
it('should compute HMAC-SHA256 correctly', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac = computeHmac('sha256', key, data);
|
||||
|
||||
expect(hmac).toBeInstanceOf(Uint8Array);
|
||||
expect(hmac.length).toBe(32); // HMAC-SHA256 produces 32 bytes
|
||||
});
|
||||
|
||||
it('should compute HMAC-SHA512 correctly', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac = computeHmac('sha512', key, data);
|
||||
|
||||
expect(hmac).toBeInstanceOf(Uint8Array);
|
||||
expect(hmac.length).toBe(64); // HMAC-SHA512 produces 64 bytes
|
||||
});
|
||||
|
||||
it('should produce consistent HMAC for same inputs', () => {
|
||||
const key = new Uint8Array([1, 2, 3, 4]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac1 = computeHmac('sha256', key, data);
|
||||
const hmac2 = computeHmac('sha256', key, data);
|
||||
|
||||
expect(hmac1).toEqual(hmac2);
|
||||
});
|
||||
|
||||
it('should produce different HMAC for different keys', () => {
|
||||
const key1 = new Uint8Array([1, 2, 3, 4]);
|
||||
const key2 = new Uint8Array([1, 2, 3, 5]);
|
||||
const data = new Uint8Array([5, 6, 7, 8]);
|
||||
const hmac1 = computeHmac('sha256', key1, data);
|
||||
const hmac2 = computeHmac('sha256', key2, data);
|
||||
|
||||
expect(hmac1).not.toEqual(hmac2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pbkdf2', () => {
|
||||
it('should derive key using PBKDF2-SHA256', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
|
||||
expect(key).toBeInstanceOf(Uint8Array);
|
||||
expect(key.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should derive key using PBKDF2-SHA512', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key = pbkdf2(password, salt, 1000, 64, 'sha512');
|
||||
|
||||
expect(key).toBeInstanceOf(Uint8Array);
|
||||
expect(key.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should produce consistent keys for same inputs', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
it('should produce different keys for different salts', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt1 = new Uint8Array([5, 6, 7, 8]);
|
||||
const salt2 = new Uint8Array([5, 6, 7, 9]);
|
||||
const key1 = pbkdf2(password, salt1, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt2, 1000, 32, 'sha256');
|
||||
|
||||
expect(key1).not.toEqual(key2);
|
||||
});
|
||||
|
||||
it('should handle different iteration counts', () => {
|
||||
const password = new Uint8Array([1, 2, 3, 4]);
|
||||
const salt = new Uint8Array([5, 6, 7, 8]);
|
||||
const key1 = pbkdf2(password, salt, 1000, 32, 'sha256');
|
||||
const key2 = pbkdf2(password, salt, 2000, 32, 'sha256');
|
||||
|
||||
expect(key1).not.toEqual(key2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ethers integration', () => {
|
||||
it('should have ethers.randomBytes registered', () => {
|
||||
// This test verifies that ethers.js is using our polyfill
|
||||
const { ethers } = require('ethers');
|
||||
expect(typeof ethers.randomBytes).toBe('function');
|
||||
|
||||
const bytes = ethers.randomBytes(16);
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(16);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Crypto Polyfill Functional Bugs', () => {
|
||||
describe('Method Chaining Bug', () => {
|
||||
it('should allow method chaining with update() calls', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// This should work but currently fails due to `this` binding issue
|
||||
expect(() => {
|
||||
const hasher = crypto.createHash('sha256');
|
||||
const result = hasher.update('Hello ').update('World').digest('hex');
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64); // SHA256 hex length
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should return the hasher instance from update() for chaining', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const hasher = crypto.createHash('sha256');
|
||||
const updateResult = hasher.update('test');
|
||||
|
||||
// This should be the same object for chaining
|
||||
expect(updateResult).toBe(hasher);
|
||||
expect(updateResult.update).toBeInstanceOf(Function);
|
||||
expect(updateResult.digest).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should produce the same result for chained vs separate calls', async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Chained approach
|
||||
const chainedResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
|
||||
|
||||
// Separate calls approach
|
||||
const hasher = crypto.createHash('sha256');
|
||||
hasher.update('Hello ');
|
||||
hasher.update('World');
|
||||
const separateResult = hasher.digest('hex');
|
||||
|
||||
expect(chainedResult).toBe(separateResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RNG Import Bug', () => {
|
||||
it('should not try to destructure getRandomValues from react-native-get-random-values', async () => {
|
||||
// Mock the require to simulate the actual package behavior
|
||||
vi.doMock('react-native-get-random-values', () => {
|
||||
// This package doesn't export getRandomValues - it just polyfills globalThis.crypto
|
||||
global.crypto = global.crypto || ({} as typeof crypto);
|
||||
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
}) as any;
|
||||
return {}; // Empty export
|
||||
});
|
||||
|
||||
// Should now work because we use globalThis.crypto.getRandomValues, not destructuring
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
const result = crypto.randomBytes(16);
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBe(16);
|
||||
});
|
||||
|
||||
it('should throw helpful error when crypto.getRandomValues is not available', async () => {
|
||||
// Clear module cache and remove crypto polyfill
|
||||
vi.resetModules();
|
||||
vi.doMock('react-native-get-random-values', () => {
|
||||
// Mock a broken polyfill that doesn't install crypto
|
||||
return {};
|
||||
});
|
||||
|
||||
// Remove crypto to simulate missing polyfill
|
||||
const originalCrypto = global.crypto;
|
||||
delete (global as any).crypto;
|
||||
|
||||
await expect(async () => {
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
crypto.randomBytes(16);
|
||||
}).rejects.toThrow(/globalThis.crypto.getRandomValues is not available/);
|
||||
|
||||
global.crypto = originalCrypto;
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buffer Polyfill Bug', () => {
|
||||
it('should gracefully handle Buffer availability check', async () => {
|
||||
// This test verifies that the crypto polyfill checks for Buffer availability
|
||||
// Note: We can't actually delete Buffer because Vitest's worker threads need it
|
||||
// Instead, we verify that the polyfill works correctly with and without Buffer
|
||||
|
||||
// Import the polyfill normally
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Test that randomBytes returns a typed array
|
||||
const result = crypto.randomBytes(32);
|
||||
|
||||
// The result should be either a Buffer or Uint8Array (Buffer extends Uint8Array)
|
||||
// Buffer is available in Node.js environment, so we expect Buffer here
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBe(32);
|
||||
|
||||
// Verify it's a valid typed array with proper values
|
||||
expect(result.every((byte: number) => byte >= 0 && byte <= 255)).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with Buffer polyfill imported', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Simulate proper Buffer polyfill
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const result = crypto.createHash('sha256').update('test').digest('hex');
|
||||
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64);
|
||||
});
|
||||
|
||||
it('should handle different data types correctly with Buffer polyfill', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
const hasher = crypto.createHash('sha256');
|
||||
|
||||
// Should handle strings
|
||||
hasher.update('string data');
|
||||
|
||||
// Should handle Uint8Array
|
||||
hasher.update(new Uint8Array([1, 2, 3, 4]));
|
||||
|
||||
// Should handle Buffer
|
||||
hasher.update(Buffer.from('buffer data'));
|
||||
|
||||
const result = hasher.digest('hex');
|
||||
expect(typeof result).toBe('string');
|
||||
expect(result.length).toBe(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should work end-to-end with all fixes applied', async () => {
|
||||
// Reset mocks for this test
|
||||
vi.unmock('buffer');
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Set up proper environment
|
||||
global.Buffer = require('buffer').Buffer;
|
||||
global.crypto = global.crypto || ({} as typeof crypto);
|
||||
global.crypto.getRandomValues = vi.fn((array: Uint8Array) => {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = i % 256;
|
||||
}
|
||||
return array;
|
||||
}) as any;
|
||||
|
||||
crypto = await import('../src/polyfills/cryptoPolyfill.js');
|
||||
|
||||
// Test hash chaining
|
||||
const hashResult = crypto.createHash('sha256').update('Hello ').update('World').digest('hex');
|
||||
|
||||
expect(typeof hashResult).toBe('string');
|
||||
expect(hashResult.length).toBe(64);
|
||||
|
||||
// Test randomBytes
|
||||
const randomResult = crypto.randomBytes(32);
|
||||
expect(randomResult).toBeInstanceOf(Buffer);
|
||||
expect(randomResult.length).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PassportData } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { PassportData } from '@selfxyz/common/utils/types';
|
||||
|
||||
describe('documentStore - BigInt serialization (simplified)', () => {
|
||||
it('should demonstrate the BigInt serialization problem with JSON.stringify/parse', () => {
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { DocumentCatalog } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentCatalog } from '@selfxyz/common/utils/types';
|
||||
|
||||
import { selectDocument, updateAfterDelete } from '../../src/lib/catalog';
|
||||
|
||||
|
||||
2
packages/mobile-sdk-demo/tests/mocks/mobile-sdk-alpha.ts
Normal file
2
packages/mobile-sdk-demo/tests/mocks/mobile-sdk-alpha.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { extractMRZInfo, formatDateToYYMMDD } from '../../../mobile-sdk-alpha/src/processing/mrz';
|
||||
export type { MRZInfo, MRZValidation } from '../../../mobile-sdk-alpha/src/types/public';
|
||||
45
packages/mobile-sdk-demo/tests/mocks/react-native.ts
Normal file
45
packages/mobile-sdk-demo/tests/mocks/react-native.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export const AccessibilityInfo = {
|
||||
announceForAccessibility: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
export const ActivityIndicator = () => null;
|
||||
export const Alert = { alert: () => undefined };
|
||||
|
||||
export const PermissionsAndroid = {
|
||||
PERMISSIONS: { CAMERA: 'android.permission.CAMERA' },
|
||||
RESULTS: { GRANTED: 'granted', DENIED: 'denied' },
|
||||
request: async () => 'granted' as const,
|
||||
};
|
||||
|
||||
export const Platform = {
|
||||
OS: 'ios',
|
||||
select<T>(mapping: Record<string, T> & { default?: T }): T {
|
||||
if (Object.prototype.hasOwnProperty.call(mapping, 'ios')) {
|
||||
return mapping.ios as T;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(mapping, 'default')) {
|
||||
return mapping.default as T;
|
||||
}
|
||||
return Object.values(mapping)[0] as T;
|
||||
},
|
||||
};
|
||||
|
||||
export const StyleSheet = {
|
||||
create: <T extends Record<string, object>>(styles: T) => styles,
|
||||
};
|
||||
|
||||
export const Text = () => null;
|
||||
export const TouchableOpacity = () => null;
|
||||
export const View = () => null;
|
||||
|
||||
export default {
|
||||
AccessibilityInfo,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
PermissionsAndroid,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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 { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { MRZInfo } from '@selfxyz/mobile-sdk-alpha';
|
||||
|
||||
import { buildValidationRows, formatMRZDate, humanizeDocumentType, normalizeMRZPayload } from '../../src/utils/camera';
|
||||
|
||||
describe('formatMRZDate', () => {
|
||||
it('formats valid YYMMDD strings into readable dates', () => {
|
||||
expect(formatMRZDate('740812', 'en-US')).toBe('August 12, 1974');
|
||||
expect(formatMRZDate('010101', 'en-US')).toBe('January 1, 2001');
|
||||
});
|
||||
|
||||
it('returns Unknown for invalid values', () => {
|
||||
expect(formatMRZDate('991332', 'en-US')).toBe('Unknown');
|
||||
expect(formatMRZDate('abc123', 'en-US')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMRZPayload', () => {
|
||||
it('parses raw MRZ strings and surfaces validation data', () => {
|
||||
const rawMRZ = 'P<UTOERIKSSON<<ANNA<MARIA<<<<<<<<<<<<<<<<<<<<<<\nL898902C36UTO7408122F1204159ZE184226B<<<<<10';
|
||||
|
||||
const normalized = normalizeMRZPayload(rawMRZ);
|
||||
|
||||
expect(normalized.info.documentNumber).toBe('L898902C3');
|
||||
expect(normalized.info.dateOfBirth).toBe('740812');
|
||||
expect(normalized.info.dateOfExpiry).toBe('120415');
|
||||
expect(normalized.info.validation?.overall).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves provided MRZ info when validation already exists', () => {
|
||||
const info: MRZInfo = {
|
||||
documentNumber: 'X1234567',
|
||||
dateOfBirth: '010101',
|
||||
dateOfExpiry: '251231',
|
||||
issuingCountry: 'UTO',
|
||||
documentType: 'P',
|
||||
validation: {
|
||||
format: true,
|
||||
passportNumberChecksum: true,
|
||||
dateOfBirthChecksum: true,
|
||||
dateOfExpiryChecksum: true,
|
||||
compositeChecksum: true,
|
||||
overall: true,
|
||||
},
|
||||
};
|
||||
|
||||
const normalized = normalizeMRZPayload(info);
|
||||
|
||||
expect(normalized.info).toEqual(info);
|
||||
expect(normalized.readableBirthDate).toBe(formatMRZDate('010101', 'en-US'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('humanizeDocumentType', () => {
|
||||
it('maps known document codes to friendly labels', () => {
|
||||
expect(humanizeDocumentType('P')).toBe('Passport');
|
||||
expect(humanizeDocumentType('I')).toBe('ID Card');
|
||||
});
|
||||
|
||||
it('falls back to normalized text for unknown values', () => {
|
||||
expect(humanizeDocumentType(' visa ')).toBe('VISA');
|
||||
expect(humanizeDocumentType('')).toBe('Unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildValidationRows', () => {
|
||||
it('returns null when validation is unavailable', () => {
|
||||
expect(buildValidationRows(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps MRZ validation flags into labelled rows', () => {
|
||||
const rows = buildValidationRows({
|
||||
format: true,
|
||||
passportNumberChecksum: true,
|
||||
dateOfBirthChecksum: false,
|
||||
dateOfExpiryChecksum: true,
|
||||
compositeChecksum: true,
|
||||
overall: false,
|
||||
});
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ label: 'Format', value: true },
|
||||
{ label: 'Document number checksum', value: true },
|
||||
{ label: 'Date of birth checksum', value: false },
|
||||
{ label: 'Expiry date checksum', value: true },
|
||||
{ label: 'Composite checksum', value: true },
|
||||
{ label: 'Overall validation', value: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { DocumentMetadata } from '@selfxyz/common/dist/esm/src/utils/types.js';
|
||||
import type { DocumentMetadata } from '@selfxyz/common/utils/types';
|
||||
|
||||
import { formatDataPreview, humanizeDocumentType, maskId } from '../../src/utils/document';
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
{
|
||||
"extends": "@tsconfig/react-native/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "ES2020",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"paths": {
|
||||
"@selfxyz/mobile-sdk-alpha": ["../../packages/mobile-sdk-alpha/dist/esm/index"],
|
||||
"@selfxyz/mobile-sdk-alpha/onboarding/*": ["../../packages/mobile-sdk-alpha/dist/esm/flows/onboarding/*"],
|
||||
"@selfxyz/mobile-sdk-alpha/disclosing/*": ["../../packages/mobile-sdk-alpha/dist/esm/flows/disclosing/*"]
|
||||
},
|
||||
"types": ["react-native", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "App.tsx", "index.js", "types/**/*", "__tests__/**/*", "tests/**/*"]
|
||||
"include": ["src", "App.tsx", "index.js", "types/**/*", "__tests__/**/*", "tests/**/*"],
|
||||
"exclude": ["node_modules", "../../packages/mobile-sdk-alpha/src"]
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, '../..');
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
include: ['__tests__/**/*.test.{ts,tsx}', 'tests/**/*.test.{ts,tsx}'],
|
||||
include: ['tests/**/*.test.{ts,tsx}'],
|
||||
exclude: ['node_modules/**'],
|
||||
// Skip checking node_modules for faster testing
|
||||
server: {
|
||||
@@ -23,10 +22,22 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
target: 'node18',
|
||||
},
|
||||
build: {
|
||||
target: 'node18',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@selfxyz/common': resolve(repoRoot, 'common/dist/cjs/index.cjs'),
|
||||
'@selfxyz/mobile-sdk-alpha': resolve(repoRoot, 'packages/mobile-sdk-alpha/src/index.ts'),
|
||||
},
|
||||
alias: [
|
||||
{
|
||||
find: '@selfxyz/mobile-sdk-alpha',
|
||||
replacement: resolve(__dirname, './tests/mocks/mobile-sdk-alpha.ts'),
|
||||
},
|
||||
{
|
||||
find: 'react-native',
|
||||
replacement: resolve(__dirname, './tests/mocks/react-native.ts'),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user