diff --git a/.github/workflows/js-unit-tests.yml b/.github/workflows/js-unit-tests.yml index 0c83ed3f8..b0ddbaa30 100644 --- a/.github/workflows/js-unit-tests.yml +++ b/.github/workflows/js-unit-tests.yml @@ -24,8 +24,7 @@ jobs: js-unit-tests: runs-on: ubuntu-latest timeout-minutes: 15 - # TODO: remove continue-on-error after all test failures are fixed. - continue-on-error: true + continue-on-error: false env: TURBO_SCM_BASE: ${{ inputs.turbo_scm_base }} TURBO_SCM_HEAD: ${{ inputs.turbo_scm_head }} diff --git a/PingTestRunner/__tests__/integration/device-client.test.ts b/PingTestRunner/__tests__/integration/device-client.test.ts index 156261f0d..5a1bd54f1 100644 --- a/PingTestRunner/__tests__/integration/device-client.test.ts +++ b/PingTestRunner/__tests__/integration/device-client.test.ts @@ -292,7 +292,11 @@ describe('@ping-identity/rn-device-client — integration', () => { }); const mod = await loadDeviceClient(native); const client = mod.createDeviceClient(VALID_CONFIG); - await expect(client.oath.get()).rejects.toEqual(nativeError); + await expect(client.oath.get()).rejects.toMatchObject({ + code: nativeError.code, + message: nativeError.message, + status: nativeError.status, + }); }); }); diff --git a/PingTestRunner/__tests__/integration/external-idp.test.ts b/PingTestRunner/__tests__/integration/external-idp.test.ts index 98abbfc79..767e5270a 100644 --- a/PingTestRunner/__tests__/integration/external-idp.test.ts +++ b/PingTestRunner/__tests__/integration/external-idp.test.ts @@ -58,6 +58,23 @@ async function loadExternalIdp(nativeMock: NativeExternalIdpMock) { return require('@ping-identity/rn-external-idp'); } +async function loadExternalIdpWithRealHelpers( + nativeMock: NativeExternalIdpMock, +) { + jest.resetModules(); + jest.doMock( + '../../../packages/external-idp/src/NativeRNPingExternalIdp', + () => ({ + ...jest.requireActual( + '../../../packages/external-idp/src/NativeRNPingExternalIdp', + ), + getNativeModule: jest.fn(() => nativeMock), + }), + ); + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@ping-identity/rn-external-idp'); +} + describe('@ping-identity/rn-external-idp — integration', () => { afterEach(() => jest.restoreAllMocks()); @@ -273,6 +290,67 @@ describe('@ping-identity/rn-external-idp — integration', () => { }); }); + // ─── fromNativeAuthorizeResult — real field validation ──────────────────── + // + // Uses jest.requireActual so the real fromNativeAuthorizeResult runs instead of + // an identity stub. The helper reads .token and validates .additionalParameters, + // so a bridge-side field rename is caught here without a device or simulator. + + describe('fromNativeAuthorizeResult — real field validation', () => { + it('extracts token from native result', async () => { + const mock = makeMock({ + authorizeForJourney: jest.fn(async () => ({ token: 'tok-1' })), + }); + const mod = await loadExternalIdpWithRealHelpers(mock); + const client = mod.createExternalIdpClient({}); + const journey = { getId: jest.fn(async () => 'j-1') }; + const result = await client.authorizeForJourney(journey); + expect(result.token).toBe('tok-1'); + }); + + it('extracts token and additionalParameters from native result', async () => { + const mock = makeMock({ + authorizeForJourney: jest.fn(async () => ({ + token: 'tok-2', + additionalParameters: { key: 'val' }, + })), + }); + const mod = await loadExternalIdpWithRealHelpers(mock); + const client = mod.createExternalIdpClient({}); + const journey = { getId: jest.fn(async () => 'j-2') }; + const result = await client.authorizeForJourney(journey); + expect(result.token).toBe('tok-2'); + expect(result.additionalParameters).toEqual({ key: 'val' }); + }); + + it('throws when native result is missing token', async () => { + const mock = makeMock({ + authorizeForJourney: jest.fn(async () => ({})), + }); + const mod = await loadExternalIdpWithRealHelpers(mock); + const client = mod.createExternalIdpClient({}); + const journey = { getId: jest.fn(async () => 'j-3') }; + await expect(client.authorizeForJourney(journey)).rejects.toThrow( + 'token must be a string', + ); + }); + + it('throws when additionalParameters contains a non-string value', async () => { + const mock = makeMock({ + authorizeForJourney: jest.fn(async () => ({ + token: 'tok-4', + additionalParameters: { k: 123 }, + })), + }); + const mod = await loadExternalIdpWithRealHelpers(mock); + const client = mod.createExternalIdpClient({}); + const journey = { getId: jest.fn(async () => 'j-4') }; + await expect(client.authorizeForJourney(journey)).rejects.toThrow( + 'additionalParameters.k must be a string', + ); + }); + }); + // ─── selectProviderForJourney() ─────────────────────────────────────────── describe('selectProviderForJourney()', () => { diff --git a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts index 9353d71d2..3513fb6e4 100644 --- a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts +++ b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts @@ -24,13 +24,16 @@ import type { Spec as BindingSpec } from '../../../packages/binding/src/NativeRNPingBinding'; import type { Spec as BrowserSpec } from '../../../packages/browser/src/NativeRNPingBrowser'; +import type { Spec as DeviceClientSpec } from '../../../packages/device-client/src/NativeRNPingDeviceClient'; import type { Spec as DeviceIdSpec } from '../../../packages/device-id/src/NativeRNPingDeviceId'; import type { Spec as DeviceProfileSpec } from '../../../packages/device-profile/src/NativeRNPingDeviceProfile'; +import type { Spec as ExternalIdpSpec } from '../../../packages/external-idp/src/NativeRNPingExternalIdp'; import type { Spec as FidoSpec } from '../../../packages/fido/src/NativeRNPingFido'; import type { Spec as JourneySpec } from '../../../packages/journey/src/NativeRNPingJourney'; import type { Spec as LoggerSpec } from '../../../packages/logger/src/NativeRNPingLogger'; import type { Spec as OathSpec } from '../../../packages/oath/src/NativeRNPingOath'; import type { Spec as OidcSpec } from '../../../packages/oidc/src/NativeRNPingOidc'; +import type { Spec as PushSpec } from '../../../packages/push/src/NativeRNPingPush'; import type { Spec as StorageSpec } from '../../../packages/storage/src/NativeRNPingStorage'; // ─── rn-binding ───────────────────────────────────────────────────────────── @@ -53,6 +56,13 @@ type _BindingMockedMethods = Pick< // jest.setup.js mocks: configure, reset, open type _BrowserMockedMethods = Pick; +// ─── rn-device-client ──────────────────────────────────────────────────────── +// jest.setup.js mocks: create, get, update, deleteDevice, dispose +type _DeviceClientMockedMethods = Pick< + DeviceClientSpec, + 'create' | 'get' | 'update' | 'deleteDevice' | 'dispose' +>; + // ─── rn-device-id ─────────────────────────────────────────────────────────── // jest.setup.js mocks: getDefaultDeviceId type _DeviceIdMockedMethods = Pick; @@ -64,11 +74,22 @@ type _DeviceProfileMockedMethods = Pick< 'collectDeviceProfile' | 'collectDeviceProfileForJourney' >; +// ─── rn-external-idp ───────────────────────────────────────────────────────── +// jest.setup.js mocks: authorizeForJourney, selectProviderForJourney +type _ExternalIdpMockedMethods = Pick< + ExternalIdpSpec, + 'authorizeForJourney' | 'selectProviderForJourney' +>; + // ─── rn-fido ──────────────────────────────────────────────────────────────── -// jest.setup.js mocks: registerCredential, authenticateCredential +// jest.setup.js mocks: registerCredential, authenticateCredential, +// registerCredentialForJourney, authenticateCredentialForJourney type _FidoMockedMethods = Pick< FidoSpec, - 'registerCredential' | 'authenticateCredential' + | 'registerCredential' + | 'authenticateCredential' + | 'registerCredentialForJourney' + | 'authenticateCredentialForJourney' >; // ─── rn-journey ───────────────────────────────────────────────────────────── @@ -133,6 +154,33 @@ type _OidcMockedMethods = Pick< | 'logout' >; +// ─── rn-push ───────────────────────────────────────────────────────────────── +// jest.setup.js mocks: all 21 bridge methods +type _PushMockedMethods = Pick< + PushSpec, + | 'initialize' + | 'addCredentialFromUri' + | 'getCredential' + | 'getCredentials' + | 'saveCredential' + | 'deleteCredential' + | 'setDeviceToken' + | 'getDeviceToken' + | 'processNotification' + | 'processNotificationFromMessage' + | 'approveNotification' + | 'approveChallengeNotification' + | 'approveBiometricNotification' + | 'denyNotification' + | 'getPendingNotifications' + | 'getAllNotifications' + | 'getNotification' + | 'cleanupNotifications' + | 'close' + | 'consumePendingMessages' + | 'refreshToken' +>; + // ─── rn-storage ───────────────────────────────────────────────────────────── // jest.setup.js mocks: registerSessionStorage, configureSessionStorage, // registerOidcStorage, configureOidcStorage diff --git a/PingTestRunner/__tests__/integration/oath.test.ts b/PingTestRunner/__tests__/integration/oath.test.ts index af2bb50dd..59707ae3e 100644 --- a/PingTestRunner/__tests__/integration/oath.test.ts +++ b/PingTestRunner/__tests__/integration/oath.test.ts @@ -213,7 +213,7 @@ describe('@ping-identity/rn-oath — integration', () => { await client.close(); await expect(client.generateCode('cred-1')).rejects.toMatchObject({ type: 'state_error', - error: 'OATH_STATE_ERROR', + code: 'OATH_STATE_ERROR', }); }); }); @@ -337,7 +337,7 @@ describe('configureOathPolicyEvaluator', () => { } expect(thrown).toMatchObject({ type: 'argument_error', - error: 'OATH_INVALID_PARAMETER', + code: 'OATH_INVALID_PARAMETER', }); }); @@ -354,7 +354,7 @@ describe('configureOathPolicyEvaluator', () => { } expect(thrown).toMatchObject({ type: 'argument_error', - error: 'OATH_INVALID_PARAMETER', + code: 'OATH_INVALID_PARAMETER', }); expect(native.registerOathPolicyEvaluator).not.toHaveBeenCalled(); }); diff --git a/PingTestRunner/__tests__/integration/push.test.ts b/PingTestRunner/__tests__/integration/push.test.ts index 7212fd6dd..02fc3657b 100644 --- a/PingTestRunner/__tests__/integration/push.test.ts +++ b/PingTestRunner/__tests__/integration/push.test.ts @@ -189,6 +189,45 @@ async function loadPush( return { mod, emittedHandlers }; } +async function loadPushWithRealHelpers( + nativeMock: NativePushMock, + platform: 'ios' | 'android' = 'ios', +): Promise<{ + mod: ReturnType; + emittedHandlers: EventHandlers; +}> { + jest.resetModules(); + const emittedHandlers: EventHandlers = {}; + jest.doMock('react-native', () => ({ + Platform: { + OS: platform, + select: (s: Record) => s[platform] ?? s.default, + }, + NativeModules: {}, + TurboModuleRegistry: { + get: jest.fn(() => null), + getEnforcing: jest.fn(() => null), + }, + DeviceEventEmitter: { + addListener: jest.fn( + (eventName: string, handler: (...args: unknown[]) => void) => { + emittedHandlers[eventName] = handler; + return { remove: jest.fn() }; + }, + ), + }, + })); + // Use real fromNative* helpers — only override getNativeModule so the actual + // field-reading code (fromNativeWrappedCredential, fromNativeToken, etc.) runs. + jest.doMock('../../../packages/push/src/NativeRNPingPush', () => ({ + ...jest.requireActual('../../../packages/push/src/NativeRNPingPush'), + getNativeModule: jest.fn(() => nativeMock), + })); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mod = require('@ping-identity/rn-push'); + return { mod, emittedHandlers }; +} + describe('@ping-identity/rn-push — integration', () => { afterEach(() => jest.restoreAllMocks()); @@ -362,9 +401,10 @@ describe('@ping-identity/rn-push — integration', () => { }); const { mod } = await loadPush(mock); const client = await mod.createPushClient(); - await expect(client.addCredentialFromUri('bad')).rejects.toEqual( - nativeError, - ); + await expect(client.addCredentialFromUri('bad')).rejects.toMatchObject({ + type: nativeError.type, + code: nativeError.error, + }); }); }); @@ -402,7 +442,10 @@ describe('@ping-identity/rn-push — integration', () => { }); const { mod } = await loadPush(mock); const client = await mod.createPushClient(); - await expect(client.getCredentials()).rejects.toEqual(nativeError); + await expect(client.getCredentials()).rejects.toMatchObject({ + type: nativeError.type, + code: nativeError.error, + }); }); }); @@ -453,9 +496,10 @@ describe('@ping-identity/rn-push — integration', () => { }); const { mod } = await loadPush(mock); const client = await mod.createPushClient(); - await expect(client.deleteCredential('cred-1')).rejects.toEqual( - nativeError, - ); + await expect(client.deleteCredential('cred-1')).rejects.toMatchObject({ + type: nativeError.type, + code: nativeError.error, + }); }); }); @@ -670,8 +714,11 @@ describe('@ping-identity/rn-push — integration', () => { }); const { mod } = await loadPush(mock); const client = await mod.createPushClient(); - await expect(client.approveNotification('notif-1')).rejects.toEqual( - nativeError, + await expect(client.approveNotification('notif-1')).rejects.toMatchObject( + { + type: nativeError.type, + code: nativeError.error, + }, ); }); }); @@ -700,7 +747,7 @@ describe('@ping-identity/rn-push — integration', () => { client.approveChallengeNotification('notif-1', ' '), ).rejects.toMatchObject({ type: 'argument_error', - error: 'invalid_parameter_value', + code: 'invalid_parameter_value', }); expect(mock.approveChallengeNotification).not.toHaveBeenCalled(); }); @@ -872,7 +919,10 @@ describe('@ping-identity/rn-push — integration', () => { }); const { mod } = await loadPush(mock); const client = await mod.createPushClient(); - await expect(client.close()).rejects.toEqual(nativeError); + await expect(client.close()).rejects.toMatchObject({ + type: nativeError.type, + code: nativeError.error, + }); }); }); @@ -919,7 +969,7 @@ describe('@ping-identity/rn-push — integration', () => { // ─── error shape preservation ───────────────────────────────────────────────── describe('PushError shape preservation', () => { - it('passes native PushError { type, error, message } through rejection unchanged', async () => { + it('wraps native { type, error, message } into PushError with .code', async () => { const nativePushError = { type: 'state_error', error: 'not_initialized', @@ -934,7 +984,11 @@ describe('@ping-identity/rn-push — integration', () => { const client = await mod.createPushClient(); await expect( client.addCredentialFromUri('pushauth://test'), - ).rejects.toMatchObject(nativePushError); + ).rejects.toMatchObject({ + type: nativePushError.type, + code: nativePushError.error, + message: nativePushError.message, + }); }); it('passes network_failure error shape through rejection', async () => { @@ -951,9 +1005,138 @@ describe('@ping-identity/rn-push — integration', () => { const { mod } = await loadPush(mock); const client = await mod.createPushClient(); await expect(client.approveNotification('notif-1')).rejects.toMatchObject( - nativeError, + { + type: nativeError.type, + code: nativeError.error, + message: nativeError.message, + }, + ); + }); + }); + + // ─── encode/decode — real bridge helpers ──────────────────────────────────── + // + // These tests use jest.requireActual on NativeRNPingPush so the real fromNative* + // functions execute rather than identity stubs. This catches any rename of the + // bridge wrapper keys (.credential, .credentials, .notification, .notifications, + // .token) without requiring a device or simulator. + + describe('encode/decode — real bridge helpers', () => { + it('getCredentials() — real fromNativeCredentialList reads .credentials key', async () => { + const mock = makeMock({ + getCredentials: jest.fn(async () => ({ + credentials: [mockCredential], + })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + const result = await client.getCredentials(); + expect(result).toEqual([mockCredential]); + }); + + it('getCredentials() — returns [] when .credentials is empty', async () => { + const mock = makeMock({ + getCredentials: jest.fn(async () => ({ credentials: [] })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getCredentials()).toEqual([]); + }); + + it('getCredential() — real fromNativeWrappedCredential reads .credential key', async () => { + const mock = makeMock({ + getCredential: jest.fn(async () => ({ credential: mockCredential })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getCredential('cred-1')).toEqual(mockCredential); + }); + + it('getCredential() — returns null when .credential is null', async () => { + const mock = makeMock({ + getCredential: jest.fn(async () => ({ credential: null })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getCredential('missing')).toBeNull(); + }); + + it('getDeviceToken() — real fromNativeToken reads .token key', async () => { + const mock = makeMock({ + getDeviceToken: jest.fn(async () => ({ token: 'real-token-abc' })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getDeviceToken()).toBe('real-token-abc'); + }); + + it('getDeviceToken() — returns null when .token is null', async () => { + const mock = makeMock({ + getDeviceToken: jest.fn(async () => ({ token: null })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getDeviceToken()).toBeNull(); + }); + + it('processNotification() — real fromNativeNotification reads .notification key', async () => { + const mock = makeMock({ + processNotification: jest.fn(async () => ({ + notification: mockNotification, + })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.processNotification({})).toEqual(mockNotification); + }); + + it('processNotification() — returns null when .notification is null', async () => { + const mock = makeMock({ + processNotification: jest.fn(async () => ({ notification: null })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.processNotification({})).toBeNull(); + }); + + it('getAllNotifications() — real fromNativeNotificationList reads .notifications key', async () => { + const mock = makeMock({ + getAllNotifications: jest.fn(async () => ({ + notifications: [mockNotification], + })), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.getAllNotifications()).toEqual([mockNotification]); + }); + + it('addCredentialFromUri() — fromNativeCredential is a type cast; result passes through', async () => { + const mock = makeMock({ + addCredentialFromUri: jest.fn(async () => mockCredential), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + expect(await client.addCredentialFromUri('pushauth://enroll')).toEqual( + mockCredential, ); }); + + it('native error type field is preserved on rejection', async () => { + const mock = makeMock({ + getCredentials: jest.fn(async () => { + throw { + type: 'state_error', + error: 'not_initialized', + message: 'Not init', + }; + }), + }); + const { mod } = await loadPushWithRealHelpers(mock); + const client = await mod.createPushClient(); + await expect(client.getCredentials()).rejects.toMatchObject({ + type: 'state_error', + }); + }); }); // ─── BYO push library path ─────────────────────────────────────────────────── diff --git a/PingTestRunner/jest.setup.js b/PingTestRunner/jest.setup.js index c953da607..8667f9739 100644 --- a/PingTestRunner/jest.setup.js +++ b/PingTestRunner/jest.setup.js @@ -52,6 +52,22 @@ jest.mock('../packages/binding/src/NativeRNPingBinding', () => ({ fromNativeUserKeys: jest.fn((result) => result), })); +// ---------- rn-device-client ---------- +jest.mock('../packages/device-client/src/NativeRNPingDeviceClient', () => ({ + __esModule: true, + getNativeModule: jest.fn(() => ({ + create: jest.fn(async () => 'device-client-handle-mock'), + get: jest.fn(async () => ({ result: [] })), + update: jest.fn(async () => ({ + result: { id: 'mock', deviceName: 'Mock' }, + })), + deleteDevice: jest.fn(async () => ({ + result: { id: 'mock', deviceName: 'Mock' }, + })), + dispose: jest.fn(async () => undefined), + })), +})); + // ---------- rn-browser ---------- jest.mock('../packages/browser/src/NativeRNPingBrowser', () => ({ __esModule: true, @@ -206,6 +222,43 @@ jest.mock('../packages/external-idp/src/NativeRNPingExternalIdp', () => ({ fromNativeAuthorizeResult: jest.fn((result) => result), })); +// ---------- rn-push ---------- +jest.mock('../packages/push/src/NativeRNPingPush', () => ({ + __esModule: true, + getNativeModule: jest.fn(() => ({ + initialize: jest.fn(async () => 'push-client-handle-mock'), + addCredentialFromUri: jest.fn(async () => ({})), + getCredential: jest.fn(async () => ({ credential: null })), + getCredentials: jest.fn(async () => ({ credentials: [] })), + saveCredential: jest.fn(async () => ({})), + deleteCredential: jest.fn(async () => true), + setDeviceToken: jest.fn(async () => true), + getDeviceToken: jest.fn(async () => ({ token: null })), + processNotification: jest.fn(async () => ({ notification: null })), + processNotificationFromMessage: jest.fn(async () => ({ + notification: null, + })), + approveNotification: jest.fn(async () => true), + approveChallengeNotification: jest.fn(async () => true), + approveBiometricNotification: jest.fn(async () => true), + denyNotification: jest.fn(async () => true), + getPendingNotifications: jest.fn(async () => ({ notifications: [] })), + getAllNotifications: jest.fn(async () => ({ notifications: [] })), + getNotification: jest.fn(async () => ({ notification: null })), + cleanupNotifications: jest.fn(async () => 0), + close: jest.fn(async () => undefined), + consumePendingMessages: jest.fn(async () => []), + refreshToken: jest.fn(async () => ({ token: null })), + })), + toNativePushConfig: jest.fn((config) => config), + fromNativeCredential: jest.fn((result) => result), + fromNativeWrappedCredential: jest.fn((w) => w?.credential ?? null), + fromNativeNotification: jest.fn((r) => r?.notification ?? null), + fromNativeCredentialList: jest.fn((r) => r?.credentials ?? []), + fromNativeNotificationList: jest.fn((r) => r?.notifications ?? []), + fromNativeToken: jest.fn((w) => w?.token ?? null), +})); + // ---------- rn-storage ---------- jest.mock('../packages/storage/src/NativeRNPingStorage', () => ({ __esModule: true, diff --git a/packages/binding/android/build.gradle b/packages/binding/android/build.gradle index 664f00594..b13a0cc7d 100644 --- a/packages/binding/android/build.gradle +++ b/packages/binding/android/build.gradle @@ -91,5 +91,6 @@ dependencies { implementation(project(":ping-identity_rn-core")) testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.12" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0" testImplementation "org.robolectric:robolectric:4.11.1" } diff --git a/packages/binding/android/src/test/java/com/pingidentity/rnbinding/RNPingBindingTest.kt b/packages/binding/android/src/test/java/com/pingidentity/rnbinding/RNPingBindingTest.kt index addc2a614..fd12b9daf 100644 --- a/packages/binding/android/src/test/java/com/pingidentity/rnbinding/RNPingBindingTest.kt +++ b/packages/binding/android/src/test/java/com/pingidentity/rnbinding/RNPingBindingTest.kt @@ -7,14 +7,28 @@ package com.pingidentity.rnbinding import androidx.biometric.BiometricPrompt +import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.facebook.soloader.SoLoader import com.facebook.soloader.nativeloader.NativeLoader import com.facebook.soloader.nativeloader.SystemDelegate +import com.pingidentity.device.binding.UserKey +import com.pingidentity.device.binding.UserKeysStorage +import com.pingidentity.device.binding.authenticator.DeviceBindingAuthenticationType import com.pingidentity.device.binding.authenticator.exception.BiometricAuthenticationException +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import org.junit.Assert.assertArrayEquals @@ -30,12 +44,15 @@ import org.junit.runner.RunWith import org.robolectric.RuntimeEnvironment import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements /** * Unit tests for Binding module metadata and bridge behavior. */ +@OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -@Config(sdk = [29]) +@Config(sdk = [29], shadows = [ShadowBindingArguments::class]) class RNPingBindingTest { @Before @@ -43,11 +60,13 @@ class RNPingBindingTest { runCatching { SoLoader.init(RuntimeEnvironment.getApplication(), false) } runCatching { NativeLoader.init(SystemDelegate()) } RNPingBindingCommon.foregroundActivityProvider = { true } + Dispatchers.setMain(UnconfinedTestDispatcher()) } @After fun tearDown() { RNPingBindingCommon.foregroundActivityProvider = { true } + Dispatchers.resetMain() } // MARK: - Error code contracts @@ -741,6 +760,142 @@ class RNPingBindingTest { assertNull(parsed.jwtIssueTimeEpochSeconds) } + // MARK: - getAllKeys bridge key contract + + /** + * getAllKeys serialises "id" from UserKey.id. + * Catches any rename of the bridge key (e.g. "id" → "keyId"). + */ + @Test + fun getAllKeys_serializesIdField() { + val key = UserKey( + id = "key-id-1", + userId = "user-1", + userName = "alice", + kid = "kid-1", + authType = DeviceBindingAuthenticationType.NONE, + createdAt = 0L, + ) + val storage = mockk() + coEvery { storage.findAll() } returns listOf(key) + injectUserKeysStorage(storage) + + val promise = TestPromise() + RNPingBindingCommon.getAllKeys(promise) + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals(1, array.size()) + assertEquals("key-id-1", array.getMap(0)?.getString("id")) + } + + /** + * getAllKeys serialises "userId" from UserKey.userId. + */ + @Test + fun getAllKeys_serializesUserIdField() { + val key = UserKey( + id = "key-id-2", + userId = "user-42", + userName = "bob", + kid = "kid-2", + authType = DeviceBindingAuthenticationType.NONE, + createdAt = 0L, + ) + val storage = mockk() + coEvery { storage.findAll() } returns listOf(key) + injectUserKeysStorage(storage) + + val promise = TestPromise() + RNPingBindingCommon.getAllKeys(promise) + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals("user-42", array.getMap(0)?.getString("userId")) + } + + /** + * getAllKeys serialises bridge key "username" (not "userName"). + * The SDK field is userName but the bridge key is username — a critical naming discrepancy. + */ + @Test + fun getAllKeys_serializesUsernameBridgeKey() { + val key = UserKey( + id = "key-id-3", + userId = "user-3", + userName = "charlie", + kid = "kid-3", + authType = DeviceBindingAuthenticationType.NONE, + createdAt = 0L, + ) + val storage = mockk() + coEvery { storage.findAll() } returns listOf(key) + injectUserKeysStorage(storage) + + val promise = TestPromise() + RNPingBindingCommon.getAllKeys(promise) + promise.await() + + val array = promise.resolvedValue as ReadableArray + val map = array.getMap(0)!! + assertEquals("charlie", map.getString("username")) + // Confirm the old SDK field name is NOT present as a bridge key + assertFalse("userName must not appear as a bridge key", map.hasKey("userName")) + } + + /** + * getAllKeys serialises "authenticationType" as the uppercase enum name from authType.name. + */ + @Test + fun getAllKeys_serializesAuthenticationTypeAsUppercaseEnumName() { + val key = UserKey( + id = "key-id-4", + userId = "user-4", + userName = "dave", + kid = "kid-4", + authType = DeviceBindingAuthenticationType.BIOMETRIC_ONLY, + createdAt = 0L, + ) + val storage = mockk() + coEvery { storage.findAll() } returns listOf(key) + injectUserKeysStorage(storage) + + val promise = TestPromise() + RNPingBindingCommon.getAllKeys(promise) + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals("BIOMETRIC_ONLY", array.getMap(0)?.getString("authenticationType")) + } + + /** + * getAllKeys resolves with an empty array when no keys are stored. + */ + @Test + fun getAllKeys_resolvesEmptyArrayWhenNoKeys() { + val storage = mockk() + coEvery { storage.findAll() } returns emptyList() + injectUserKeysStorage(storage) + + val promise = TestPromise() + RNPingBindingCommon.getAllKeys(promise) + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals(0, array.size()) + } + + private fun injectUserKeysStorage(storage: UserKeysStorage) { + val delegateField = RNPingBindingCommon::class.java.getDeclaredField("userKeysStorage\$delegate") + delegateField.isAccessible = true + val delegate = delegateField.get(RNPingBindingCommon) + // SynchronizedLazyImpl stores its computed value in the _value field. + // Setting it directly bypasses the lock and forces the lazy to return our mock. + val valueField = delegate.javaClass.getDeclaredField("_value") + valueField.isAccessible = true + valueField.set(delegate, storage) + } + private fun invokePrivate(name: String, vararg args: Any?): Any? { val candidates = listOf( RNPingBindingCommon::class.java, @@ -853,3 +1008,14 @@ class RNPingBindingTest { } } } + +@Implements(className = "com.facebook.react.bridge.Arguments") +object ShadowBindingArguments { + @Implementation + @JvmStatic + fun createMap(): WritableMap = JavaOnlyMap() + + @Implementation + @JvmStatic + fun createArray(): WritableArray = JavaOnlyArray() +} diff --git a/packages/binding/ios/RNPingBindingCommon.swift b/packages/binding/ios/RNPingBindingCommon.swift index eff5594e1..e0b477487 100644 --- a/packages/binding/ios/RNPingBindingCommon.swift +++ b/packages/binding/ios/RNPingBindingCommon.swift @@ -295,9 +295,7 @@ public class RNPingBindingCommon: NSObject { Task { do { let keys = try await BindingModule.getAllKeys() - let result: NSArray = keys.map { key -> [String: Any] in - ["id": key.id, "userId": key.userId, "username": key.username, "authenticationType": key.authType.rawValue] - } as NSArray + let result: NSArray = keys.map { RNPingBindingCommon.serializeUserKey($0) } as NSArray handlers.resolve(result) } catch { handlers.reject( @@ -308,6 +306,13 @@ public class RNPingBindingCommon: NSObject { } } + /// Converts a `UserKey` into a bridge-safe dictionary for the JS layer. + /// + /// Extracted from the `getAllKeys` inline closure so tests can assert bridge key names directly. + static func serializeUserKey(_ key: UserKey) -> [String: Any] { + ["id": key.id, "userId": key.userId, "username": key.username, "authenticationType": key.authType.rawValue] + } + /// Deletes the device binding key identified by `userId` and `keyId` from the Keychain. /// /// - Parameters: diff --git a/packages/binding/ios/Tests/RNPingBindingCommonTests.swift b/packages/binding/ios/Tests/RNPingBindingCommonTests.swift index 3f1448328..69f1c0d6a 100644 --- a/packages/binding/ios/Tests/RNPingBindingCommonTests.swift +++ b/packages/binding/ios/Tests/RNPingBindingCommonTests.swift @@ -523,6 +523,40 @@ final class RNPingBindingCommonTests: XCTestCase { XCTAssertEqual(code, "BINDING_AUTH_FAILED") } + // MARK: - serializeUserKey field-name contracts + + func testSerializeUserKey_idFieldIsKid() { + let key = UserKey(keyTag: "tag", userId: "u1", username: "alice", kid: "kid-123", authType: .biometricOnly) + let dict = RNPingBindingCommon.serializeUserKey(key) + XCTAssertEqual(dict["id"] as? String, "kid-123") + } + + func testSerializeUserKey_userIdField() { + let key = UserKey(keyTag: "tag", userId: "user-abc", username: "alice", kid: "kid-1", authType: .biometricOnly) + let dict = RNPingBindingCommon.serializeUserKey(key) + XCTAssertEqual(dict["userId"] as? String, "user-abc") + } + + func testSerializeUserKey_usernameKeyIsLowercase() { + // Bridge key must be "username" not "userName" + let key = UserKey(keyTag: "tag", userId: "u1", username: "alice", kid: "kid-1", authType: .biometricOnly) + let dict = RNPingBindingCommon.serializeUserKey(key) + XCTAssertEqual(dict["username"] as? String, "alice") + XCTAssertNil(dict["userName"]) + } + + func testSerializeUserKey_authenticationTypeIsUppercasedRawValue() { + let key = UserKey(keyTag: "tag", userId: "u1", username: "alice", kid: "kid-1", authType: .biometricOnly) + let dict = RNPingBindingCommon.serializeUserKey(key) + XCTAssertEqual(dict["authenticationType"] as? String, "BIOMETRIC_ONLY") + } + + func testSerializeUserKey_noExtraFields() { + let key = UserKey(keyTag: "tag", userId: "u1", username: "alice", kid: "kid-1", authType: .biometricOnly) + let dict = RNPingBindingCommon.serializeUserKey(key) + XCTAssertEqual((dict as NSDictionary).count, 4) + } + // MARK: - Key Management func testGetAllKeysResolvesWithArray() async { diff --git a/packages/binding/jest.config.js b/packages/binding/jest.config.js index 7132db90d..b281a75d9 100644 --- a/packages/binding/jest.config.js +++ b/packages/binding/jest.config.js @@ -25,4 +25,7 @@ module.exports = { }, ], ], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'], + coverageDirectory: './build/coverage', + coverageReporters: ['lcov', 'text-summary'], }; diff --git a/packages/device-client/jest.config.js b/packages/device-client/jest.config.js index 7132db90d..b281a75d9 100644 --- a/packages/device-client/jest.config.js +++ b/packages/device-client/jest.config.js @@ -25,4 +25,7 @@ module.exports = { }, ], ], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'], + coverageDirectory: './build/coverage', + coverageReporters: ['lcov', 'text-summary'], }; diff --git a/packages/fido/jest.config.js b/packages/fido/jest.config.js index 7132db90d..b281a75d9 100644 --- a/packages/fido/jest.config.js +++ b/packages/fido/jest.config.js @@ -25,4 +25,7 @@ module.exports = { }, ], ], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'], + coverageDirectory: './build/coverage', + coverageReporters: ['lcov', 'text-summary'], }; diff --git a/packages/oath/android/src/test/java/com/pingidentity/rnoath/RNPingOathCommonTest.kt b/packages/oath/android/src/test/java/com/pingidentity/rnoath/RNPingOathCommonTest.kt index 73cfa9c59..42e945a05 100644 --- a/packages/oath/android/src/test/java/com/pingidentity/rnoath/RNPingOathCommonTest.kt +++ b/packages/oath/android/src/test/java/com/pingidentity/rnoath/RNPingOathCommonTest.kt @@ -9,14 +9,19 @@ package com.pingidentity.rnoath import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.pingidentity.mfa.commons.policy.BiometricAvailablePolicy import com.pingidentity.mfa.commons.policy.DeviceTamperingPolicy import com.pingidentity.mfa.commons.policy.MfaPolicyEvaluator import com.pingidentity.mfa.oath.OathClient +import com.pingidentity.mfa.oath.OathCodeInfo import com.pingidentity.mfa.oath.OathConfiguration import com.pingidentity.mfa.oath.OathCredential +import com.pingidentity.mfa.oath.OathAlgorithm +import com.pingidentity.mfa.oath.OathType import com.pingidentity.mfa.oath.storage.OathStorage import com.pingidentity.rncore.CoreRuntime import com.pingidentity.rncore.storage.OathStorageConfigHandleContract @@ -599,6 +604,220 @@ class RNPingOathCommonTest { CoreRuntime.oathPolicyEvaluatorRegistry.removeAll() } + + // --------------------------------------------------------------------------- + // encodeCredential bridge key contract + // + // Tests call getCredentials() (which calls the private encodeCredential) via a + // mocked OathClient so we can assert the exact WritableMap keys without native + // SQLite initialisation. A rename of any key here breaks the JS TypeScript types. + // --------------------------------------------------------------------------- + + private fun createHandleWithMockedClient(mockClient: OathClient): String { + mockkObject(OathClient.Companion) + coEvery { OathClient.Companion.invoke(any()) } returns mockClient + val createPromise = TestPromise() + RNPingOathCommon.create(JavaOnlyMap(), createPromise) + createPromise.await() + unmockkObject(OathClient.Companion) + return createPromise.resolvedValue as String + } + + private fun buildTestCredential( + id: String = "cred-enc-1", + oathType: OathType = OathType.TOTP, + oathAlgorithm: OathAlgorithm = OathAlgorithm.SHA1, + ): OathCredential = OathCredential( + id = id, + userId = null, + resourceId = null, + issuer = "Ping", + displayIssuer = "Ping Identity", + accountName = "user@example.com", + displayAccountName = "User", + oathType = oathType, + secret = "SECRET", + oathAlgorithm = oathAlgorithm, + digits = 6, + period = 30, + counter = 0L, + createdAt = java.util.Date(1_700_000_000_000L), + imageURL = null, + backgroundColor = null, + policies = null, + lockingPolicy = null, + isLocked = false, + ) + + @Test + fun encodeCredential_serializesIdField() { + val credential = buildTestCredential(id = "my-cred-id") + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals("my-cred-id", array.getMap(0)?.getString("id")) + } + + @Test + fun encodeCredential_serializesIssuerAndDisplayIssuerFields() { + val credential = buildTestCredential() + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertEquals("Ping", map.getString("issuer")) + assertEquals("Ping Identity", map.getString("displayIssuer")) + } + + @Test + fun encodeCredential_serializesTypeAsUppercaseEnumName() { + val totpCredential = buildTestCredential(oathType = OathType.TOTP) + val hotpCredential = buildTestCredential(id = "hotp-1", oathType = OathType.HOTP) + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(totpCredential, hotpCredential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val array = promise.resolvedValue as ReadableArray + assertEquals("TOTP", array.getMap(0)?.getString("type")) + assertEquals("HOTP", array.getMap(1)?.getString("type")) + } + + @Test + fun encodeCredential_serializesAlgorithmAsUppercaseEnumName() { + val sha256Credential = buildTestCredential(oathAlgorithm = OathAlgorithm.SHA256) + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(sha256Credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertEquals("SHA256", map.getString("algorithm")) + } + + @Test + fun encodeCredential_serializesCreatedAtAsDoubleMilliseconds() { + val credential = buildTestCredential() + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertEquals(1_700_000_000_000.0, map.getDouble("createdAt"), 0.0) + } + + @Test + fun encodeCredential_serializesIsLockedAsBoolean() { + val credential = buildTestCredential() + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertFalse(map.getBoolean("isLocked")) + } + + @Test + fun encodeCredential_doesNotSerializeSecretField() { + val credential = buildTestCredential() + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertFalse("secret must not be serialized to the bridge", map.hasKey("secret")) + assertFalse("secretKey must not be serialized to the bridge", map.hasKey("secretKey")) + } + + @Test + fun encodeCredential_nullableFieldsAreNullWhenAbsent() { + val credential = buildTestCredential() + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { mockClient.getCredentials() } returns Result.success(listOf(credential)) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.getCredentials(handle, promise) + scope.advanceUntilIdle() + promise.await() + + val map = (promise.resolvedValue as ReadableArray).getMap(0)!! + assertTrue(map.isNull("userId")) + assertTrue(map.isNull("resourceId")) + assertTrue(map.isNull("policies")) + assertTrue(map.isNull("lockingPolicy")) + assertTrue(map.isNull("imageURL")) + assertTrue(map.isNull("backgroundColor")) + } + + // --------------------------------------------------------------------------- + // generateCodeWithValidity bridge key contract + // --------------------------------------------------------------------------- + + @Test + fun generateCodeWithValidity_serializesAllFiveBridgeKeys() { + val mockClient = io.mockk.mockk(relaxed = true) + coEvery { + mockClient.generateCodeWithValidity("cred-gv-1") + } returns Result.success( + OathCodeInfo( + code = "123456", + timeRemaining = 15, + counter = 0L, + progress = 0.5, + totalPeriod = 30, + ) + ) + val handle = createHandleWithMockedClient(mockClient) + + val promise = TestPromise() + RNPingOathCommon.generateCodeWithValidity(handle, "cred-gv-1", promise) + scope.advanceUntilIdle() + promise.await() + + val map = promise.resolvedValue as ReadableMap + assertEquals("123456", map.getString("code")) + assertEquals(15, map.getInt("timeRemaining")) + assertEquals(0.0, map.getDouble("counter"), 0.0) + assertEquals(0.5, map.getDouble("progress"), 0.001) + assertEquals(30, map.getInt("totalPeriod")) + } } @Implements(className = "com.facebook.react.bridge.Arguments") diff --git a/packages/oath/ios/RNPingOathCommon.swift b/packages/oath/ios/RNPingOathCommon.swift index 7dbf8f54e..adcfdc7f2 100644 --- a/packages/oath/ios/RNPingOathCommon.swift +++ b/packages/oath/ios/RNPingOathCommon.swift @@ -429,14 +429,7 @@ public class RNPingOathCommon: NSObject { Task { do { let info = try await client.generateCodeWithValidity(credentialId) - let result: NSDictionary = [ - "code": info.code, - "timeRemaining": NSNumber(value: info.timeRemaining), - "counter": NSNumber(value: Double(info.counter)), // Double-backed to match Android's putDouble encoding - "progress": NSNumber(value: info.progress), - "totalPeriod": NSNumber(value: info.totalPeriod), - ] - handlers.resolve(result) + handlers.resolve(RNPingOathCommon.encodeCodeInfo(info)) } catch { handlers.reject(OathErrorMapper.mapError(error)) } @@ -615,7 +608,7 @@ public class RNPingOathCommon: NSObject { /// /// - Parameter c: The `OathCredential` to encode. /// - Returns: An `NSDictionary` suitable for passing through the React Native bridge. - private static func encodeCredential(_ c: OathCredential) -> NSDictionary { + static func encodeCredential(_ c: OathCredential) -> NSDictionary { [ "id": c.id, "issuer": c.issuer, @@ -638,6 +631,22 @@ public class RNPingOathCommon: NSObject { ] } + /// Encodes an `OathCodeInfo` into a bridge-safe `NSDictionary`. + /// + /// `counter` is encoded as `NSNumber(Double)` to match Android's `putDouble` encoding. + /// + /// - Parameter info: The `OathCodeInfo` to encode. + /// - Returns: An `NSDictionary` suitable for passing through the React Native bridge. + static func encodeCodeInfo(_ info: OathCodeInfo) -> NSDictionary { + [ + "code": info.code, + "timeRemaining": NSNumber(value: info.timeRemaining), + "counter": NSNumber(value: Double(info.counter)), + "progress": NSNumber(value: info.progress), + "totalPeriod": NSNumber(value: info.totalPeriod), + ] + } + /// Decodes a bridge `NSDictionary` into an `OathCredential`. /// /// All optional fields fall back to safe defaults. The `secret` parameter is diff --git a/packages/oath/ios/Tests/RNPingOathCommonTests.swift b/packages/oath/ios/Tests/RNPingOathCommonTests.swift index 0f386cdaf..1c68997c8 100644 --- a/packages/oath/ios/Tests/RNPingOathCommonTests.swift +++ b/packages/oath/ios/Tests/RNPingOathCommonTests.swift @@ -334,6 +334,128 @@ final class RNPingOathCommonTests: XCTestCase { ) } + // MARK: - encodeCredential field-name contracts + + private func makeCredential( + id: String = "cred-id-1", + issuer: String = "Acme", + accountName: String = "user@acme.com", + oathType: OathType = .totp, + oathAlgorithm: OathAlgorithm = .sha256, + createdAt: Date = Date(timeIntervalSince1970: 1_700_000), + isLocked: Bool = false, + userId: String? = nil, + resourceId: String? = nil + ) -> OathCredential { + OathCredential( + id: id, + userId: userId, + resourceId: resourceId, + issuer: issuer, + accountName: accountName, + oathType: oathType, + oathAlgorithm: oathAlgorithm, + createdAt: createdAt, + isLocked: isLocked, + secretKey: "secret" + ) + } + + func test_encodeCredential_idField() { + let cred = makeCredential(id: "test-id-abc") + let dict = RNPingOathCommon.encodeCredential(cred) + XCTAssertEqual(dict["id"] as? String, "test-id-abc") + } + + func test_encodeCredential_issuerField() { + let cred = makeCredential(issuer: "Ping Identity") + let dict = RNPingOathCommon.encodeCredential(cred) + XCTAssertEqual(dict["issuer"] as? String, "Ping Identity") + } + + func test_encodeCredential_displayIssuerField() { + let cred = makeCredential(issuer: "Acme") + let dict = RNPingOathCommon.encodeCredential(cred) + XCTAssertEqual(dict["displayIssuer"] as? String, "Acme") + } + + func test_encodeCredential_accountNameField() { + let cred = makeCredential(accountName: "bob@example.com") + let dict = RNPingOathCommon.encodeCredential(cred) + XCTAssertEqual(dict["accountName"] as? String, "bob@example.com") + } + + func test_encodeCredential_typeIsUppercased() { + let totpCred = makeCredential(oathType: .totp) + XCTAssertEqual(RNPingOathCommon.encodeCredential(totpCred)["type"] as? String, "TOTP") + + let hotpCred = makeCredential(oathType: .hotp) + XCTAssertEqual(RNPingOathCommon.encodeCredential(hotpCred)["type"] as? String, "HOTP") + } + + func test_encodeCredential_algorithmIsAlreadyUppercase() { + let cred = makeCredential(oathAlgorithm: .sha256) + XCTAssertEqual(RNPingOathCommon.encodeCredential(cred)["algorithm"] as? String, "SHA256") + } + + func test_encodeCredential_createdAtIsMillisecondsSinceEpoch() { + let epoch = Date(timeIntervalSince1970: 1_700_000) + let cred = makeCredential(createdAt: epoch) + let ms = RNPingOathCommon.encodeCredential(cred)["createdAt"] as? NSNumber + XCTAssertEqual(ms?.doubleValue ?? 0, epoch.timeIntervalSince1970 * 1000, accuracy: 1.0) + } + + func test_encodeCredential_isLockedField() { + XCTAssertEqual(RNPingOathCommon.encodeCredential(makeCredential(isLocked: false))["isLocked"] as? Bool, false) + XCTAssertEqual(RNPingOathCommon.encodeCredential(makeCredential(isLocked: true))["isLocked"] as? Bool, true) + } + + func test_encodeCredential_nullableFieldsAreNSNullWhenNil() { + let cred = makeCredential(userId: nil, resourceId: nil) + let dict = RNPingOathCommon.encodeCredential(cred) + XCTAssertTrue(dict["userId"] is NSNull) + XCTAssertTrue(dict["resourceId"] is NSNull) + XCTAssertTrue(dict["imageURL"] is NSNull) + XCTAssertTrue(dict["backgroundColor"] is NSNull) + XCTAssertTrue(dict["policies"] is NSNull) + XCTAssertTrue(dict["lockingPolicy"] is NSNull) + } + + func test_encodeCredential_secretNotPresent() { + let dict = RNPingOathCommon.encodeCredential(makeCredential()) + XCTAssertNil(dict["secret"]) + XCTAssertNil(dict["secretKey"]) + } + + // MARK: - encodeCodeInfo field-name contracts + + func test_encodeCodeInfo_codeField() { + let info = OathCodeInfo.forTotp(code: "123456", timeRemaining: 15, totalPeriod: 30) + XCTAssertEqual(RNPingOathCommon.encodeCodeInfo(info)["code"] as? String, "123456") + } + + func test_encodeCodeInfo_timeRemainingField() { + let info = OathCodeInfo.forTotp(code: "000000", timeRemaining: 15, totalPeriod: 30) + XCTAssertEqual(RNPingOathCommon.encodeCodeInfo(info)["timeRemaining"] as? NSNumber, NSNumber(value: 15)) + } + + func test_encodeCodeInfo_counterIsDoubleBackedNSNumber() { + let info = OathCodeInfo.forHotp(code: "654321", counter: 42) + let num = RNPingOathCommon.encodeCodeInfo(info)["counter"] as? NSNumber + XCTAssertEqual(num?.doubleValue, 42.0) + } + + func test_encodeCodeInfo_progressField() { + let info = OathCodeInfo.forTotp(code: "000000", timeRemaining: 15, totalPeriod: 30) + let progress = RNPingOathCommon.encodeCodeInfo(info)["progress"] as? NSNumber + XCTAssertNotNil(progress) + } + + func test_encodeCodeInfo_totalPeriodField() { + let info = OathCodeInfo.forTotp(code: "000000", timeRemaining: 15, totalPeriod: 30) + XCTAssertEqual(RNPingOathCommon.encodeCodeInfo(info)["totalPeriod"] as? NSNumber, NSNumber(value: 30)) + } + // MARK: - Policy evaluator producer tests func test_registerOathPolicyEvaluator_withBothPolicies_returnsDeterministicIdAndRoundTrips() { diff --git a/packages/push/jest.config.js b/packages/push/jest.config.js index 7132db90d..b281a75d9 100644 --- a/packages/push/jest.config.js +++ b/packages/push/jest.config.js @@ -25,4 +25,7 @@ module.exports = { }, ], ], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'], + coverageDirectory: './build/coverage', + coverageReporters: ['lcov', 'text-summary'], }; diff --git a/packages/types/jest.config.js b/packages/types/jest.config.js index ea2d06d1c..3fc18b9c7 100644 --- a/packages/types/jest.config.js +++ b/packages/types/jest.config.js @@ -25,4 +25,7 @@ module.exports = { }, ], ], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/__tests__/**'], + coverageDirectory: './build/coverage', + coverageReporters: ['lcov', 'text-summary'], };