diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index b1c4c7b1e..f7ec845f6 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -8,6 +8,7 @@ "large-files", "secure-storage", "crypto-wallet", + "testing-with-jest", "migration", "nitro-integration" ] diff --git a/docs/content/docs/guides/testing-with-jest.mdx b/docs/content/docs/guides/testing-with-jest.mdx new file mode 100644 index 000000000..6ea55959c --- /dev/null +++ b/docs/content/docs/guides/testing-with-jest.mdx @@ -0,0 +1,67 @@ +--- +title: Testing with Jest +description: Run code that uses react-native-quick-crypto in Jest +--- + +import { Callout } from 'fumadocs-ui/components/callout'; + +Jest runs your tests in Node, where the native module behind `react-native-quick-crypto` doesn't exist, so importing it throws. The package ships a mock for this. + +Node already implements the same `crypto` API that this library brings to React Native, so the mock forwards to `node:crypto` instead of stubbing each function. Hashes, ciphertexts and signatures in your tests are real, and your code runs unchanged. + +## Setup + +Register the mock once in a [Jest setup file](https://jestjs.io/docs/configuration#setupfilesafterenv-array): + +```js title="jest.setup.js" +jest.mock('react-native-quick-crypto', () => + require('react-native-quick-crypto/jest'), +); +``` + +```js title="jest.config.js" +module.exports = { + preset: 'react-native', // or 'jest-expo' + setupFilesAfterEnv: ['/jest.setup.js'], +}; +``` + +Or call `jest.mock(...)` at the top of an individual test file. + +## Usage + +Default and named imports both work: + +```ts +import QuickCrypto, { createHash, randomBytes } from 'react-native-quick-crypto'; + +test('hashes a password', () => { + const salt = randomBytes(16); + const hash = createHash('sha256').update(salt).update('hunter2').digest('hex'); + expect(hash).toHaveLength(64); +}); + +test('verifies an HMAC with subtle', async () => { + const key = await QuickCrypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + const data = new TextEncoder().encode('hello'); + const signature = await QuickCrypto.subtle.sign('HMAC', key, data); + expect(await QuickCrypto.subtle.verify('HMAC', key, signature, data)).toBe(true); +}); +``` + +`install()` is a no-op in the mock, because Node already provides `globalThis.crypto` and `Buffer`. + + + The mock only covers what `node:crypto` provides. Library-specific APIs such as `blake3`, ML-KEM, `xsalsa20`, `randomUUIDv7`, and the `hkdfExtract`/`hkdfExpand` helpers are `undefined`, and `argon2` needs Node 24.7 or newer. If your code uses them, extend the mock: + + ```js + jest.mock('react-native-quick-crypto', () => ({ + ...require('react-native-quick-crypto/jest'), + blake3: jest.fn(), + })); + ``` + diff --git a/packages/react-native-quick-crypto/eslint.config.mjs b/packages/react-native-quick-crypto/eslint.config.mjs index d4d51929a..8e8e7af31 100644 --- a/packages/react-native-quick-crypto/eslint.config.mjs +++ b/packages/react-native-quick-crypto/eslint.config.mjs @@ -18,7 +18,10 @@ export default [ languageOptions: { parser: typescriptEslint.parser, parserOptions: { - projectService: true, + projectService: { + // The Jest mock is plain CommonJS shipped as-is, outside tsconfig. + allowDefaultProject: ['jest/*.js'], + }, }, }, plugins: { @@ -49,6 +52,17 @@ export default [ 'react-native/no-inline-styles': 'warn', }, }, + // Jest mock: CommonJS that runs in Node + { + files: ['jest/*.js'], + languageOptions: { + sourceType: 'commonjs', + globals: { require: 'readonly', module: 'writable' }, + }, + rules: { + '@typescript-eslint/no-require-imports': 'off', + }, + }, // Ignore patterns { ignores: [ diff --git a/packages/react-native-quick-crypto/jest/index.js b/packages/react-native-quick-crypto/jest/index.js new file mode 100644 index 000000000..626892a81 --- /dev/null +++ b/packages/react-native-quick-crypto/jest/index.js @@ -0,0 +1,16 @@ +// Jest mock: forwards to node:crypto. See docs/content/docs/guides/testing-with-jest.mdx. +const crypto = require('node:crypto'); +const { Buffer } = require('node:buffer'); + +const QuickCrypto = { + ...crypto, + Buffer, + CryptoKey: globalThis.CryptoKey, + install: () => {}, +}; + +module.exports = { + __esModule: true, + default: QuickCrypto, + ...QuickCrypto, +}; diff --git a/packages/react-native-quick-crypto/package.json b/packages/react-native-quick-crypto/package.json index 240f1356e..c18be2033 100644 --- a/packages/react-native-quick-crypto/package.json +++ b/packages/react-native-quick-crypto/package.json @@ -25,6 +25,7 @@ "files": [ "src", "lib", + "jest", "android/build.gradle", "android/gradle.properties", "android/CMakeLists.txt", diff --git a/packages/react-native-quick-crypto/test/jestMock.test.ts b/packages/react-native-quick-crypto/test/jestMock.test.ts new file mode 100644 index 000000000..9261b1e6f --- /dev/null +++ b/packages/react-native-quick-crypto/test/jestMock.test.ts @@ -0,0 +1,58 @@ +// `virtual` because the package can't resolve its own name inside this repo; +// apps use the same call without it. +jest.mock('react-native-quick-crypto', () => require('../jest'), { + virtual: true, +}); + +import QuickCrypto, { + CryptoKey, + createCipheriv, + createDecipheriv, + createHash, + install, + randomBytes, +} from 'react-native-quick-crypto'; + +test('jest mock exposes the default export and named exports', () => { + expect(typeof QuickCrypto.createHash).toBe('function'); + expect(QuickCrypto.createHash).toBe(createHash); + expect(typeof QuickCrypto.Buffer.from).toBe('function'); + expect(() => install()).not.toThrow(); +}); + +test('jest mock hashes with real results', () => { + expect(createHash('sha256').update('abc').digest('hex')).toBe( + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + ); +}); + +test('jest mock round-trips AES-256-GCM', () => { + const key = randomBytes(32); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ciphertext = Buffer.concat([ + cipher.update('secret', 'utf8'), + cipher.final(), + ]); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(cipher.getAuthTag()); + const plaintext = Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]); + expect(plaintext.toString('utf8')).toBe('secret'); +}); + +test('jest mock subtle.verify resolves to a boolean', async () => { + const key = await QuickCrypto.subtle.generateKey( + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], + ); + expect(key).toBeInstanceOf(CryptoKey); + const data = new TextEncoder().encode('hello'); + const signature = await QuickCrypto.subtle.sign('HMAC', key, data); + await expect( + QuickCrypto.subtle.verify('HMAC', key, signature, data), + ).resolves.toBe(true); +});