diff --git a/packages/expo/package.json b/packages/expo/package.json index fea14f8b61..20330837ad 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -62,7 +62,8 @@ "dev": "tsup --watch", "dev:publish": "pnpm dev -- --env.publish", "lint": "eslint src/", - "publish:local": "pnpm yalc push --replace --sig" + "publish:local": "pnpm yalc push --replace --sig", + "test": "vitest" }, "dependencies": { "@clerk/clerk-js": "workspace:*", diff --git a/packages/expo/src/secure-store/__tests__/dummy-test-data.ts b/packages/expo/src/secure-store/__tests__/dummy-test-data.ts new file mode 100644 index 0000000000..4236e57585 --- /dev/null +++ b/packages/expo/src/secure-store/__tests__/dummy-test-data.ts @@ -0,0 +1,226 @@ +export const DUMMY_TEST_LARGE_JSON = { + object: 'environment', + id: '', + auth_config: { object: 'auth_config', id: '', single_session_mode: true }, + display_config: { + object: 'display_config', + id: 'display_config_DUMMY_ID', + instance_environment_type: 'development', + application_name: '', + theme: { + buttons: { font_color: '#ffffff', font_family: 'sans-serif', font_weight: '600' }, + general: { + color: '#6c47ff', + padding: '1em', + box_shadow: '0 2px 8px rgba(0, 0, 0, 0.2)', + font_color: '#151515', + font_family: 'sans-serif', + border_radius: '0.5em', + background_color: '#ffffff', + label_font_weight: '600', + }, + accounts: { background_color: '#ffffff' }, + }, + preferred_sign_in_strategy: 'password', + logo_image_url: '', + favicon_image_url: '', + home_url: '', + sign_in_url: '', + sign_up_url: '', + user_profile_url: '', + after_sign_in_url: '', + after_sign_up_url: '', + after_sign_out_one_url: '', + after_sign_out_all_url: '', + after_switch_session_url: '', + branded: true, + captcha_public_key: null, + captcha_widget_type: null, + captcha_provider: null, + captcha_public_key_invisible: null, + captcha_oauth_bypass: [], + captcha_heartbeat: false, + support_email: '', + clerk_js_version: '5', + organization_profile_url: '', + create_organization_url: '', + after_leave_organization_url: '', + after_create_organization_url: '', + google_one_tap_client_id: null, + show_devmode_warning: true, + terms_url: null, + privacy_policy_url: null, + waitlist_url: '', + after_join_waitlist_url: '', + }, + user_settings: { + social: { + oauth_google: { + enabled: true, + required: false, + authenticatable: true, + block_email_subaddresses: true, + strategy: 'oauth_google', + not_selectable: false, + deprecated: false, + name: 'Google', + logo_url: 'https://img.clerk.com/static/google.png', + }, + }, + saml: { enabled: false }, + attributes: { + email_address: { + enabled: true, + required: true, + used_for_first_factor: true, + first_factors: ['email_code'], + used_for_second_factor: false, + second_factors: [], + verifications: ['email_code'], + verify_at_sign_up: true, + name: 'email_address', + }, + phone_number: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'phone_number', + }, + username: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'username', + }, + web3_wallet: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'web3_wallet', + }, + first_name: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'first_name', + }, + last_name: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'last_name', + }, + password: { + enabled: true, + required: true, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'password', + }, + authenticator_app: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'authenticator_app', + }, + ticket: { + enabled: true, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'ticket', + }, + backup_code: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'backup_code', + }, + passkey: { + enabled: false, + required: false, + used_for_first_factor: false, + first_factors: [], + used_for_second_factor: false, + second_factors: [], + verifications: [], + verify_at_sign_up: false, + name: 'passkey', + }, + }, + actions: { delete_self: true, create_organization: true, create_organizations_limit: null }, + sign_in: { second_factor: { required: false } }, + sign_up: { + captcha_enabled: false, + captcha_widget_type: 'smart', + custom_action_required: false, + progressive: true, + mode: 'public', + legal_consent_enabled: false, + }, + password_settings: { + disable_hibp: false, + min_length: 8, + max_length: 72, + require_special_char: false, + require_numbers: false, + require_uppercase: false, + require_lowercase: false, + show_zxcvbn: false, + min_zxcvbn_strength: 0, + enforce_hibp_on_sign_in: true, + allowed_special_characters: '!"#$%&\'()*+,-./:;<=>?@[]^_`{|}~', + }, + passkey_settings: { allow_autofill: true, show_sign_in_button: true }, + }, + organization_settings: { + enabled: false, + max_allowed_memberships: 5, + actions: { admin_delete: true }, + domains: { enabled: false, enrollment_modes: [], default_role: null }, + }, + maintenance_mode: false, +} as const; diff --git a/packages/expo/src/secure-store/__tests__/secure-store.test.ts b/packages/expo/src/secure-store/__tests__/secure-store.test.ts new file mode 100644 index 0000000000..087563f8e1 --- /dev/null +++ b/packages/expo/src/secure-store/__tests__/secure-store.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { createSecureStore } from '../secure-store'; +import { DUMMY_TEST_LARGE_JSON } from './dummy-test-data'; + +const KEY = 'key'; + +const splitIntoChunks = (value: string): string[] => { + // Array.from is used to handle unicode characters correctly + const characters = Array.from(value); + + const chunks: string[] = []; + for (let i = 0; i < characters.length; i += 1024) { + chunks.push(characters.slice(i, i + 1024).join('')); + } + return chunks; +}; + +const mocks = vi.hoisted(() => { + return { + setItemAsync: vi.fn(), + getItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), + }; +}); + +vi.mock('expo-secure-store', () => { + return { + setItemAsync: mocks.setItemAsync, + getItemAsync: mocks.getItemAsync, + deleteItemAsync: mocks.deleteItemAsync, + }; +}); + +describe('SecureStore', () => { + describe('generic', () => { + beforeEach(() => { + vi.useFakeTimers(); + + const createSecureStoreMock = () => { + const _map = new Map(); + return { + setItemAsync: (key: string, value: string): Promise => { + _map.set(key, value); + return Promise.resolve(); + }, + getItemAsync: (key: string): Promise => { + return Promise.resolve(_map.get(key) || null); + }, + deleteItemAsync: (key: string): Promise => { + _map.delete(key); + return Promise.resolve(); + }, + }; + }; + const secureStoreMock = createSecureStoreMock(); + mocks.setItemAsync.mockImplementation(secureStoreMock.setItemAsync); + mocks.getItemAsync.mockImplementation(secureStoreMock.getItemAsync); + mocks.deleteItemAsync.mockImplementation(secureStoreMock.deleteItemAsync); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('sets a value correctly', async () => { + const secureStore = createSecureStore(); + await secureStore.set(KEY, 'value'); + await vi.runAllTimersAsync(); + expect(await secureStore.get(KEY)).toBe('value'); + }); + + test('returns null for a non-existent key', async () => { + const secureStore = createSecureStore(); + expect(await secureStore.get(KEY)).toBeNull(); + }); + + test('returns the last set value', async () => { + const secureStore = createSecureStore(); + await secureStore.set(KEY, 'value1'); + await secureStore.set(KEY, 'value2'); + await vi.runAllTimersAsync(); + expect(await secureStore.get(KEY)).toBe('value2'); + await secureStore.set(KEY, 'value3'); + await vi.runAllTimersAsync(); + expect(await secureStore.get(KEY)).toBe('value3'); + }); + }); + + describe('delayed write', () => { + beforeEach(() => { + vi.useFakeTimers(); + const createSecureStoreMock = () => { + const _map = new Map(); + return { + setItemAsync: (key: string, value: string): Promise => { + _map.set(key, value); + return new Promise(resolve => { + setTimeout(() => resolve(), 3000); + }); + }, + getItemAsync: (key: string): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(_map.get(key) || null); + }, 3000); + }); + }, + deleteItemAsync: (key: string): Promise => { + _map.delete(key); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); + }, + }; + }; + const secureStoreMock = createSecureStoreMock(); + mocks.setItemAsync.mockImplementation(secureStoreMock.setItemAsync); + mocks.getItemAsync.mockImplementation(secureStoreMock.getItemAsync); + mocks.deleteItemAsync.mockImplementation(secureStoreMock.deleteItemAsync); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('sets a value async', async () => { + const secureStore = createSecureStore(); + secureStore.set(KEY, 'value'); + await vi.runAllTimersAsync(); + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe('value'); + }); + + test('sets the correct last value when many sets happen almost at the same time', async () => { + const secureStore = createSecureStore(); + secureStore.set(KEY, 'value'); + secureStore.set(KEY, 'value2'); + secureStore.set(KEY, 'value3'); + secureStore.set(KEY, 'value4'); + secureStore.set(KEY, 'value5'); + secureStore.set(KEY, 'value6'); + await vi.runAllTimersAsync(); + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe('value6'); + }); + }); + + describe('chunking', () => { + test('splits a value that is too large to be saved in one go', async () => { + vi.useFakeTimers(); + + const _map = new Map(); + const setItemAsync = (key: string, value: string): Promise => { + _map.set(key, value); + return new Promise(resolve => { + setTimeout(() => resolve(), 3000); + }); + }; + const getItemAsync = (key: string): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(_map.get(key) || null); + }, 3000); + }); + }; + const deleteItemAsync = (key: string): Promise => { + _map.delete(key); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); + }; + + mocks.setItemAsync.mockImplementation(setItemAsync); + mocks.getItemAsync.mockImplementation(getItemAsync); + mocks.deleteItemAsync.mockImplementation(deleteItemAsync); + + const secureStore = createSecureStore(); + secureStore.set(KEY, JSON.stringify(DUMMY_TEST_LARGE_JSON)); + await vi.runAllTimersAsync(); + + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe(JSON.stringify(DUMMY_TEST_LARGE_JSON)); + + const chunks = splitIntoChunks(JSON.stringify(DUMMY_TEST_LARGE_JSON)); + + expect(_map.get('key-B-metadata')).toBe(JSON.stringify({ totalChunks: chunks.length })); + for (let i = 0; i < chunks.length; i++) { + expect(_map.get(`key-B-chunk-${i}`)).toBe(chunks[i]); + } + expect(_map.get('key-B-complete')).toBe('true'); + expect(_map.get('key-latest')).toBe('B'); + + vi.useRealTimers(); + }); + + test('keeps the last 2 values in two different slots A/B', async () => { + vi.useFakeTimers(); + + const _map = new Map(); + const setItemAsync = (key: string, value: string): Promise => { + _map.set(key, value); + return new Promise(resolve => { + setTimeout(() => resolve(), 3000); + }); + }; + const getItemAsync = (key: string): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(_map.get(key) || null); + }, 3000); + }); + }; + const deleteItemAsync = (key: string): Promise => { + _map.delete(key); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); + }; + + mocks.setItemAsync.mockImplementation(setItemAsync); + mocks.getItemAsync.mockImplementation(getItemAsync); + mocks.deleteItemAsync.mockImplementation(deleteItemAsync); + + const secureStore = createSecureStore(); + secureStore.set(KEY, JSON.stringify(DUMMY_TEST_LARGE_JSON)); + await vi.runAllTimersAsync(); + secureStore.set(KEY, 'new value'); + await vi.runAllTimersAsync(); + + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe('new value'); + + const chunks = splitIntoChunks(JSON.stringify(DUMMY_TEST_LARGE_JSON)); + + expect(_map.get('key-B-metadata')).toBe(JSON.stringify({ totalChunks: chunks.length })); + for (let i = 0; i < chunks.length; i++) { + expect(_map.get(`key-B-chunk-${i}`)).toBe(chunks[i]); + } + + expect(_map.get('key-A-metadata')).toBe(JSON.stringify({ totalChunks: 1 })); + expect(_map.get('key-A-chunk-0')).toBe('new value'); + expect(_map.get('key-A-complete')).toBe('true'); + expect(_map.get('key-latest')).toBe('A'); + + vi.useRealTimers(); + }); + }); + + describe('failures', () => { + test('does not change the value if set fails', async () => { + vi.useFakeTimers(); + const _map = new Map(); + _map.set('key-latest', 'A'); + _map.set('key-A-metadata', JSON.stringify({ totalChunks: 1 })); + _map.set('key-A-chunk-0', 'initial value'); + _map.set('key-A-complete', 'true'); + let countBeforeFail = 0; + const setItemAsync = (key: string, value: string): Promise => { + // the first two sets will succeed, the rest will fail + if (countBeforeFail < 2) { + _map.set(key, value); + countBeforeFail++; + } + return new Promise(resolve => { + setTimeout(() => resolve(), 3000); + }); + }; + const getItemAsync = (key: string): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(_map.get(key) || null); + }, 3000); + }); + }; + const deleteItemAsync = (key: string): Promise => { + _map.delete(key); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); + }; + + mocks.setItemAsync.mockImplementation(setItemAsync); + mocks.getItemAsync.mockImplementation(getItemAsync); + mocks.deleteItemAsync.mockImplementation(deleteItemAsync); + + const secureStore = createSecureStore(); + secureStore.set(KEY, 'new value'); + await vi.runAllTimersAsync(); + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe('initial value'); + vi.useRealTimers(); + }); + + test('get returns null if set fails', async () => { + vi.useFakeTimers(); + const _map = new Map(); + _map.set('key-latest', 'A'); + _map.set('key-A-metadata', JSON.stringify({ totalChunks: 1 })); + _map.set('key-A-chunk-0', 'initial value'); + _map.set('key-A-complete', 'false'); + let countBeforeFail = 0; + const setItemAsync = (key: string, value: string): Promise => { + // the first two sets will succeed, the rest will fail + if (countBeforeFail < 2) { + _map.set(key, value); + countBeforeFail++; + } + return new Promise(resolve => { + setTimeout(() => resolve(), 3000); + }); + }; + const getItemAsync = (key: string): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(_map.get(key) || null); + }, 3000); + }); + }; + const deleteItemAsync = (key: string): Promise => { + _map.delete(key); + return new Promise(resolve => { + setTimeout(resolve, 3000); + }); + }; + + mocks.setItemAsync.mockImplementation(setItemAsync); + mocks.getItemAsync.mockImplementation(getItemAsync); + mocks.deleteItemAsync.mockImplementation(deleteItemAsync); + + const secureStore = createSecureStore(); + secureStore.set(KEY, 'new value'); + await vi.runAllTimersAsync(); + const value = secureStore.get(KEY); + await vi.runAllTimersAsync(); + expect(await value).toBe(null); + vi.useRealTimers(); + }); + }); +}); diff --git a/packages/expo/src/secure-store/secure-store.ts b/packages/expo/src/secure-store/secure-store.ts index c27c8fb020..0d99aa9cdb 100644 --- a/packages/expo/src/secure-store/secure-store.ts +++ b/packages/expo/src/secure-store/secure-store.ts @@ -37,7 +37,8 @@ export const createSecureStore = (): IStorage => { const set = (key: string, value: string): Promise => { queue.push({ key, value }); - return processQueue(); + void processQueue(); + return Promise.resolve(); }; const processQueue = async (): Promise => { diff --git a/packages/expo/tsconfig.declarations.json b/packages/expo/tsconfig.declarations.json index 4a7735336e..a1c4fc7c0f 100644 --- a/packages/expo/tsconfig.declarations.json +++ b/packages/expo/tsconfig.declarations.json @@ -8,5 +8,6 @@ "declarationMap": true, "sourceMap": false, "declarationDir": "./dist" - } + }, + "exclude": ["**/__tests__/**/*"] } diff --git a/packages/expo/tsconfig.test.json b/packages/expo/tsconfig.test.json new file mode 100644 index 0000000000..5635d6cd1b --- /dev/null +++ b/packages/expo/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + } +} diff --git a/packages/expo/tsup.config.ts b/packages/expo/tsup.config.ts index 2d375c4d69..abfb57bec4 100644 --- a/packages/expo/tsup.config.ts +++ b/packages/expo/tsup.config.ts @@ -12,7 +12,7 @@ export default defineConfig(overrideOptions => { const options: Options = { format: 'cjs', outDir: './dist', - entry: ['./src/**/*.{ts,tsx,js,jsx}'], + entry: ['./src/**/*.{ts,tsx,js,jsx}', '!./src/**/*.test.{ts,tsx}', '!./src/**/__tests__/**'], bundle: false, clean: true, minify: false, diff --git a/packages/expo/vitest.config.mts b/packages/expo/vitest.config.mts new file mode 100644 index 0000000000..20bc22a33d --- /dev/null +++ b/packages/expo/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { + environment: 'jsdom', + includeSource: ['**/*.{js,ts,jsx,tsx}'], + setupFiles: './vitest.setup.mts', + }, +}); diff --git a/packages/expo/vitest.setup.mts b/packages/expo/vitest.setup.mts new file mode 100644 index 0000000000..3a6868a950 --- /dev/null +++ b/packages/expo/vitest.setup.mts @@ -0,0 +1,6 @@ +import { beforeAll } from 'vitest'; + +globalThis.PACKAGE_NAME = '@clerk/clerk-expo'; +globalThis.PACKAGE_VERSION = '0.0.0-test'; + +beforeAll(() => {});