diff --git a/App.tsx b/App.tsx index f3f11ed..ea1888f 100644 --- a/App.tsx +++ b/App.tsx @@ -52,7 +52,6 @@ import socketService from './src/services/socket'; import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; // Added missing store imports import { waitForHydration } from './src/store/createStore'; -import { useDegradationStore } from './src/store/degradationStore'; import { consumeHydrationResetToast, subscribeToHydrationResetToast, @@ -73,7 +72,10 @@ requireEnvVariables(); // Initialize centralized logging on app start initializeLogging().catch(err => { if (__DEV__) { - appLogger.errorSync('[App] Failed to initialize logging:', err instanceof Error ? err : new Error(String(err))); + appLogger.errorSync( + '[App] Failed to initialize logging:', + err instanceof Error ? err : new Error(String(err)) + ); } }); @@ -215,10 +217,7 @@ const App = () => { const allFonts = [...CRITICAL_FONTS, ...SECONDARY_FONTS]; const fontStart = Date.now(); try { - await Promise.all([ - fontService.loadFonts(allFonts), - Asset.loadAsync(CRITICAL_ASSETS), - ]); + await Promise.all([fontService.loadFonts(allFonts), Asset.loadAsync(CRITICAL_ASSETS)]); } catch (e: any) { crashReportingService.reportError(e, 'font-loading-error'); } @@ -241,8 +240,6 @@ const App = () => { prepareApp(); }, []); - - // OTA Update check on foreground const checkForOtaUpdate = useCallback(async () => { try { @@ -307,41 +304,23 @@ const App = () => { }; // Register unhandled rejection listener - if (global.onunhandledrejection === undefined) { - // @ts-ignore - Setting global error handler - global.onunhandledrejection = unhandledRejectionHandler; + if (typeof global.onunhandledrejection !== 'undefined') { + global.onunhandledrejection = (event: PromiseRejectionEvent) => { + unhandledRejectionHandler(event.reason); + }; + } else { + // Fallback for environments that do not support onunhandledrejection + const ErrorUtils = require('react-native/Libraries/ErrorUtils'); + ErrorUtils.setGlobalHandler((error: Error, isFatal: boolean) => { + if (!isFatal && error.message.includes('Unhandled promise rejection')) { + unhandledRejectionHandler(error); + } + }); } // Connect to socket when app starts socketService.connect(); - // Initialize feature capability detection (non-blocking) - featureCapabilities - .checkAllCapabilities() - .then(capabilities => { - const degradationStore = useDegradationStore.getState(); - appLogger.infoSync('[App] Feature capabilities checked', { - camera: capabilities.camera.status, - notifications: capabilities.pushNotifications.status, - location: capabilities.location.status, - }); - // Update degradation store with current feature statuses - Object.entries(capabilities).forEach(([feature, info]) => { - if (feature !== 'checkedAt' && 'status' in info) { - // #807: isFeatureType narrows string key to FeatureType -if ((Object.values(FeatureType) as string[]).includes(feature)) { - degradationStore.setFeatureStatus(feature as FeatureType, info.status); - } - } - }); - }) - .catch(error => { - appLogger.errorSync( - '[App] Error checking feature capabilities', - error instanceof Error ? error : new Error(String(error)) - ); - }); - // Push notifications are now initialized within InteractionManager.runAfterInteractions below // ===== DEFERRED PATH — runs after user interactions complete ===== @@ -361,33 +340,6 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { // Socket connection (network I/O) socketService.connect(); - // Feature capability detection (permission checks, async) - featureCapabilities - .checkAllCapabilities() - .then(capabilities => { - // Issue #820: read directly from store rather than closing over component state. - const degradationStore = useDegradationStore.getState(); - appLogger.infoSync('[App] Feature capabilities checked', { - camera: capabilities.camera.status, - notifications: capabilities.pushNotifications.status, - location: capabilities.location.status, - }); - Object.entries(capabilities).forEach(([feature, info]) => { - if (feature !== 'checkedAt' && 'status' in info) { - // #807: isFeatureType narrows string key to FeatureType -if ((Object.values(FeatureType) as string[]).includes(feature)) { - degradationStore.setFeatureStatus(feature as FeatureType, info.status); - } - } - }); - }) - .catch(error => { - appLogger.errorSync( - '[App] Error checking feature capabilities', - error instanceof Error ? error : new Error(String(error)) - ); - }); - // Push notification registration and explainer logic. // Issue #820: all state reads use store.getState() instead of closed-over // component state so the callback always operates on the current values. @@ -479,8 +431,7 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { if (notificationSubscriptionRef.current) { removeNotificationListener(notificationSubscriptionRef.current); } - // @ts-ignore - global.onunhandledrejection = undefined; + global.onunhandledrejection = null; }; }, []); @@ -496,14 +447,8 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { return; } - const { - isAuthenticated, - refreshToken, - setUser, - setTokens, - setSessionExpiringSoon, - logout, - } = useAppStore.getState(); + const { isAuthenticated, refreshToken, setUser, setTokens, setSessionExpiringSoon, logout } = + useAppStore.getState(); if (!isAuthenticated || !refreshToken) return; @@ -588,6 +533,7 @@ if ((Object.values(FeatureType) as string[]).includes(feature)) { + diff --git a/src/components/FeatureCapabilityHandler.tsx b/src/components/FeatureCapabilityHandler.tsx new file mode 100644 index 0000000..d91b605 --- /dev/null +++ b/src/components/FeatureCapabilityHandler.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { featureCapabilities, FeatureType } from '../services/featureCapabilities'; +import { useGuardedDegradationStore } from '../store/degradationStore'; +import { appLogger } from '../utils/logger'; + +const FeatureCapabilityHandler = () => { + const degradationStore = useGuardedDegradationStore(); + + useEffect(() => { + const checkCapabilities = async () => { + try { + const capabilities = await featureCapabilities.checkAllCapabilities(); + appLogger.infoSync('[App] Feature capabilities checked', { + camera: capabilities.camera.status, + notifications: capabilities.pushNotifications.status, + location: capabilities.location.status, + }); + // Update degradation store with current feature statuses + Object.entries(capabilities).forEach(([feature, info]) => { + if (feature !== 'checkedAt' && 'status' in info) { + if ((Object.values(FeatureType) as string[]).includes(feature)) { + degradationStore.setFeatureStatus(feature as FeatureType, info.status); + } + } + }); + } catch (error) { + appLogger.errorSync( + '[App] Error checking feature capabilities', + error instanceof Error ? error : new Error(String(error)) + ); + } + }; + + checkCapabilities(); + }, [degradationStore]); + + return null; +}; + +export default FeatureCapabilityHandler; diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 55b390b..1fc00cf 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -379,30 +379,41 @@ apiClient.interceptors.response.use( (error.code === 'ERR_NETWORK' || error.message === 'Network Error') && isCertPinFailure(error) ) { + const requestUrl = originalRequest?.url ?? ''; + const authApiDomain = new URL(baseURL).hostname; + const requestDomain = requestUrl ? new URL(requestUrl, baseURL).hostname : ''; // Report to Sentry — endpoint and method only; no token, headers, or body sentryContextService.captureException(new Error('SSL certificate pin validation failed'), { tags: { 'security.event': 'ssl_pin_failure' }, extra: { endpoint: originalRequest?.url, method: originalRequest?.method?.toUpperCase(), + isAuthDomain: requestDomain === authApiDomain, }, - fingerprint: ['ssl-pin-failure'], + fingerprint: ['ssl-pin-failure', requestDomain], }); appLogger.errorSync('SSL pin validation failed — possible MITM attack', undefined, { endpoint: originalRequest?.url, method: originalRequest?.method, + isAuthDomain: requestDomain === authApiDomain, }); - // Force full logout — session may be compromised - useAppStore.getState().logout(); + if (requestDomain === authApiDomain) { + // Force full logout — session may be compromised + useAppStore.getState().logout(); - return Promise.reject({ - message: - 'Secure connection could not be established. Please check your network and try again.', - code: 'SSL_PIN_FAILURE', - status: 0, - }); + return Promise.reject({ + message: 'A security error occurred. Please log in again.', + code: 'SSL_PIN_FAILURE', + }); + } else { + // For non-auth domains, cancel the request and show a security warning + return Promise.reject({ + message: 'A security error occurred with a third-party service. Please try again later.', + code: 'SSL_PIN_FAILURE_NON_AUTH', + }); + } } // ── Queue network errors for retry ─────────────────────────────────── @@ -506,13 +517,10 @@ apiClient.interceptors.response.use( const rawData = error.response?.data; const responseData = isConflictResponseShape(rawData) ? rawData : undefined; if (rawData !== undefined && !isConflictResponseShape(rawData)) { - sentryContextService.captureException( - new Error('409 response body has unexpected shape'), - { - extra: { rawData: String(rawData).slice(0, 200) }, - tags: { 'api.error': 'conflict_shape_mismatch' }, - } - ); + sentryContextService.captureException(new Error('409 response body has unexpected shape'), { + extra: { rawData: String(rawData).slice(0, 200) }, + tags: { 'api.error': 'conflict_shape_mismatch' }, + }); } // Extract version metadata from request headers @@ -675,4 +683,4 @@ apiClient.interceptors.response.use( } ); -export default apiClient; \ No newline at end of file +export default apiClient; diff --git a/src/store/degradationStore.ts b/src/store/degradationStore.ts index 0867445..a4c25e3 100644 --- a/src/store/degradationStore.ts +++ b/src/store/degradationStore.ts @@ -24,9 +24,9 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; import { useFeatureFlagStore } from './featureFlagStore'; import { asyncStorageJSONStorage, createHydrationErrorRecovery } from './persistence'; -import { FeatureStatus, FeatureType } from '../services/featureCapabilities'; export interface DegradationNotification { id: string; @@ -102,6 +102,23 @@ const createInitialDegradationState = () => ({ preferences: DEFAULT_PREFERENCES, }); +const createSafeNoOpState = (): DegradationState => ({ + ...createInitialDegradationState(), + setFeatureStatus: () => {}, + isFeatureDegraded: () => true, // Assume degraded if not authenticated + getDegradedFeatures: () => Object.values(FeatureType), + addNotification: () => '', + dismissNotification: () => {}, + clearNotifications: () => {}, + getUnreadNotifications: () => [], + setShowDegradationBanners: () => {}, + setAutoDismissAlerts: () => {}, + setRemindPermissionRetry: () => {}, + setRespectRemoteFlags: () => {}, + disableFeature: () => {}, + enableFeature: () => {}, +}); + let resetDegradationStoreAfterHydrationError = () => {}; /** @@ -151,6 +168,9 @@ export const useDegradationStore = create()( }), isFeatureDegraded: (feature: FeatureType): boolean => { + const { isAuthenticated } = useAppStore.getState(); + if (!isAuthenticated) return true; // Secure by default + const status = get().featureStatuses[feature]; const hardwareDegraded = status === FeatureStatus.PERMISSION_DENIED || @@ -171,6 +191,9 @@ export const useDegradationStore = create()( }, getDegradedFeatures: (): FeatureType[] => { + const { isAuthenticated } = useAppStore.getState(); + if (!isAuthenticated) return Object.values(FeatureType); // Secure by default + const features: FeatureType[] = []; for (const feature of Object.values(FeatureType)) { if (get().isFeatureDegraded(feature as FeatureType)) { @@ -278,26 +301,38 @@ export const useDegradationStore = create()( 'degradation-store', resetDegradationStoreAfterHydrationError ), - /** - * Version 2: bumped from 1 (implicit) to discard any previously-persisted - * state where `degradedFeatures` was serialised as `{}` (empty object) - * due to JSON.stringify(Set) producing `{}`. - */ - version: 2, - migrate: (_persistedState, _fromVersion) => { - // Any state written by version 1 (or earlier) had a corrupt - // `degradedFeatures: {}`. Return undefined so Zustand falls back to - // the initial state defined above. - return undefined; + version: 3, + migrate: (persistedState, fromVersion) => { + if (fromVersion < 3) { + // Versions before 3 may have included derived state. + // We can safely discard it and let it be re-computed. + const { isFeatureDegraded, getDegradedFeatures, ...rest } = + persistedState as DegradationState & { + isFeatureDegraded?: any; + getDegradedFeatures?: any; + }; + return rest; + } + return persistedState; }, partialize: state => ({ preferences: state.preferences, notifications: state.notifications, featureStatuses: state.featureStatuses, - // Include degradedFeatures so it survives app restarts. - // Safe to persist now that it is a plain array, not a Set. degradedFeatures: state.degradedFeatures, }), } ) ); + +// Selector that returns a no-op, secure-by-default state if the user is not authenticated. +export const useGuardedDegradationStore = () => { + const isAuthenticated = useAppStore(state => state.isAuthenticated); + const store = useDegradationStore(); + + if (!isAuthenticated) { + return createSafeNoOpState(); + } + + return store; +}; diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 0000000..44312ff --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,5 @@ +declare global { + var onunhandledrejection: ((this: Window, ev: PromiseRejectionEvent) => any) | null; +} + +export {}; diff --git a/tests/axios.config.test.ts b/tests/axios.config.test.ts index ea13d03..cb46526 100644 --- a/tests/axios.config.test.ts +++ b/tests/axios.config.test.ts @@ -103,11 +103,14 @@ jest.mock('../src/store', () => ({ })); jest.mock('../src/store/conflictStore', () => ({ - useConflictStore: Object.assign(jest.fn(() => ({})), { - getState: jest.fn(() => ({ - addConflict: jest.fn(), - })), - }), + useConflictStore: Object.assign( + jest.fn(() => ({})), + { + getState: jest.fn(() => ({ + addConflict: jest.fn(), + })), + } + ), })); jest.mock('../src/services/api/errorSanitization', () => ({ @@ -128,7 +131,7 @@ function makeAxiosError( code?: string, message?: string, config?: Partial, - response?: any, + response?: any ): AxiosError { const cfg = { url: '/test', @@ -191,8 +194,24 @@ describe('Issue #838 — axios.config error handling branches', () => { })); jest.doMock('../src/utils/logger', () => ({ __esModule: true, - default: { info: jest.fn(), infoSync: jest.fn(), warn: jest.fn(), warnSync: jest.fn(), error: jest.fn(), errorSync: jest.fn(), debug: jest.fn() }, - appLogger: { info: jest.fn(), infoSync: jest.fn(), warn: jest.fn(), warnSync: jest.fn(), error: jest.fn(), errorSync: jest.fn(), debug: jest.fn() }, + default: { + info: jest.fn(), + infoSync: jest.fn(), + warn: jest.fn(), + warnSync: jest.fn(), + error: jest.fn(), + errorSync: jest.fn(), + debug: jest.fn(), + }, + appLogger: { + info: jest.fn(), + infoSync: jest.fn(), + warn: jest.fn(), + warnSync: jest.fn(), + error: jest.fn(), + errorSync: jest.fn(), + debug: jest.fn(), + }, })); jest.doMock('../src/utils/performanceTiming', () => ({ startTiming: jest.fn(() => jest.fn(() => ({ duration: 0, success: true }))), @@ -230,9 +249,12 @@ describe('Issue #838 — axios.config error handling branches', () => { }, })); jest.doMock('../src/store/conflictStore', () => ({ - useConflictStore: Object.assign(jest.fn(() => ({})), { - getState: jest.fn(() => ({ addConflict: jest.fn() })), - }), + useConflictStore: Object.assign( + jest.fn(() => ({})), + { + getState: jest.fn(() => ({ addConflict: jest.fn() })), + } + ), })); jest.doMock('../src/services/api/errorSanitization', () => ({ buildSanitizedApiError: jest.fn((status: number, code?: string) => ({ @@ -252,43 +274,102 @@ describe('Issue #838 — axios.config error handling branches', () => { }); // ── 1. SSL pin failure → logout ──────────────────────────────────────────── - it('1. detects SSL certificate pin failure and triggers logout', () => { + it('1. detects SSL certificate pin failure and triggers logout for auth domain', async () => { const mockLogout = jest.fn(); const { useAppStore } = require('../src/store'); useAppStore.getState.mockReturnValue({ isAuthenticated: true, sessionExpiresAt: Date.now() + 3600_000, logout: mockLogout, - incrementAuthFailure: jest.fn(), - incrementRefreshFailure: jest.fn(), }); - // Override SSL_PINNING bypass to false so the check runs - const { SSL_PINNING } = require('../src/config/security'); - SSL_PINNING.bypassEnabled = false; + const { sentryContextService } = require('../src/services/sentryContext'); + const { appLogger } = require('../src/utils/logger'); - // Verify the SSL detection conditions are correct: - // 1. Error has code ERR_NETWORK - // 2. SSL_PINNING.bypassEnabled is false - // 3. Error cause contains SSL keywords - const error = makeAxiosError(undefined, 'ERR_NETWORK', 'Network Error', { - url: '/api/data', - method: 'get', - headers: {} as any, - }, { cause: 'javax.net.ssl.SSLHandshakeException' }); - - // Replicate the isCertPinFailure check from axios.config.ts - const msg = (error.message ?? '').toLowerCase(); - const cause = String((error as unknown as { cause?: unknown }).cause ?? '').toLowerCase(); - const isSSLError = - !SSL_PINNING.bypassEnabled && - (msg.includes('ssl') || msg.includes('certificate') || msg.includes('tls') || - cause.includes('sslhandshakeexception') || cause.includes('sslpeerunverifiedexception')); - - expect(error.code).toBe('ERR_NETWORK'); - expect(isSSLError).toBe(true); - expect(mockLogout).not.toHaveBeenCalled(); // Direct detection doesn't call logout - SSL_PINNING.bypassEnabled = true; // Reset + // Re-import with mocks + const axiosConfig = require('../src/services/api/axios.config'); + const interceptor = apiClient.interceptors.response.handlers[1].rejected; + + const error = makeAxiosError( + undefined, + 'ERR_NETWORK', + 'Network Error', + { url: 'https://api.example.com/user' }, + { cause: 'javax.net.ssl.SSLHandshakeException' } + ); + + // Enable SSL pinning for this test + jest.doMock('../src/config/security', () => ({ + SSL_PINNING: { bypassEnabled: false }, + })); + + await expect(interceptor(error)).rejects.toMatchObject({ + code: 'SSL_PIN_FAILURE', + message: 'A security error occurred. Please log in again.', + }); + + expect(mockLogout).toHaveBeenCalledTimes(1); + expect(sentryContextService.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { 'security.event': 'ssl_pin_failure' }, + extra: expect.objectContaining({ isAuthDomain: true }), + fingerprint: ['ssl-pin-failure', 'api.example.com'], + }) + ); + expect(appLogger.errorSync).toHaveBeenCalledWith( + 'SSL pin validation failed — possible MITM attack', + undefined, + expect.objectContaining({ isAuthDomain: true }) + ); + }); + + it('1.1. detects SSL pin failure on non-auth domain and rejects without logout', async () => { + const mockLogout = jest.fn(); + const { useAppStore } = require('../src/store'); + useAppStore.getState.mockReturnValue({ + isAuthenticated: true, + sessionExpiresAt: Date.now() + 3600_000, + logout: mockLogout, + }); + + const { sentryContextService } = require('../src/services/sentryContext'); + const { appLogger } = require('../src/utils/logger'); + + const interceptor = apiClient.interceptors.response.handlers[1].rejected; + + const error = makeAxiosError( + undefined, + 'ERR_NETWORK', + 'Network Error', + { url: 'https://cdn.some-other-domain.com/image.png' }, + { cause: 'javax.net.ssl.SSLHandshakeException' } + ); + + // Enable SSL pinning for this test + jest.doMock('../src/config/security', () => ({ + SSL_PINNING: { bypassEnabled: false }, + })); + + await expect(interceptor(error)).rejects.toMatchObject({ + code: 'SSL_PIN_FAILURE_NON_AUTH', + message: 'A security error occurred with a third-party service. Please try again later.', + }); + + expect(mockLogout).not.toHaveBeenCalled(); + expect(sentryContextService.captureException).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + tags: { 'security.event': 'ssl_pin_failure' }, + extra: expect.objectContaining({ isAuthDomain: false }), + fingerprint: ['ssl-pin-failure', 'cdn.some-other-domain.com'], + }) + ); + expect(appLogger.errorSync).toHaveBeenCalledWith( + 'SSL pin validation failed — possible MITM attack', + undefined, + expect.objectContaining({ isAuthDomain: false }) + ); }); // ── 2. 401 first retry with token refresh ───────────────────────────────── @@ -303,7 +384,11 @@ describe('Issue #838 — axios.config error handling branches', () => { incrementRefreshFailure: jest.fn(), }); - const { saveTokens, getRefreshToken, getAccessToken } = require('../src/services/secureStorage'); + const { + saveTokens, + getRefreshToken, + getAccessToken, + } = require('../src/services/secureStorage'); // Mock adapter: first call returns 401, second (retry after refresh) returns 200 let callCount = 0; @@ -379,17 +464,23 @@ describe('Issue #838 — axios.config error handling branches', () => { message: 'Conflict detected', }; - const error = makeAxiosError(409, undefined, 'Conflict', { - url: '/api/notes/123', - method: 'put', - headers: { - 'X-Last-Known-Version': '3', - 'X-Client-Timestamp': '1234567890', - 'X-Entity-Type': 'note', - 'X-Entity-Id': 'note-123', - } as any, - data: { name: 'Local Version' }, - }, { data: conflictData }); + const error = makeAxiosError( + 409, + undefined, + 'Conflict', + { + url: '/api/notes/123', + method: 'put', + headers: { + 'X-Last-Known-Version': '3', + 'X-Client-Timestamp': '1234567890', + 'X-Entity-Type': 'note', + 'X-Entity-Id': 'note-123', + } as any, + data: { name: 'Local Version' }, + }, + { data: conflictData } + ); const responseData = error.response?.data as any; expect(responseData?.serverVersionNumber).toBe(5); @@ -406,7 +497,8 @@ describe('Issue #838 — axios.config error handling branches', () => { for (let i = 0; i < RATE_LIMIT_DELAYS.length; i++) { retryCount++; const delayIndex = retryCount - 1; - const delayTime = RATE_LIMIT_DELAYS[delayIndex] || RATE_LIMIT_DELAYS[RATE_LIMIT_DELAYS.length - 1]; + const delayTime = + RATE_LIMIT_DELAYS[delayIndex] || RATE_LIMIT_DELAYS[RATE_LIMIT_DELAYS.length - 1]; delays.push(delayTime); } @@ -456,8 +548,7 @@ describe('Issue #838 — axios.config error handling branches', () => { }); const isUpload = - error.config?.method?.toUpperCase() === 'POST' && - error.config?.data instanceof FormData; + error.config?.method?.toUpperCase() === 'POST' && error.config?.data instanceof FormData; const message = isUpload ? 'Upload timed out. Please check your connection and try again.' diff --git a/tests/unhandledRejection.test.ts b/tests/unhandledRejection.test.ts new file mode 100644 index 0000000..317bdf0 --- /dev/null +++ b/tests/unhandledRejection.test.ts @@ -0,0 +1,46 @@ +import { crashReportingService } from '../src/services/crashReporting'; +import { appLogger } from '../src/utils/logger'; + +jest.mock('../src/utils/logger', () => ({ + appLogger: { + errorSync: jest.fn(), + }, +})); + +jest.mock('../src/services/crashReporting', () => ({ + crashReportingService: { + reportError: jest.fn(), + }, +})); + +describe('Unhandled Promise Rejection', () => { + let globalHandler: (error: Error, isFatal?: boolean) => void; + + beforeAll(() => { + globalHandler = ErrorUtils.getGlobalHandler(); + }); + + afterEach(() => { + ErrorUtils.setGlobalHandler(globalHandler); + jest.clearAllMocks(); + }); + + it('should be captured by the global error handler', done => { + const testError = new Error('Test unhandled rejection'); + + ErrorUtils.setGlobalHandler((error, isFatal) => { + if (!isFatal) { + appLogger.errorSync('Unhandled Promise Rejection', error); + crashReportingService.reportError(error, 'UnhandledPromiseRejection'); + expect(appLogger.errorSync).toHaveBeenCalledWith('Unhandled Promise Rejection', testError); + expect(crashReportingService.reportError).toHaveBeenCalledWith( + testError, + 'UnhandledPromiseRejection' + ); + done(); + } + }); + + Promise.reject(testError); + }); +});